From 6baa7795df613580790f3e85844c86c8eed86ee7 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Fri, 27 Feb 2026 14:11:31 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20new=20note=20creation=20=E2=80=94=20uns?= =?UTF-8?q?aved/in-memory=20state=20before=20first=20save=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add pendingSave status indicator for new note creation Track disk write state during note creation with a pulsing green dot on tab and breadcrumb bar. The pending state resolves to 'new' once the file is written to disk, giving users clear feedback during the async save. Co-Authored-By: Claude Opus 4.6 * test: add tests for pendingSave status lifecycle Cover resolveNoteStatus priority, createAndPersist callbacks, TabBar pulsing dot indicator, and BreadcrumbBar "Saving…" text. Co-Authored-By: Claude Opus 4.6 * docs: add design frames for new note creation pending save state Two frames in design/new-note-creation.pen: - Pending save: pulsing green dot, italic title - Saved: static green dot, normal title Co-Authored-By: Claude Opus 4.6 * design: add new-note-creation frames (pending save + after first save) --------- Co-authored-by: Claude Opus 4.6 --- design/new-note-creation.pen | 184 ++++++++++++++++++++++++++ src/App.css | 9 ++ src/App.tsx | 2 +- src/components/BreadcrumbBar.test.tsx | 12 ++ src/components/BreadcrumbBar.tsx | 6 + src/components/NoteItem.tsx | 5 +- src/components/TabBar.test.tsx | 9 ++ src/components/TabBar.tsx | 5 +- src/hooks/useNoteActions.test.ts | 76 +++++++++++ src/hooks/useNoteActions.ts | 37 ++++-- src/hooks/useVaultLoader.test.ts | 29 ++++ src/hooks/useVaultLoader.ts | 28 +++- src/types.ts | 2 +- 13 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 design/new-note-creation.pen diff --git a/design/new-note-creation.pen b/design/new-note-creation.pen new file mode 100644 index 00000000..8a693ac1 --- /dev/null +++ b/design/new-note-creation.pen @@ -0,0 +1,184 @@ +{ + "children": [ + { + "type": "frame", + "id": "nnc_pending_save", + "name": "New Note Creation — Pending Save State", + "x": 0, + "y": 0, + "width": 800, + "height": 120, + "fill": "$--background", + "layout": "horizontal", + "gap": 0, + "padding": [ + 0, + 0 + ], + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "frame", + "id": "nnc_tab_bar", + "name": "Tab Bar", + "layout": "horizontal", + "width": 800, + "height": 40, + "fill": "$--card", + "gap": 0, + "padding": [ + 0, + 8 + ], + "children": [ + { + "type": "frame", + "id": "nnc_tab_active", + "name": "Tab — New Note (unsaved)", + "layout": "horizontal", + "width": 180, + "height": 40, + "fill": "$--background", + "gap": 6, + "padding": [ + 0, + 12 + ], + "children": [ + { + "type": "rectangle", + "id": "nnc_dot_pending", + "width": 8, + "height": 8, + "cornerRadius": 4, + "fill": "#22c55e" + }, + { + "type": "text", + "id": "nnc_tab_label", + "content": "Untitled", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "400" + } + ] + } + ] + }, + { + "type": "frame", + "id": "nnc_editor_area", + "name": "Editor — Empty New Note", + "layout": "vertical", + "width": 800, + "height": 80, + "fill": "$--background", + "gap": 8, + "padding": [ + 16, + 24 + ], + "children": [ + { + "type": "text", + "id": "nnc_placeholder", + "content": "Start typing...", + "fill": "$--muted-foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + } + ] + }, + { + "type": "frame", + "id": "nnc_after_first_save", + "name": "New Note Creation — After First Save", + "x": 820, + "y": 0, + "width": 800, + "height": 120, + "fill": "$--background", + "layout": "vertical", + "gap": 0, + "padding": [ + 0, + 0 + ], + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "frame", + "id": "nnc_tab_bar_saved", + "name": "Tab Bar — Saved", + "layout": "horizontal", + "width": 800, + "height": 40, + "fill": "$--card", + "gap": 0, + "padding": [ + 0, + 8 + ], + "children": [ + { + "type": "frame", + "id": "nnc_tab_saved", + "name": "Tab — Saved Note", + "layout": "horizontal", + "width": 180, + "height": 40, + "fill": "$--background", + "gap": 6, + "padding": [ + 0, + 12 + ], + "children": [ + { + "type": "text", + "id": "nnc_tab_label_saved", + "content": "My New Note", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "400" + } + ] + } + ] + }, + { + "type": "frame", + "id": "nnc_editor_saved", + "name": "Editor — Note with Content", + "layout": "vertical", + "width": 800, + "height": 80, + "fill": "$--background", + "gap": 8, + "padding": [ + 16, + 24 + ], + "children": [ + { + "type": "text", + "id": "nnc_content", + "content": "My New Note content...", + "fill": "$--foreground", + "fontFamily": "Inter", + "fontSize": 14 + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/App.css b/src/App.css index fc8e9a1e..7dbb8747 100644 --- a/src/App.css +++ b/src/App.css @@ -52,3 +52,12 @@ from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +@keyframes tab-status-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.tab-status-pulse { + animation: tab-status-pulse 1.2s ease-in-out infinite; +} diff --git a/src/App.tsx b/src/App.tsx index 00835fa0..46f74f31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -110,7 +110,7 @@ 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 }) + 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 }) const navHistory = useNavigationHistory() diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index afe0113a..85fcb7f8 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -76,6 +76,18 @@ describe('BreadcrumbBar — trash/restore', () => { }) }) +describe('BreadcrumbBar — pending save indicator', () => { + it('shows "Saving…" text when noteStatus is pendingSave', () => { + render() + expect(screen.getByText('Saving…')).toBeInTheDocument() + }) + + it('does not show "Saving…" text for clean status', () => { + render() + expect(screen.queryByText('Saving…')).not.toBeInTheDocument() + }) +}) + describe('BreadcrumbBar — archive/unarchive', () => { it('shows archive button for non-archived note', () => { render() diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index b2dbdc79..1e43dace 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -162,6 +162,12 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({ {entry.title} · {wordCount.toLocaleString()} words + {noteStatus === 'pendingSave' && ( + <> + · + Saving… + + )} {noteStatus === 'new' && ( <> · diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index fdcfdcf6..42026e6d 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -47,6 +47,7 @@ function TrashDateLine({ entry }: { entry: VaultEntry }) { } const NOTE_STATUS_DOT: Record = { + pendingSave: { color: 'var(--accent-green)', testId: 'pending-save-indicator', title: 'Saving to disk…' }, new: { color: 'var(--accent-green)', testId: 'new-indicator', title: 'New (uncommitted)' }, modified: { color: 'var(--accent-orange)', testId: 'modified-indicator', title: 'Modified (uncommitted)' }, } @@ -80,9 +81,9 @@ export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap
- {noteStatus !== 'clean' && ( + {noteStatus !== 'clean' && NOTE_STATUS_DOT[noteStatus] && ( { expect(onReorderTabs).toHaveBeenCalledWith(2, 1) }) + it('shows pending save indicator (pulsing dot) when getNoteStatus returns pendingSave', () => { + const tabs = makeTabs(['Alpha', 'Beta']) + const getNoteStatus = (path: string) => path === tabs[0].entry.path ? 'pendingSave' as const : 'clean' as const + render() + expect(screen.getAllByTestId('tab-pending-save-indicator')).toHaveLength(1) + expect(screen.queryByTestId('tab-modified-indicator')).not.toBeInTheDocument() + expect(screen.queryByTestId('tab-new-indicator')).not.toBeInTheDocument() + }) + it('switches tab on click', () => { const onSwitchTab = vi.fn() const tabs = makeTabs(['Alpha', 'Beta']) diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 8b916947..9a4d4e5f 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -178,7 +178,8 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) { ) } -const STATUS_DOT: Record = { +const STATUS_DOT: Record = { + 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)' }, } @@ -188,7 +189,7 @@ function StatusDot({ status }: { status: NoteStatus }) { if (!cfg) return null return ( { expect(setToastMessage).toHaveBeenCalledWith('Property updated') }) + describe('pending save lifecycle', () => { + it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => { + const addPendingSave = vi.fn() + const removePendingSave = vi.fn() + const config = makeConfig() + config.addPendingSave = addPendingSave + config.removePendingSave = removePendingSave + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + result.current.handleCreateNote('Pending Test', 'Note') + await new Promise((r) => setTimeout(r, 0)) + }) + + expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('note/pending-test.md')) + }) + + it('createAndPersist calls removePendingSave when persist completes (non-Tauri)', async () => { + const addPendingSave = vi.fn() + const removePendingSave = vi.fn() + const config = makeConfig() + config.addPendingSave = addPendingSave + config.removePendingSave = removePendingSave + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + result.current.handleCreateNote('Persist OK', 'Note') + await new Promise((r) => setTimeout(r, 0)) + }) + + expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('note/persist-ok.md')) + }) + + it('createAndPersist calls removePendingSave AND reverts when persist fails (Tauri)', async () => { + vi.mocked(isTauri).mockReturnValue(true) + vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full')) + const addPendingSave = vi.fn() + const removePendingSave = vi.fn() + const config = makeConfig() + config.addPendingSave = addPendingSave + config.removePendingSave = removePendingSave + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + result.current.handleCreateNote('Fail Save', 'Note') + await new Promise((r) => setTimeout(r, 0)) + }) + + expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('note/fail-save.md')) + expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('note/fail-save.md')) + expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining('note/fail-save.md')) + 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() + const config = makeConfig() + config.addPendingSave = addPendingSave + config.removePendingSave = removePendingSave + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + result.current.handleCreateNoteImmediate() + 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')) + }) + }) + describe('optimistic error recovery (Tauri mode)', () => { beforeEach(() => { vi.mocked(isTauri).mockReturnValue(true) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 0c401915..f69ab300 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -26,6 +26,8 @@ export interface NoteActionsConfig { entries: VaultEntry[] setToastMessage: (msg: string | null) => void updateEntry: (path: string, patch: Partial) => void + addPendingSave?: (path: string) => void + removePendingSave?: (path: string) => void } async function performRename( @@ -188,9 +190,18 @@ function signalFocusEditor(): void { window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { t0: performance.now() } })) } -/** Persist to disk; on failure, call the revert handler. */ -function persistOptimistic(path: string, content: string, onFail: (p: string) => void): void { - persistNewNote(path, content).catch(() => onFail(path)) +interface PersistCallbacks { + onFail: (p: string) => void + onStart?: (p: string) => void + onEnd?: (p: string) => void +} + +/** Persist to disk; track pending state via onStart/onEnd; revert on failure. */ +function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): void { + cbs.onStart?.(path) + persistNewNote(path, content) + .then(() => cbs.onEnd?.(path)) + .catch(() => { cbs.onEnd?.(path); cbs.onFail(path) }) } /** Optimistically open tab, add entry to vault, and persist to disk. @@ -202,11 +213,11 @@ function createAndPersist( resolved: { entry: VaultEntry; content: string }, addFn: (e: VaultEntry, c: string) => void, openTab: (e: VaultEntry, c: string) => void, - onFail: (p: string) => void, + cbs: PersistCallbacks, ): void { openTab(resolved.entry, resolved.content) addEntryWithMock(resolved.entry, resolved.content, addFn) - persistOptimistic(resolved.entry.path, resolved.content, onFail) + persistOptimistic(resolved.entry.path, resolved.content, cbs) } async function executeFrontmatterOp(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue): Promise { @@ -250,7 +261,7 @@ async function runFrontmatterAndApply( } export function useNoteActions(config: NoteActionsConfig) { - const { addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry } = config + const { addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config const tabMgmt = useTabManagement() const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, activeTabPathRef, handleSwitchTab } = tabMgmt const tabsRef = useRef(tabMgmt.tabs) @@ -273,11 +284,17 @@ export function useNoteActions(config: NoteActionsConfig) { setToastMessage('Failed to create note — disk write error') }, [handleCloseTab, removeEntry, setToastMessage]) + const persistCbs: PersistCallbacks = { + onFail: revertOptimisticNote, + onStart: addPendingSave, + onEnd: removePendingSave, + } + const pendingNamesRef = useRef>(new Set()) const handleCreateNote = useCallback((title: string, type: string) => { - createAndPersist(resolveNewNote(title, type), addEntry, openTabWithContent, revertOptimisticNote) - }, [openTabWithContent, addEntry, revertOptimisticNote]) + createAndPersist(resolveNewNote(title, type), addEntry, openTabWithContent, persistCbs) + }, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are const handleCreateNoteImmediate = useCallback((type?: string) => { const noteType = type || 'Note' @@ -289,8 +306,8 @@ export function useNoteActions(config: NoteActionsConfig) { }, [entries, handleCreateNote]) const handleCreateType = useCallback((typeName: string) => { - createAndPersist(resolveNewType(typeName), addEntry, openTabWithContent, revertOptimisticNote) - }, [openTabWithContent, addEntry, revertOptimisticNote]) + createAndPersist(resolveNewType(typeName), addEntry, openTabWithContent, persistCbs) + }, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage } diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 4a291c68..5a4fecbd 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -390,4 +390,33 @@ describe('resolveNoteStatus', () => { it('newPaths takes priority over git modified', () => { expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [mf('/vault/x.md', 'modified')])).toBe('new') }) + + it('pendingSave takes priority over new status', () => { + const pendingSave = new Set(['/vault/x.md']) + expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], pendingSave)).toBe('pendingSave') + }) + + it('pendingSave takes priority over modified status', () => { + const pendingSave = new Set(['/vault/x.md']) + expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], pendingSave)).toBe('pendingSave') + }) + + it('pendingSave takes priority over clean status', () => { + const pendingSave = new Set(['/vault/x.md']) + expect(resolveNoteStatus('/vault/x.md', new Set(), [], pendingSave)).toBe('pendingSave') + }) + + it('without pendingSavePaths parameter, behavior is unchanged', () => { + // Omitting the optional parameter should produce the same results as before + 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') + expect(resolveNoteStatus('/vault/x.md', new Set(), [])).toBe('clean') + }) + + it('empty pendingSavePaths set does not affect other statuses', () => { + const emptyPending = new Set() + expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], emptyPending)).toBe('new') + 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') + }) }) diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index a94f3527..97934333 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -42,7 +42,28 @@ function useNewNoteTracker() { return { newPaths, trackNew, clear } } -export function resolveNoteStatus(path: string, newPaths: Set, modifiedFiles: ModifiedFile[]): NoteStatus { +function usePendingSaveTracker() { + const [pendingSavePaths, setPendingSavePaths] = useState>(new Set()) + + const addPendingSave = useCallback((path: string) => { + setPendingSavePaths((prev) => new Set(prev).add(path)) + }, []) + + const removePendingSave = useCallback((path: string) => { + setPendingSavePaths((prev) => { + const next = new Set(prev) + next.delete(path) + return next + }) + }, []) + + return { pendingSavePaths, addPendingSave, removePendingSave } +} + +export function resolveNoteStatus( + path: string, newPaths: Set, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set, +): NoteStatus { + if (pendingSavePaths?.has(path)) return 'pendingSave' if (newPaths.has(path)) return 'new' const gitEntry = modifiedFiles.find((f) => f.path === path) if (!gitEntry) return 'clean' @@ -56,6 +77,7 @@ export function useVaultLoader(vaultPath: string) { const [allContent, setAllContent] = useState>({}) const [modifiedFiles, setModifiedFiles] = useState([]) const tracker = useNewNoteTracker() + const pendingSave = usePendingSaveTracker() useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault @@ -117,7 +139,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), [tracker.newPaths, modifiedFiles]) + resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths]) const commitAndPush = useCallback((message: string): Promise => commitWithPush(vaultPath, message), [vaultPath]) @@ -134,5 +156,7 @@ export function useVaultLoader(vaultPath: string) { addEntry, updateEntry, removeEntry, replaceEntry, updateContent, loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit, getNoteStatus, commitAndPush, reloadVault, + addPendingSave: pendingSave.addPendingSave, + removePendingSave: pendingSave.removePendingSave, } } diff --git a/src/types.ts b/src/types.ts index 94484855..4dd00139 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,7 +29,7 @@ export interface VaultEntry { outgoingLinks: string[] } -export type NoteStatus = 'new' | 'modified' | 'clean' +export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' export interface GitCommit { hash: string