feat: auto-save file editor with 1.5s debounce (#649)

* fix: project label text hidden by selection overlay in dark mode

In NavRail's ProjectTile, the expanded active item renders an absolutely
positioned background overlay (--interactive-selection) that visually
covers the text label. The label span was missing 'relative z-10',
unlike NavRailActionButton which correctly uses it.

Fixes text being invisible on selected project in dark themes
(nord-dark, dracula-dark, monokai-dark, gruvbox-dark, etc.)

* feat: auto-save file editor with 1.5s debounce

Add debounced auto-save to the file editor so users no longer need to
manually save with Cmd/Ctrl+S or the save button. Files are automatically
saved 1.5 seconds after the user stops typing.

- Debounced auto-save effect that triggers on draft content changes
- Cmd/Ctrl+S still works for immediate save (cancels pending debounce)
- Updated save button UI with contextual status indicators:
  'Saving...' spinner, 'Saved' checkmark, or save icon when dirty
- Reset auto-save status when switching between files
- Applied consistently to both normal and fullscreen editor views
This commit is contained in:
Nguyễn Ngô Thượng
2026-03-14 00:46:43 +02:00
committed by GitHub
parent d88c782541
commit e57f17996f
+83 -30
View File
@@ -549,6 +549,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [draftContent, setDraftContent] = React.useState(''); const [draftContent, setDraftContent] = React.useState('');
const [isSaving, setIsSaving] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false);
const autoSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
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);
@@ -1150,6 +1152,35 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}; };
}, [isDirty, setMainTabGuard]); }, [isDirty, setMainTabGuard]);
// 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) {
return;
}
autoSaveTimerRef.current = setTimeout(() => {
void saveDraft().then(() => {
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
});
}, AUTO_SAVE_DELAY);
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
};
}, [draftContent, isDirty, selectedFile, files.writeFile, isSaving, saveDraft]);
// Reset auto-save status when switching files
React.useEffect(() => {
setAutoSaveStatus('idle');
}, [selectedFile?.path]);
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!hasModifier(e)) { if (!hasModifier(e)) {
@@ -1158,8 +1189,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (e.key.toLowerCase() === 's') { if (e.key.toLowerCase() === 's') {
e.preventDefault(); e.preventDefault();
// Cancel pending auto-save; user wants immediate save
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (!isSaving) { if (!isSaving) {
void saveDraft(); void saveDraft().then(() => {
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
});
} }
} else if (e.key.toLowerCase() === 'f') { } else if (e.key.toLowerCase() === 'f') {
e.preventDefault(); e.preventDefault();
@@ -2253,21 +2292,28 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
{selectedFile && ( {selectedFile && (
<div className={cn('flex items-center justify-end gap-1 px-3 pb-1.5', !showEditorTabsRow && 'pt-1.5')}> <div className={cn('flex items-center justify-end gap-1 px-3 pb-1.5', !showEditorTabsRow && 'pt-1.5')}>
{canEdit && textViewMode === 'edit' && ( {canEdit && textViewMode === 'edit' && (
<Button isSaving ? (
variant="ghost" <span className="flex items-center gap-1 text-muted-foreground typography-meta">
size="sm" <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
onClick={() => void saveDraft()} Saving
disabled={!isDirty || isSaving} </span>
className="h-5 w-5 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100" ) : autoSaveStatus === 'saved' && !isDirty ? (
title={`Save (${getModifierLabel()}+S)`} <span className="flex items-center gap-1 text-[color:var(--status-success)] typography-meta">
aria-label={`Save (${getModifierLabel()}+S)`} <RiCheckLine className="h-3.5 w-3.5" />
> Saved
{isSaving ? ( </span>
<RiLoader4Line className="h-4 w-4 animate-spin" /> ) : isDirty ? (
) : ( <Button
<RiSave3Line className="h-4 w-4" /> variant="ghost"
)} size="sm"
</Button> onClick={() => void saveDraft()}
className="h-5 px-1 gap-1 text-muted-foreground opacity-70 hover:opacity-100"
title={`Save now (${getModifierLabel()}+S) — auto-saves after 1.5s`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
<RiSave3Line className="h-3.5 w-3.5" />
</Button>
) : null
)} )}
<DropdownMenu> <DropdownMenu>
@@ -2714,21 +2760,28 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{canEdit && textViewMode === 'edit' && ( {canEdit && textViewMode === 'edit' && (
<Button isSaving ? (
variant="ghost" <span className="flex items-center gap-1 text-muted-foreground typography-meta">
size="sm" <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
onClick={() => void saveDraft()} Saving
disabled={!isDirty || isSaving} </span>
className="h-6 w-6 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100" ) : autoSaveStatus === 'saved' && !isDirty ? (
title={`Save (${getModifierLabel()}+S)`} <span className="flex items-center gap-1 text-[color:var(--status-success)] typography-meta">
aria-label={`Save (${getModifierLabel()}+S)`} <RiCheckLine className="h-3.5 w-3.5" />
> Saved
{isSaving ? ( </span>
<RiLoader4Line className="h-4 w-4 animate-spin" /> ) : isDirty ? (
) : ( <Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
className="h-6 px-1 gap-1 text-muted-foreground opacity-70 hover:opacity-100"
title={`Save now (${getModifierLabel()}+S) — auto-saves after 1.5s`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
<RiSave3Line className="h-4 w-4" /> <RiSave3Line className="h-4 w-4" />
)} </Button>
</Button> ) : null
)} )}
<DropdownMenu> <DropdownMenu>