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:
committed by
GitHub
parent
d88c782541
commit
e57f17996f
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user