fix: derive green pallino from git status instead of in-memory tracker
The green dot (new note indicator) was disappearing after Cmd+S because markSaved cleared it from the in-memory newPaths set. Now resolveNoteStatus derives "new" status from git status (untracked/added files) so the green dot persists until the note is committed. The in-memory tracker is only used for the brief window between note creation and first save to disk. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,7 @@ function App() {
|
||||
updateVaultContent: vault.updateContent,
|
||||
setTabs: notes.setTabs,
|
||||
setToastMessage,
|
||||
onAfterSave: vault.loadModifiedFiles, onNoteSaved: vault.markSaved,
|
||||
onAfterSave: vault.loadModifiedFiles,
|
||||
})
|
||||
|
||||
const commitFlow = useCommitFlow({
|
||||
|
||||
@@ -47,7 +47,7 @@ function TrashDateLine({ entry }: { entry: VaultEntry }) {
|
||||
}
|
||||
|
||||
const NOTE_STATUS_DOT: Record<string, { color: string; testId: string; title: string }> = {
|
||||
new: { color: 'var(--accent-green)', testId: 'new-indicator', title: 'New (unsaved)' },
|
||||
new: { color: 'var(--accent-green)', testId: 'new-indicator', title: 'New (uncommitted)' },
|
||||
modified: { color: 'var(--accent-orange)', testId: 'modified-indicator', title: 'Modified (uncommitted)' },
|
||||
}
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<string, { color: string; testId: string; title: string }> = {
|
||||
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (unsaved)' },
|
||||
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (uncommitted)' },
|
||||
modified: { color: 'var(--accent-orange)', testId: 'tab-modified-indicator', title: 'Modified (uncommitted)' },
|
||||
}
|
||||
|
||||
|
||||
@@ -107,13 +107,10 @@ describe('useEditorSave', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'onAfterSave', key: 'onAfterSave' as const },
|
||||
{ name: 'onNoteSaved', key: 'onNoteSaved' as const },
|
||||
])('calls $name callback after successful save', async ({ key }) => {
|
||||
it('calls onAfterSave callback after successful save', async () => {
|
||||
const cb = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, [key]: cb })
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave: cb })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
@@ -142,15 +139,12 @@ describe('useEditorSave', () => {
|
||||
expect(onAfterSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'onAfterSave', key: 'onAfterSave' as const },
|
||||
{ name: 'onNoteSaved', key: 'onNoteSaved' as const },
|
||||
])('does not call $name when save fails', async ({ key }) => {
|
||||
it('does not call onAfterSave when save fails', async () => {
|
||||
mockInvokeFn.mockRejectedValueOnce(new Error('Disk full'))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const cb = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, [key]: cb })
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave: cb })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
|
||||
@@ -13,7 +13,6 @@ interface EditorSaveConfig {
|
||||
setTabs: (fn: SetStateAction<any[]>) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave?: () => void
|
||||
onNoteSaved?: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,7 +21,7 @@ interface EditorSaveConfig {
|
||||
*/
|
||||
const noop = () => {}
|
||||
|
||||
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNoteSaved }: EditorSaveConfig) {
|
||||
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop }: EditorSaveConfig) {
|
||||
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
|
||||
|
||||
const updateTabAndContent = useCallback((path: string, content: string) => {
|
||||
@@ -41,9 +40,8 @@ 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, onNoteSaved])
|
||||
}, [saveNote])
|
||||
|
||||
/** Called by Cmd+S — persists the current editor content to disk */
|
||||
const handleSave = useCallback(async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import type { VaultEntry, ModifiedFile, GitCommit } from '../types'
|
||||
import { useVaultLoader } from './useVaultLoader'
|
||||
import { useVaultLoader, resolveNoteStatus } from './useVaultLoader'
|
||||
|
||||
const mockEntries: VaultEntry[] = [
|
||||
{
|
||||
@@ -166,31 +166,42 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('returns clean after markSaved clears new status', async () => {
|
||||
it('returns new for git-untracked files (saved but not committed)', 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/brand-new.md', relativePath: 'note/brand-new.md', status: 'untracked' },
|
||||
])
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
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.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.markSaved('/vault/note/brand-new.md')
|
||||
it('returns new for git-added files (staged but not committed)', 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/staged.md', relativePath: 'note/staged.md', status: 'added' },
|
||||
])
|
||||
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/brand-new.md')).toBe('clean')
|
||||
expect(result.current.getNoteStatus('/vault/note/staged.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('new status takes priority over git modified', async () => {
|
||||
@@ -224,7 +235,7 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('ignores untracked git status for orange dot', async () => {
|
||||
it('treats untracked files as new (green dot, not orange)', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
@@ -240,7 +251,7 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('clean')
|
||||
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('new')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -350,3 +361,35 @@ describe('useVaultLoader', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNoteStatus', () => {
|
||||
const mf = (path: string, status: string): ModifiedFile => ({ path, relativePath: path.replace('/vault/', ''), status })
|
||||
|
||||
it('returns new when path is in newPaths (not yet on disk)', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new')
|
||||
})
|
||||
|
||||
it('returns new for untracked files in git', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'untracked')])).toBe('new')
|
||||
})
|
||||
|
||||
it('returns new for added files in git', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'added')])).toBe('new')
|
||||
})
|
||||
|
||||
it('returns modified for git-modified files', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified')
|
||||
})
|
||||
|
||||
it('returns clean for files not in git status', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [])).toBe('clean')
|
||||
})
|
||||
|
||||
it('returns clean for deleted files', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('clean')
|
||||
})
|
||||
|
||||
it('newPaths takes priority over git modified', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [mf('/vault/x.md', 'modified')])).toBe('new')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,23 +37,17 @@ function useNewNoteTracker() {
|
||||
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 }
|
||||
return { newPaths, trackNew, 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'
|
||||
const gitEntry = modifiedFiles.find((f) => f.path === path)
|
||||
if (!gitEntry) return 'clean'
|
||||
if (gitEntry.status === 'untracked' || gitEntry.status === 'added') return 'new'
|
||||
if (gitEntry.status === 'modified') return 'modified'
|
||||
return 'clean'
|
||||
}
|
||||
|
||||
@@ -120,6 +114,6 @@ export function useVaultLoader(vaultPath: string) {
|
||||
entries, allContent, modifiedFiles,
|
||||
addEntry, updateEntry, replaceEntry, updateContent,
|
||||
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
|
||||
getNoteStatus, markSaved: tracker.markSaved, commitAndPush,
|
||||
getNoteStatus, commitAndPush,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user