feat: new note creation — unsaved/in-memory state before first save (#112)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * design: add new-note-creation frames (pending save + after first save) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
184
design/new-note-creation.pen
Normal file
184
design/new-note-creation.pen
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -76,6 +76,18 @@ describe('BreadcrumbBar — trash/restore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — pending save indicator', () => {
|
||||
it('shows "Saving…" text when noteStatus is pendingSave', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteStatus={'pendingSave'} />)
|
||||
expect(screen.getByText('Saving…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Saving…" text for clean status', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteStatus={'clean'} />)
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
it('shows archive button for non-archived note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
|
||||
@@ -162,6 +162,12 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
<span className="font-medium text-foreground">{entry.title}</span>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="text-muted-foreground">{wordCount.toLocaleString()} words</span>
|
||||
{noteStatus === 'pendingSave' && (
|
||||
<>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="font-semibold tab-status-pulse" style={{ color: 'var(--accent-green)' }}>Saving…</span>
|
||||
</>
|
||||
)}
|
||||
{noteStatus === 'new' && (
|
||||
<>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
|
||||
@@ -47,6 +47,7 @@ function TrashDateLine({ entry }: { entry: VaultEntry }) {
|
||||
}
|
||||
|
||||
const NOTE_STATUS_DOT: Record<string, { color: string; testId: string; title: string }> = {
|
||||
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
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
<div className="pr-5">
|
||||
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{noteStatus !== 'clean' && (
|
||||
{noteStatus !== 'clean' && NOTE_STATUS_DOT[noteStatus] && (
|
||||
<span
|
||||
className="mr-1.5 inline-block align-middle"
|
||||
className={`mr-1.5 inline-block align-middle${noteStatus === 'pendingSave' ? ' tab-status-pulse' : ''}`}
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: NOTE_STATUS_DOT[noteStatus].color, verticalAlign: 'middle' }}
|
||||
data-testid={NOTE_STATUS_DOT[noteStatus].testId}
|
||||
title={NOTE_STATUS_DOT[noteStatus].title}
|
||||
|
||||
@@ -206,6 +206,15 @@ describe('TabBar', () => {
|
||||
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(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
|
||||
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'])
|
||||
|
||||
@@ -178,7 +178,8 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<string, { color: string; testId: string; title: string }> = {
|
||||
const STATUS_DOT: Record<string, { color: string; testId: string; title: string; pulse?: boolean }> = {
|
||||
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 (
|
||||
<span
|
||||
className="shrink-0"
|
||||
className={`shrink-0${cfg.pulse ? ' tab-status-pulse' : ''}`}
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: cfg.color }}
|
||||
data-testid={cfg.testId}
|
||||
title={cfg.title}
|
||||
|
||||
@@ -461,6 +461,82 @@ describe('useNoteActions hook', () => {
|
||||
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)
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface NoteActionsConfig {
|
||||
entries: VaultEntry[]
|
||||
setToastMessage: (msg: string | null) => void
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => 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<string> {
|
||||
@@ -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<Set<string>>(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 }
|
||||
|
||||
|
||||
@@ -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<string>()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,7 +42,28 @@ function useNewNoteTracker() {
|
||||
return { newPaths, trackNew, clear }
|
||||
}
|
||||
|
||||
export function resolveNoteStatus(path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[]): NoteStatus {
|
||||
function usePendingSaveTracker() {
|
||||
const [pendingSavePaths, setPendingSavePaths] = useState<Set<string>>(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<string>, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set<string>,
|
||||
): 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<Record<string, string>>({})
|
||||
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
|
||||
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<string>('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<string> =>
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user