diff --git a/src/App.tsx b/src/App.tsx index 8ebb5498..d1207d41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,13 +76,14 @@ function useEditorSaveWithLinks(config: { setTabs: Parameters[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 }) diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 9a4d4e5f..f062b38e 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -179,6 +179,7 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) { } const STATUS_DOT: Record = { + 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 ? ( ) : ( - { e.stopPropagation(); onDoubleClick() }}> + { e.stopPropagation(); onDoubleClick() }}> {tab.entry.title} )} diff --git a/src/hooks/useEditorSave.ts b/src/hooks/useEditorSave.ts index 36ca929a..a179e1ac 100644 --- a/src/hooks/useEditorSave.ts +++ b/src/hooks/useEditorSave.ts @@ -13,6 +13,8 @@ interface EditorSaveConfig { setTabs: (fn: SetStateAction) => 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) => { diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 3cb14918..546b3cb1 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -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() + 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) }) }) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index f69ab300..bb891e12 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -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) => void addPendingSave?: (path: string) => void removePendingSave?: (path: string) => void + trackUnsaved?: (path: string) => void + clearUnsaved?: (path: string) => void + unsavedPaths?: Set + /** 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, diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 5a4fecbd..e6e80668 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -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') + }) }) diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 97934333..3c8abbc6 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -42,6 +42,26 @@ function useNewNoteTracker() { return { newPaths, trackNew, clear } } +function useUnsavedTracker() { + const [unsavedPaths, setUnsavedPaths] = useState>(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>(new Set()) @@ -61,8 +81,9 @@ function usePendingSaveTracker() { } export function resolveNoteStatus( - path: string, newPaths: Set, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set, + path: string, newPaths: Set, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set, unsavedPaths?: Set, ): 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([]) 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('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 => 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, } } diff --git a/src/types.ts b/src/types.ts index 4dd00139..bac49ff5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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