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:
Luca Rossi
2026-02-27 14:11:31 +01:00
committed by GitHub
parent 87f847b710
commit 6baa7795df
13 changed files with 386 additions and 18 deletions

View File

@@ -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 }