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:
Bohdan Triapitsyn
2026-05-06 02:27:07 +03:00
parent b18886598c
commit 170d334921
7 changed files with 88 additions and 19 deletions
+70 -19
View File
@@ -10,6 +10,8 @@ import {
RiFolder3Fill,
RiFolderOpenFill,
RiFolderReceivedLine,
RiFileCheckFill,
RiFileCheckLine,
RiFullscreenExitLine,
RiFullscreenLine,
RiLoader4Line,
@@ -266,6 +268,19 @@ const isDirectoryReadError = (error: unknown): boolean => {
};
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 => {
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 lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled);
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
@@ -1331,12 +1347,32 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
};
}, [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
const AUTO_SAVE_DELAY = 1500;
React.useEffect(() => {
const canWrite = Boolean(selectedFile && files.writeFile);
if (!isDirty || !canWrite || isSaving) {
if (!autoSaveEnabled || !isDirty || !canWrite || isSaving) {
return;
}
@@ -1353,7 +1389,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
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
React.useEffect(() => {
@@ -2478,28 +2514,43 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
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">
{canEdit && textViewMode === 'edit' && (
isSaving ? (
<span className="flex items-center gap-1 px-1 text-muted-foreground typography-meta">
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
{t('filesView.editor.saving')}
</span>
) : autoSaveStatus === 'saved' && !isDirty ? (
<span className="flex items-center gap-1 px-1 text-[color:var(--status-success)] typography-meta">
<RiCheckLine className="h-3.5 w-3.5" />
{t('filesView.editor.saved')}
</span>
) : isDirty ? (
<>
{isSaving ? (
<span className="flex items-center gap-1 px-1 text-muted-foreground typography-meta">
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
{t('filesView.editor.saving')}
</span>
) : autoSaveEnabled && autoSaveStatus === 'saved' && !isDirty ? (
<span className="flex items-center gap-1 px-1 text-[color:var(--status-success)] typography-meta">
<RiCheckLine className="h-3.5 w-3.5" />
{t('filesView.editor.saved')}
</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
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('filesView.editor.saveNowTitle', { shortcut: `${getModifierLabel()}+S` })}
aria-label={t('filesView.editor.saveAria', { shortcut: `${getModifierLabel()}+S` })}
onClick={() => setAutoSaveEnabled((enabled) => !enabled)}
className={cn(
'h-6 w-6 p-0 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent',
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>
) : null
</>
)}
<DropdownMenu onOpenChange={handleToolbarDropdownOpenChange}>