feat: add manual save mode to file editor
Added a persistent autosave toggle for file editing Manual save mode applies across current and future opened files Updated localized toolbar labels
This commit is contained in:
@@ -10,6 +10,8 @@ import {
|
|||||||
RiFolder3Fill,
|
RiFolder3Fill,
|
||||||
RiFolderOpenFill,
|
RiFolderOpenFill,
|
||||||
RiFolderReceivedLine,
|
RiFolderReceivedLine,
|
||||||
|
RiFileCheckFill,
|
||||||
|
RiFileCheckLine,
|
||||||
RiFullscreenExitLine,
|
RiFullscreenExitLine,
|
||||||
RiFullscreenLine,
|
RiFullscreenLine,
|
||||||
RiLoader4Line,
|
RiLoader4Line,
|
||||||
@@ -266,6 +268,19 @@ const isDirectoryReadError = (error: unknown): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_VIEW_CHARS = 200_000;
|
const MAX_VIEW_CHARS = 200_000;
|
||||||
|
const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled';
|
||||||
|
|
||||||
|
const getInitialAutoSaveEnabled = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(FILE_EDITOR_AUTO_SAVE_KEY) !== 'false';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getFileIcon = (filePath: string, extension?: string): React.ReactNode => {
|
const getFileIcon = (filePath: string, extension?: string): React.ReactNode => {
|
||||||
return <FileTypeIcon filePath={filePath} extension={extension} />;
|
return <FileTypeIcon filePath={filePath} extension={extension} />;
|
||||||
@@ -649,6 +664,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const autoSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
||||||
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
||||||
|
const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled);
|
||||||
|
|
||||||
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
||||||
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
|
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
|
||||||
@@ -1331,12 +1347,32 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
};
|
};
|
||||||
}, [isDirty, setMainTabGuard]);
|
}, [isDirty, setMainTabGuard]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(FILE_EDITOR_AUTO_SAVE_KEY, autoSaveEnabled ? 'true' : 'false');
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors; the in-memory preference still applies.
|
||||||
|
}
|
||||||
|
}, [autoSaveEnabled]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (autoSaveEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoSaveStatus('idle');
|
||||||
|
if (autoSaveTimerRef.current) {
|
||||||
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
|
autoSaveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [autoSaveEnabled]);
|
||||||
|
|
||||||
// Auto-save: debounce 1.5s after user stops typing
|
// Auto-save: debounce 1.5s after user stops typing
|
||||||
const AUTO_SAVE_DELAY = 1500;
|
const AUTO_SAVE_DELAY = 1500;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const canWrite = Boolean(selectedFile && files.writeFile);
|
const canWrite = Boolean(selectedFile && files.writeFile);
|
||||||
if (!isDirty || !canWrite || isSaving) {
|
if (!autoSaveEnabled || !isDirty || !canWrite || isSaving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1353,7 +1389,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
autoSaveTimerRef.current = null;
|
autoSaveTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [draftContent, isDirty, selectedFile, files.writeFile, isSaving, saveDraft]);
|
}, [autoSaveEnabled, draftContent, isDirty, selectedFile, files.writeFile, isSaving, saveDraft]);
|
||||||
|
|
||||||
// Reset auto-save status when switching files
|
// Reset auto-save status when switching files
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -2478,28 +2514,43 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm">
|
<div className="pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm">
|
||||||
{canEdit && textViewMode === 'edit' && (
|
{canEdit && textViewMode === 'edit' && (
|
||||||
isSaving ? (
|
<>
|
||||||
<span className="flex items-center gap-1 px-1 text-muted-foreground typography-meta">
|
{isSaving ? (
|
||||||
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
<span className="flex items-center gap-1 px-1 text-muted-foreground typography-meta">
|
||||||
{t('filesView.editor.saving')}
|
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
||||||
</span>
|
{t('filesView.editor.saving')}
|
||||||
) : autoSaveStatus === 'saved' && !isDirty ? (
|
</span>
|
||||||
<span className="flex items-center gap-1 px-1 text-[color:var(--status-success)] typography-meta">
|
) : autoSaveEnabled && autoSaveStatus === 'saved' && !isDirty ? (
|
||||||
<RiCheckLine className="h-3.5 w-3.5" />
|
<span className="flex items-center gap-1 px-1 text-[color:var(--status-success)] typography-meta">
|
||||||
{t('filesView.editor.saved')}
|
<RiCheckLine className="h-3.5 w-3.5" />
|
||||||
</span>
|
{t('filesView.editor.saved')}
|
||||||
) : isDirty ? (
|
</span>
|
||||||
|
) : isDirty ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void saveDraft()}
|
||||||
|
className="h-6 gap-1 px-1 text-muted-foreground opacity-80 hover:bg-transparent hover:opacity-100 focus-visible:bg-transparent active:bg-transparent"
|
||||||
|
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` })}
|
||||||
|
aria-label={t('filesView.editor.saveAria', { shortcut: `${getModifierLabel()}+S` })}
|
||||||
|
>
|
||||||
|
<RiSave3Line className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void saveDraft()}
|
onClick={() => setAutoSaveEnabled((enabled) => !enabled)}
|
||||||
className="h-6 gap-1 px-1 text-muted-foreground opacity-80 hover:bg-transparent hover:opacity-100 focus-visible:bg-transparent active:bg-transparent"
|
className={cn(
|
||||||
title={t('filesView.editor.saveNowTitle', { shortcut: `${getModifierLabel()}+S` })}
|
'h-6 w-6 p-0 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent',
|
||||||
aria-label={t('filesView.editor.saveAria', { shortcut: `${getModifierLabel()}+S` })}
|
autoSaveEnabled ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
|
||||||
|
)}
|
||||||
|
title={autoSaveEnabled ? t('filesView.editor.autoSaveOn') : t('filesView.editor.manualSave')}
|
||||||
|
aria-label={autoSaveEnabled ? t('filesView.editor.autoSaveOn') : t('filesView.editor.manualSave')}
|
||||||
>
|
>
|
||||||
<RiSave3Line className="h-4 w-4" />
|
{autoSaveEnabled ? <RiFileCheckFill className="size-4" /> : <RiFileCheckLine className="size-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenu onOpenChange={handleToolbarDropdownOpenChange}>
|
<DropdownMenu onOpenChange={handleToolbarDropdownOpenChange}>
|
||||||
|
|||||||
@@ -851,7 +851,10 @@ export const dict = {
|
|||||||
'filesView.dialog.delete.confirm': 'Delete',
|
'filesView.dialog.delete.confirm': 'Delete',
|
||||||
'filesView.editor.saving': 'Saving...',
|
'filesView.editor.saving': 'Saving...',
|
||||||
'filesView.editor.saved': 'Saved',
|
'filesView.editor.saved': 'Saved',
|
||||||
|
'filesView.editor.autoSaveOn': 'Auto-save on',
|
||||||
|
'filesView.editor.manualSave': 'Manual save',
|
||||||
'filesView.editor.saveNowTitle': 'Save now ({shortcut}) - auto-saves after 1.5s',
|
'filesView.editor.saveNowTitle': 'Save now ({shortcut}) - auto-saves after 1.5s',
|
||||||
|
'filesView.editor.saveNowManualTitle': 'Save now ({shortcut})',
|
||||||
'filesView.editor.saveAria': 'Save ({shortcut})',
|
'filesView.editor.saveAria': 'Save ({shortcut})',
|
||||||
'filesView.editor.openInDesktopApp': 'Open in desktop app',
|
'filesView.editor.openInDesktopApp': 'Open in desktop app',
|
||||||
'filesView.editor.refreshApps': 'Refresh Apps',
|
'filesView.editor.refreshApps': 'Refresh Apps',
|
||||||
|
|||||||
@@ -817,7 +817,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"filesView.dialog.delete.confirm": "Eliminar",
|
"filesView.dialog.delete.confirm": "Eliminar",
|
||||||
"filesView.editor.saving": "Guardando...",
|
"filesView.editor.saving": "Guardando...",
|
||||||
"filesView.editor.saved": "Guardado",
|
"filesView.editor.saved": "Guardado",
|
||||||
|
"filesView.editor.autoSaveOn": "Guardado automático activado",
|
||||||
|
"filesView.editor.manualSave": "Guardado manual",
|
||||||
"filesView.editor.saveNowTitle": "Guardar ahora ({shortcut}) - se guarda automáticamente después de 1.5 segundos",
|
"filesView.editor.saveNowTitle": "Guardar ahora ({shortcut}) - se guarda automáticamente después de 1.5 segundos",
|
||||||
|
"filesView.editor.saveNowManualTitle": "Guardar ahora ({shortcut})",
|
||||||
"filesView.editor.saveAria": "Guardar ({shortcut})",
|
"filesView.editor.saveAria": "Guardar ({shortcut})",
|
||||||
"filesView.editor.openInDesktopApp": "Abrir en la aplicación de escritorio",
|
"filesView.editor.openInDesktopApp": "Abrir en la aplicación de escritorio",
|
||||||
"filesView.editor.refreshApps": "Actualizar aplicaciones",
|
"filesView.editor.refreshApps": "Actualizar aplicaciones",
|
||||||
|
|||||||
@@ -854,7 +854,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.dialog.delete.confirm': '삭제',
|
'filesView.dialog.delete.confirm': '삭제',
|
||||||
'filesView.editor.saving': '저장 중…',
|
'filesView.editor.saving': '저장 중…',
|
||||||
'filesView.editor.saved': '저장됨',
|
'filesView.editor.saved': '저장됨',
|
||||||
|
'filesView.editor.autoSaveOn': '자동 저장 켜짐',
|
||||||
|
'filesView.editor.manualSave': '수동 저장',
|
||||||
'filesView.editor.saveNowTitle': '지금 저장({shortcut}) · 1.5초 후 자동 저장',
|
'filesView.editor.saveNowTitle': '지금 저장({shortcut}) · 1.5초 후 자동 저장',
|
||||||
|
'filesView.editor.saveNowManualTitle': '지금 저장({shortcut})',
|
||||||
'filesView.editor.saveAria': '저장 ({shortcut})',
|
'filesView.editor.saveAria': '저장 ({shortcut})',
|
||||||
'filesView.editor.openInDesktopApp': '데스크톱 앱에서 열기',
|
'filesView.editor.openInDesktopApp': '데스크톱 앱에서 열기',
|
||||||
'filesView.editor.refreshApps': '앱 새로고침',
|
'filesView.editor.refreshApps': '앱 새로고침',
|
||||||
|
|||||||
@@ -817,7 +817,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"filesView.dialog.delete.confirm": "Excluir",
|
"filesView.dialog.delete.confirm": "Excluir",
|
||||||
"filesView.editor.saving": "Salvando...",
|
"filesView.editor.saving": "Salvando...",
|
||||||
"filesView.editor.saved": "Salvo",
|
"filesView.editor.saved": "Salvo",
|
||||||
|
"filesView.editor.autoSaveOn": "Salvamento automático ativado",
|
||||||
|
"filesView.editor.manualSave": "Salvamento manual",
|
||||||
"filesView.editor.saveNowTitle": "Salvar agora ({shortcut}) - salva automaticamente após 1,5 segundo",
|
"filesView.editor.saveNowTitle": "Salvar agora ({shortcut}) - salva automaticamente após 1,5 segundo",
|
||||||
|
"filesView.editor.saveNowManualTitle": "Salvar agora ({shortcut})",
|
||||||
"filesView.editor.saveAria": "Salvar ({shortcut})",
|
"filesView.editor.saveAria": "Salvar ({shortcut})",
|
||||||
"filesView.editor.openInDesktopApp": "Abrir no aplicativo de desktop",
|
"filesView.editor.openInDesktopApp": "Abrir no aplicativo de desktop",
|
||||||
"filesView.editor.refreshApps": "Atualizar aplicativos",
|
"filesView.editor.refreshApps": "Atualizar aplicativos",
|
||||||
|
|||||||
@@ -817,7 +817,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"filesView.dialog.delete.confirm": "Видалити",
|
"filesView.dialog.delete.confirm": "Видалити",
|
||||||
"filesView.editor.saving": "Збереження...",
|
"filesView.editor.saving": "Збереження...",
|
||||||
"filesView.editor.saved": "Збережено",
|
"filesView.editor.saved": "Збережено",
|
||||||
|
"filesView.editor.autoSaveOn": "Автозбереження ввімкнено",
|
||||||
|
"filesView.editor.manualSave": "Ручне збереження",
|
||||||
"filesView.editor.saveNowTitle": "Зберегти зараз ({shortcut}) - автоматично зберігає через 1,5 с",
|
"filesView.editor.saveNowTitle": "Зберегти зараз ({shortcut}) - автоматично зберігає через 1,5 с",
|
||||||
|
"filesView.editor.saveNowManualTitle": "Зберегти зараз ({shortcut})",
|
||||||
"filesView.editor.saveAria": "Зберегти ({shortcut})",
|
"filesView.editor.saveAria": "Зберегти ({shortcut})",
|
||||||
"filesView.editor.openInDesktopApp": "Відкрити в десктопному застосунку",
|
"filesView.editor.openInDesktopApp": "Відкрити в десктопному застосунку",
|
||||||
"filesView.editor.refreshApps": "Оновити застосунки",
|
"filesView.editor.refreshApps": "Оновити застосунки",
|
||||||
|
|||||||
@@ -817,7 +817,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.dialog.delete.confirm': '删除',
|
'filesView.dialog.delete.confirm': '删除',
|
||||||
'filesView.editor.saving': '保存中...',
|
'filesView.editor.saving': '保存中...',
|
||||||
'filesView.editor.saved': '已保存',
|
'filesView.editor.saved': '已保存',
|
||||||
|
'filesView.editor.autoSaveOn': '自动保存已开启',
|
||||||
|
'filesView.editor.manualSave': '手动保存',
|
||||||
'filesView.editor.saveNowTitle': '立即保存({shortcut})- 1.5 秒后自动保存',
|
'filesView.editor.saveNowTitle': '立即保存({shortcut})- 1.5 秒后自动保存',
|
||||||
|
'filesView.editor.saveNowManualTitle': '立即保存({shortcut})',
|
||||||
'filesView.editor.saveAria': '保存({shortcut})',
|
'filesView.editor.saveAria': '保存({shortcut})',
|
||||||
'filesView.editor.openInDesktopApp': '在桌面应用中打开',
|
'filesView.editor.openInDesktopApp': '在桌面应用中打开',
|
||||||
'filesView.editor.refreshApps': '刷新应用',
|
'filesView.editor.refreshApps': '刷新应用',
|
||||||
|
|||||||
Reference in New Issue
Block a user