feat: distinguish new notes (green dot) from modified notes (orange dot)
New notes created in-session show a green dot; existing notes with
uncommitted git changes show an orange dot. Saving a new note clears
the green dot; git commit clears the orange dot.
Introduces NoteStatus type ('new' | 'modified' | 'clean'), extracts
useNewNoteTracker hook and resolveNoteStatus pure function, and adds
onNoteSaved callback to useEditorSave for clearing new-note tracking.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -107,10 +107,13 @@ describe('useEditorSave', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onAfterSave callback after successful save', async () => {
|
||||
const onAfterSave = vi.fn()
|
||||
it.each([
|
||||
{ name: 'onAfterSave', key: 'onAfterSave' as const },
|
||||
{ name: 'onNoteSaved', key: 'onNoteSaved' as const },
|
||||
])('calls $name callback after successful save', async ({ key }) => {
|
||||
const cb = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, [key]: cb })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
@@ -121,7 +124,7 @@ describe('useEditorSave', () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(onAfterSave).toHaveBeenCalledOnce()
|
||||
expect(cb).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onAfterSave even when nothing is pending (e.g. after rename)', async () => {
|
||||
@@ -139,12 +142,15 @@ describe('useEditorSave', () => {
|
||||
expect(onAfterSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call onAfterSave when save fails', async () => {
|
||||
it.each([
|
||||
{ name: 'onAfterSave', key: 'onAfterSave' as const },
|
||||
{ name: 'onNoteSaved', key: 'onNoteSaved' as const },
|
||||
])('does not call $name when save fails', async ({ key }) => {
|
||||
mockInvokeFn.mockRejectedValueOnce(new Error('Disk full'))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const onAfterSave = vi.fn()
|
||||
const cb = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, [key]: cb })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
@@ -155,7 +161,7 @@ describe('useEditorSave', () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(onAfterSave).not.toHaveBeenCalled()
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
|
||||
@@ -13,13 +13,16 @@ interface EditorSaveConfig {
|
||||
setTabs: (fn: SetStateAction<any[]>) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave?: () => void
|
||||
onNoteSaved?: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages explicit save (Cmd+S) for editor content.
|
||||
* Tracks pending (unsaved) content and provides save + pre-rename helpers.
|
||||
*/
|
||||
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave }: EditorSaveConfig) {
|
||||
const noop = () => {}
|
||||
|
||||
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNoteSaved }: EditorSaveConfig) {
|
||||
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
|
||||
|
||||
const updateTabAndContent = useCallback((path: string, content: string) => {
|
||||
@@ -38,15 +41,16 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
if (pathFilter && pending.path !== pathFilter) return false
|
||||
await saveNote(pending.path, pending.content)
|
||||
pendingContentRef.current = null
|
||||
onNoteSaved?.(pending.path)
|
||||
return true
|
||||
}, [saveNote])
|
||||
}, [saveNote, onNoteSaved])
|
||||
|
||||
/** Called by Cmd+S — persists the current editor content to disk */
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const saved = await flushPending()
|
||||
setToastMessage(saved ? 'Saved' : 'Nothing to save')
|
||||
onAfterSave?.()
|
||||
onAfterSave()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
setToastMessage(`Save failed: ${err}`)
|
||||
|
||||
@@ -133,16 +133,114 @@ describe('useVaultLoader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFileModified', () => {
|
||||
it('returns true for modified files', async () => {
|
||||
describe('getNoteStatus', () => {
|
||||
it('returns modified for git-modified files', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.isFileModified('/vault/note/hello.md')).toBe(true)
|
||||
expect(result.current.isFileModified('/vault/note/other.md')).toBe(false)
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('modified')
|
||||
expect(result.current.getNoteStatus('/vault/note/other.md')).toBe('clean')
|
||||
})
|
||||
|
||||
it('returns new for freshly added entries', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
const newEntry: VaultEntry = {
|
||||
...mockEntries[0],
|
||||
path: '/vault/note/brand-new.md',
|
||||
filename: 'brand-new.md',
|
||||
title: 'Brand New',
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# Brand New')
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('returns clean after markSaved clears new status', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
const newEntry: VaultEntry = {
|
||||
...mockEntries[0],
|
||||
path: '/vault/note/brand-new.md',
|
||||
filename: 'brand-new.md',
|
||||
title: 'Brand New',
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# Brand New')
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
|
||||
|
||||
act(() => {
|
||||
result.current.markSaved('/vault/note/brand-new.md')
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('clean')
|
||||
})
|
||||
|
||||
it('new status takes priority over git modified', async () => {
|
||||
// If a path is both new and in modifiedFiles, it should show as new
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
{ path: '/vault/note/new.md', relativePath: 'note/new.md', status: 'modified' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
const newEntry: VaultEntry = {
|
||||
...mockEntries[0],
|
||||
path: '/vault/note/new.md',
|
||||
filename: 'new.md',
|
||||
title: 'New',
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# New')
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('ignores untracked git status for orange dot', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([
|
||||
{ path: '/vault/note/hello.md', relativePath: 'note/hello.md', status: 'untracked' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('clean')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry, GitCommit, ModifiedFile } from '../types'
|
||||
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus } from '../types'
|
||||
|
||||
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
|
||||
@@ -30,18 +30,46 @@ async function commitWithPush(vaultPath: string, message: string): Promise<strin
|
||||
}
|
||||
}
|
||||
|
||||
function useNewNoteTracker() {
|
||||
const [newPaths, setNewPaths] = useState<Set<string>>(new Set())
|
||||
|
||||
const trackNew = useCallback((path: string) => {
|
||||
setNewPaths((prev) => new Set(prev).add(path))
|
||||
}, [])
|
||||
|
||||
const markSaved = useCallback((path: string) => {
|
||||
setNewPaths((prev) => {
|
||||
if (!prev.has(path)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(path)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => setNewPaths(new Set()), [])
|
||||
|
||||
return { newPaths, trackNew, markSaved, clear }
|
||||
}
|
||||
|
||||
export function resolveNoteStatus(path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[]): NoteStatus {
|
||||
if (newPaths.has(path)) return 'new'
|
||||
if (modifiedFiles.some((f) => f.path === path && f.status === 'modified')) return 'modified'
|
||||
return 'clean'
|
||||
}
|
||||
|
||||
export function useVaultLoader(vaultPath: string) {
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [allContent, setAllContent] = useState<Record<string, string>>({})
|
||||
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
|
||||
const tracker = useNewNoteTracker()
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
|
||||
setEntries([]); setAllContent({}); setModifiedFiles([])
|
||||
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear()
|
||||
loadVaultData(vaultPath)
|
||||
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
|
||||
.catch((err) => console.warn('Vault scan failed:', err))
|
||||
}, [vaultPath])
|
||||
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable
|
||||
|
||||
const loadModifiedFiles = useCallback(async () => {
|
||||
try {
|
||||
@@ -57,24 +85,18 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const addEntry = useCallback((entry: VaultEntry, content: string) => {
|
||||
setEntries((prev) => [entry, ...prev])
|
||||
setAllContent((prev) => ({ ...prev, [entry.path]: content }))
|
||||
}, [])
|
||||
tracker.trackNew(entry.path)
|
||||
}, [tracker])
|
||||
|
||||
const updateContent = useCallback((path: string, content: string) => {
|
||||
setAllContent((prev) => ({ ...prev, [path]: content }))
|
||||
}, [])
|
||||
const updateContent = useCallback((path: string, content: string) =>
|
||||
setAllContent((prev) => ({ ...prev, [path]: content })), [])
|
||||
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
|
||||
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e))
|
||||
}, [])
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) =>
|
||||
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), [])
|
||||
|
||||
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { path: string }, newContent: string) => {
|
||||
setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e))
|
||||
setAllContent((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[oldPath]
|
||||
next[patch.path] = newContent
|
||||
return next
|
||||
})
|
||||
setAllContent((prev) => { const next = { ...prev }; delete next[oldPath]; next[patch.path] = newContent; return next })
|
||||
}, [])
|
||||
|
||||
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
|
||||
@@ -83,34 +105,21 @@ export function useVaultLoader(vaultPath: string) {
|
||||
}, [vaultPath])
|
||||
|
||||
const loadDiffAtCommit = useCallback((path: string, commitHash: string): Promise<string> =>
|
||||
tauriCall<string>('get_file_diff_at_commit', { vaultPath, path, commitHash }, { path, commitHash }),
|
||||
[vaultPath])
|
||||
tauriCall<string>('get_file_diff_at_commit', { vaultPath, path, commitHash }, { path, commitHash }), [vaultPath])
|
||||
|
||||
const loadDiff = useCallback((path: string): Promise<string> =>
|
||||
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }),
|
||||
[vaultPath])
|
||||
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }), [vaultPath])
|
||||
|
||||
const isFileModified = useCallback((path: string): boolean =>
|
||||
modifiedFiles.some((f) => f.path === path),
|
||||
[modifiedFiles])
|
||||
const getNoteStatus = useCallback((path: string): NoteStatus =>
|
||||
resolveNoteStatus(path, tracker.newPaths, modifiedFiles), [tracker.newPaths, modifiedFiles])
|
||||
|
||||
const commitAndPush = useCallback((message: string): Promise<string> =>
|
||||
commitWithPush(vaultPath, message),
|
||||
[vaultPath])
|
||||
commitWithPush(vaultPath, message), [vaultPath])
|
||||
|
||||
return {
|
||||
entries,
|
||||
allContent,
|
||||
modifiedFiles,
|
||||
addEntry,
|
||||
updateEntry,
|
||||
replaceEntry,
|
||||
updateContent,
|
||||
loadModifiedFiles,
|
||||
loadGitHistory,
|
||||
loadDiff,
|
||||
loadDiffAtCommit,
|
||||
isFileModified,
|
||||
commitAndPush,
|
||||
entries, allContent, modifiedFiles,
|
||||
addEntry, updateEntry, replaceEntry, updateContent,
|
||||
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
|
||||
getNoteStatus, markSaved: tracker.markSaved, commitAndPush,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user