feat: in-memory unsaved state for new notes (Cmd+N)

New notes created via Cmd+N now stay in memory until the user
explicitly saves with Cmd+S. This eliminates the disk-write
blocking delay and lets the cursor appear instantly.

- Add 'unsaved' to NoteStatus; blue dot + italic title in tab bar
- useUnsavedTracker in useVaultLoader manages unsaved path set
- useNoteActions skips persist on create, cleans up on close
- useEditorSave accepts unsavedFallback for first-save scenario
- App.tsx wires tracking via contentChangeRef + clearUnsaved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Laputa App
2026-02-27 18:17:47 +01:00
parent f3dcaadca8
commit 30ca211bb0
8 changed files with 184 additions and 32 deletions

View File

@@ -76,13 +76,14 @@ function useEditorSaveWithLinks(config: {
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
setToastMessage: (msg: string | null) => void
onAfterSave: () => void
onNotePersisted?: (path: string) => void
}) {
const { updateContent, updateEntry } = config
const saveContent = useCallback((path: string, content: string) => {
updateContent(path, content)
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
}, [updateContent, updateEntry])
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave })
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
@@ -127,7 +128,11 @@ function App() {
onToast: (msg) => setToastMessage(msg),
})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave })
// Ref bridges handleContentChange (created after notes) into useNoteActions.
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content) })
const navHistory = useNavigationHistory()
@@ -198,10 +203,21 @@ function App() {
}
}, [handleGoBack, handleGoForward])
const { handleSave, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
setTabs: notes.setTabs, setToastMessage, onAfterSave: vault.loadModifiedFiles,
onNotePersisted: vault.clearUnsaved,
})
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
const handleSave = useCallback(async () => {
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
? { path: activeTab.entry.path, content: activeTab.content }
: undefined
await handleSaveRaw(fallback)
}, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })

View File

@@ -179,6 +179,7 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
}
const STATUS_DOT: Record<string, { color: string; testId: string; title: string; pulse?: boolean }> = {
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Unsaved — Cmd+S to save' },
pendingSave: { color: 'var(--accent-green)', testId: 'tab-pending-save-indicator', title: 'Saving to disk…', pulse: true },
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (uncommitted)' },
modified: { color: 'var(--accent-orange)', testId: 'tab-modified-indicator', title: 'Modified (uncommitted)' },
@@ -235,7 +236,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
{isEditing ? (
<InlineTabEdit initialValue={tab.entry.title} onSave={onRenameSave} onCancel={onRenameCancel} />
) : (
<span className="truncate" onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
<span className="truncate" style={noteStatus === 'unsaved' ? { fontStyle: 'italic' } : undefined} onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
{tab.entry.title}
</span>
)}

View File

@@ -13,6 +13,8 @@ interface EditorSaveConfig {
setTabs: (fn: SetStateAction<any[]>) => void
setToastMessage: (msg: string | null) => void
onAfterSave?: () => void
/** Called after content is persisted — used to clear unsaved state. */
onNotePersisted?: (path: string) => void
}
/**
@@ -21,7 +23,7 @@ interface EditorSaveConfig {
*/
const noop = () => {}
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop }: EditorSaveConfig) {
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNotePersisted }: EditorSaveConfig) {
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
const updateTabAndContent = useCallback((path: string, content: string) => {
@@ -40,20 +42,29 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
if (pathFilter && pending.path !== pathFilter) return false
await saveNote(pending.path, pending.content)
pendingContentRef.current = null
onNotePersisted?.(pending.path)
return true
}, [saveNote])
}, [saveNote, onNotePersisted])
/** Called by Cmd+S — persists the current editor content to disk */
const handleSave = useCallback(async () => {
/** Called by Cmd+S — persists the current editor content to disk.
* Accepts optional fallback for unsaved notes with no pending edits. */
const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }) => {
try {
const saved = await flushPending()
if (!saved && unsavedFallback) {
await saveNote(unsavedFallback.path, unsavedFallback.content)
onNotePersisted?.(unsavedFallback.path)
setToastMessage('Saved')
onAfterSave()
return
}
setToastMessage(saved ? 'Saved' : 'Nothing to save')
onAfterSave()
} catch (err) {
console.error('Save failed:', err)
setToastMessage(`Save failed: ${err}`)
}
}, [flushPending, setToastMessage, onAfterSave])
}, [flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
/** Called by Editor onChange — buffers the latest content without saving */
const handleContentChange = useCallback((path: string, content: string) => {

View File

@@ -518,12 +518,12 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate works with pending save callbacks', async () => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
it('handleCreateNoteImmediate calls trackUnsaved and markContentPending (no disk write)', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
const config = makeConfig()
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
const { result } = renderHook(() => useNoteActions(config))
@@ -532,8 +532,8 @@ describe('useNoteActions hook', () => {
await new Promise((r) => setTimeout(r, 0))
})
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'), expect.stringContaining('Untitled note'))
})
})
@@ -573,12 +573,7 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).not.toHaveBeenCalled()
})
it('handles rapid creation with one failure independently', async () => {
vi.mocked(invoke)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('disk full'))
.mockResolvedValueOnce(undefined)
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
await act(async () => {
@@ -589,8 +584,34 @@ describe('useNoteActions hook', () => {
})
expect(addEntry).toHaveBeenCalledTimes(3)
expect(removeEntry).toHaveBeenCalledTimes(1)
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining('untitled-note-2.md'))
// No disk writes for immediate creation — notes are unsaved/in-memory
expect(invoke).not.toHaveBeenCalled()
expect(removeEntry).not.toHaveBeenCalled()
})
it('closing unsaved tab removes entry', () => {
const clearUnsaved = vi.fn()
const unsavedPaths = new Set<string>()
const config = makeConfig()
config.clearUnsaved = clearUnsaved
config.unsavedPaths = unsavedPaths
const { result } = renderHook(() => useNoteActions(config))
act(() => {
result.current.handleCreateNoteImmediate()
})
const createdPath = addEntry.mock.calls[0][0].path
unsavedPaths.add(createdPath) // simulate trackUnsaved
config.unsavedPaths = unsavedPaths // update ref
act(() => {
result.current.handleCloseTab(createdPath)
})
expect(removeEntry).toHaveBeenCalledWith(createdPath)
expect(clearUnsaved).toHaveBeenCalledWith(createdPath)
})
})
})

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
import type { VaultEntry } from '../types'
@@ -28,6 +28,11 @@ export interface NoteActionsConfig {
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
addPendingSave?: (path: string) => void
removePendingSave?: (path: string) => void
trackUnsaved?: (path: string) => void
clearUnsaved?: (path: string) => void
unsavedPaths?: Set<string>
/** Called when an unsaved note is created so the save system can buffer its initial content. */
markContentPending?: (path: string, content: string) => void
}
async function performRename(
@@ -263,10 +268,13 @@ async function runFrontmatterAndApply(
export function useNoteActions(config: NoteActionsConfig) {
const { addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config
const tabMgmt = useTabManagement()
const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, activeTabPathRef, handleSwitchTab } = tabMgmt
const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, handleCloseTabRef, activeTabPathRef, handleSwitchTab } = tabMgmt
const tabsRef = useRef(tabMgmt.tabs)
// eslint-disable-next-line react-hooks/refs
tabsRef.current = tabMgmt.tabs
const unsavedPathsRef = useRef(config.unsavedPaths)
// eslint-disable-next-line react-hooks/refs
unsavedPathsRef.current = config.unsavedPaths
const updateTabContent = useCallback((path: string, newContent: string) => {
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
@@ -300,10 +308,26 @@ export function useNoteActions(config: NoteActionsConfig) {
const noteType = type || 'Note'
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
pendingNamesRef.current.add(title)
handleCreateNote(title, noteType)
const resolved = resolveNewNote(title, noteType)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
config.trackUnsaved?.(resolved.entry.path)
config.markContentPending?.(resolved.entry.path, resolved.content)
signalFocusEditor()
setTimeout(() => pendingNamesRef.current.delete(title), 500)
}, [entries, handleCreateNote])
}, [entries, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
/** Close tab and discard entry+unsaved state if the note was never persisted. */
const handleCloseTabWithCleanup = useCallback((path: string) => {
if (unsavedPathsRef.current?.has(path)) {
removeEntry(path)
config.clearUnsaved?.(path)
}
handleCloseTab(path)
}, [handleCloseTab, removeEntry, config.clearUnsaved]) // eslint-disable-line react-hooks/exhaustive-deps -- ref access is stable
// Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
const handleCreateType = useCallback((typeName: string) => {
createAndPersist(resolveNewType(typeName), addEntry, openTabWithContent, persistCbs)
@@ -340,6 +364,7 @@ export function useNoteActions(config: NoteActionsConfig) {
return {
...tabMgmt,
handleCloseTab: handleCloseTabWithCleanup,
handleNavigateWikilink,
handleCreateNote,
handleCreateNoteImmediate,

View File

@@ -233,6 +233,47 @@ describe('useVaultLoader', () => {
expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new')
})
it('returns unsaved for paths in unsavedPaths', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry, '# Draft')
result.current.trackUnsaved('/vault/note/draft.md')
})
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
})
it('unsaved has higher priority than new', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry, '# Draft')
result.current.trackUnsaved('/vault/note/draft.md')
})
// addEntry also calls trackNew, so path is in both newPaths and unsavedPaths
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
})
it('clearUnsaved transitions from unsaved to new', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry, '# Draft')
result.current.trackUnsaved('/vault/note/draft.md')
})
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
act(() => { result.current.clearUnsaved('/vault/note/draft.md') })
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('new')
})
it('treats untracked files as new (green dot, not orange)', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
@@ -419,4 +460,16 @@ describe('resolveNoteStatus', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], emptyPending)).toBe('modified')
expect(resolveNoteStatus('/vault/x.md', new Set(), [], emptyPending)).toBe('clean')
})
it('unsaved takes priority over all other statuses', () => {
const unsaved = new Set(['/vault/x.md'])
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], undefined, unsaved)).toBe('unsaved')
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], undefined, unsaved)).toBe('unsaved')
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], new Set(['/vault/x.md']), unsaved)).toBe('unsaved')
})
it('without unsavedPaths parameter, behavior is unchanged', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new')
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified')
})
})

View File

@@ -42,6 +42,26 @@ function useNewNoteTracker() {
return { newPaths, trackNew, clear }
}
function useUnsavedTracker() {
const [unsavedPaths, setUnsavedPaths] = useState<Set<string>>(new Set())
const trackUnsaved = useCallback((path: string) => {
setUnsavedPaths((prev) => new Set(prev).add(path))
}, [])
const clearUnsaved = useCallback((path: string) => {
setUnsavedPaths((prev) => {
const next = new Set(prev)
next.delete(path)
return next
})
}, [])
const clearAll = useCallback(() => setUnsavedPaths(new Set()), [])
return { unsavedPaths, trackUnsaved, clearUnsaved, clearAll }
}
function usePendingSaveTracker() {
const [pendingSavePaths, setPendingSavePaths] = useState<Set<string>>(new Set())
@@ -61,8 +81,9 @@ function usePendingSaveTracker() {
}
export function resolveNoteStatus(
path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set<string>,
path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set<string>, unsavedPaths?: Set<string>,
): NoteStatus {
if (unsavedPaths?.has(path)) return 'unsaved'
if (pendingSavePaths?.has(path)) return 'pendingSave'
if (newPaths.has(path)) return 'new'
const gitEntry = modifiedFiles.find((f) => f.path === path)
@@ -78,10 +99,11 @@ export function useVaultLoader(vaultPath: string) {
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
const tracker = useNewNoteTracker()
const pendingSave = usePendingSaveTracker()
const unsaved = useUnsavedTracker()
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear()
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear(); unsaved.clearAll()
loadVaultData(vaultPath)
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
.catch((err) => console.warn('Vault scan failed:', err))
@@ -139,7 +161,7 @@ export function useVaultLoader(vaultPath: string) {
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }), [vaultPath])
const getNoteStatus = useCallback((path: string): NoteStatus =>
resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths])
resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths])
const commitAndPush = useCallback((message: string): Promise<string> =>
commitWithPush(vaultPath, message), [vaultPath])
@@ -158,5 +180,8 @@ export function useVaultLoader(vaultPath: string) {
getNoteStatus, commitAndPush, reloadVault,
addPendingSave: pendingSave.addPendingSave,
removePendingSave: pendingSave.removePendingSave,
unsavedPaths: unsaved.unsavedPaths,
trackUnsaved: unsaved.trackUnsaved,
clearUnsaved: unsaved.clearUnsaved,
}
}

View File

@@ -29,7 +29,7 @@ export interface VaultEntry {
outgoingLinks: string[]
}
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave'
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved'
export interface GitCommit {
hash: string