- {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