diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index a8e6cb76..c631d44e 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -800,6 +800,15 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc - `src-tauri/resources/agent-docs/AGENTS.md` orients agents to the generated docs bundle, while `index.md`, section bundles, `all.md`, `search-index.json`, and `pages/` provide fast local lookup - `get_agent_docs_path` exposes the resolved resource folder to the renderer, and `buildAgentSystemPrompt()` tells every app-managed CLI agent to read vault `AGENTS.md` first, then search the bundled docs for Tolaria behavior +### Action History + +`useActionHistory` owns renderer-scoped app undo/redo state. It stores explicit action entries with labels plus undo/redo callbacks, suppresses nested recording during replay, and exposes the top labels to command-palette commands. + +- Frontmatter mutations record history only after the write succeeds and only for non-silent user actions. +- Entry state toggles such as archive, favorite, and organized record explicit before/after replay callbacks after persistence succeeds. +- Text inputs, contenteditable surfaces, and editor-owned text history keep native undo/redo first; app-level history runs only when focus is outside text editing. +- Irreversible destructive actions stay outside the stack and continue to use confirmation/destructive affordances. + ### Getting Started / Onboarding `useOnboarding` hook detects first launch: diff --git a/docs/adr/0124-cached-secondary-note-window-startup.md b/docs/adr/0124-cached-secondary-note-window-startup.md new file mode 100644 index 00000000..a37f70d2 --- /dev/null +++ b/docs/adr/0124-cached-secondary-note-window-startup.md @@ -0,0 +1,27 @@ +--- +type: ADR +id: "0124" +title: "Cached secondary note window startup" +status: active +date: 2026-05-26 +supersedes: "0123" +--- + +## Context + +ADR-0123 restored secondary note windows to the normal `App` path so they retain the full vault/workspace graph required by Properties, quick open/search, wikilinks, and workspace-aware note actions. + +That parity is still the product contract, but forcing a fresh Tauri `reload_vault` during every secondary-window mount invalidates the backend cache. Opening several note windows can therefore repeat expensive full-vault scans even when the main window has already warmed the cache. + +## Decision + +**Secondary note windows keep the full vault graph, but their initial vault load uses the cached/incremental `list_vault` path instead of the forced `reload_vault` path.** + +Normal main-window startup continues to force a fresh initial reload. Explicit refresh paths, watcher-driven refreshes, and user-initiated reloads still use reload commands where they need disk freshness. + +## Consequences + +- Secondary note windows remain capable full app windows rather than reduced editor shells. +- Repeated note-window opens can reuse the backend vault cache instead of invalidating it on every startup. +- First open after a cold cache still scans the vault, then warms the shared backend cache for later windows. +- If the cached scan path is stale, the existing backend cache update logic remains responsible for incremental freshness. diff --git a/docs/adr/0126-renderer-action-history.md b/docs/adr/0126-renderer-action-history.md new file mode 100644 index 00000000..bce527f6 --- /dev/null +++ b/docs/adr/0126-renderer-action-history.md @@ -0,0 +1,29 @@ +--- +id: "0126" +title: "Renderer action history for app-level undo and redo" +status: "active" +date: "2026-05-26" +supersedes: + - "0106" +--- + +# ADR-0126: Renderer action history for app-level undo and redo + +## Context + +Tolaria already lets native text surfaces keep their own undo stacks, but app-level state changes such as frontmatter edits, archive toggles, favorite toggles, and organization toggles did not share a consistent undo/redo model. Routing all Undo and Redo through the native menu items left these app actions one-way while also making command-palette discoverability inconsistent. + +## Decision + +Introduce a renderer-owned `useActionHistory` stack for app-level actions. Supported actions record explicit undo and redo callbacks only after the write succeeds, clear redo after new user actions, and suppress nested recordings while a history entry is replaying. + +The Edit menu and command manifest now route Undo and Redo to renderer commands. Focused text-editing controls still receive native text history first through `document.execCommand('undo' | 'redo')`, so editor/input undo behavior remains separate from the app action stack. + +Destructive actions that are not safely reversible remain outside this stack and continue to rely on confirmation/destructive UX instead of pretending to be undoable. + +## Consequences + +- App-level history is scoped to the active renderer session and is not persisted across launches. +- Undo/redo labels can be surfaced in the command palette because the top stack entries expose labels. +- Menu accelerators and keyboard shortcuts use the shared command manifest instead of Tauri's native Undo/Redo menu builders. +- ADR-0106 remains valid for the broader menu ownership model, but its native Undo/Redo exception is superseded by this renderer action-history route. diff --git a/docs/adr/README.md b/docs/adr/README.md index a1f922da..51110720 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -175,4 +175,6 @@ proposed → active → superseded | [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active | | [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) | AppImage external fallback for audio and video previews | active | | [0122](0122-scalar-array-frontmatter-properties.md) | Scalar array frontmatter properties | active | -| [0123](0123-full-vault-graph-for-secondary-note-windows.md) | Full vault graph for secondary note windows | active | +| [0123](0123-full-vault-graph-for-secondary-note-windows.md) | Full vault graph for secondary note windows | superseded -> [0124](0124-cached-secondary-note-window-startup.md) | +| [0124](0124-cached-secondary-note-window-startup.md) | Cached secondary note window startup | active | +| [0126](0126-renderer-action-history.md) | Renderer action history for app-level undo and redo | active | diff --git a/lara.lock b/lara.lock index 35801758..750b16eb 100644 --- a/lara.lock +++ b/lara.lock @@ -40,6 +40,10 @@ files: command.note.newType: adce5108f18945cc502a06c02445e32d command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2 command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047 + command.note.undo: 1cdc076b28f70afac5fcedadf99fa119 + command.note.undoAction: 83a1f43c314533161109d8d4daf032f2 + command.note.redo: 5afeaba074ef570dc720caaa855d49f6 + command.note.redoAction: 7556c9dd6845991933c56006bf7ffbac command.note.pastePlainText: 09e8075dbd91e11878f8f4a138df760c command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1 diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index e22361cb..46b9850f 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -336,13 +336,16 @@ fn build_file_menu(app: &App) -> MenuResult { fn build_edit_menu(app: &App) -> MenuResult { let section = manifest_section("Edit")?; let mut items = section.items.iter(); - let mut builder = SubmenuBuilder::new(app, "Edit") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste(); + let mut builder = SubmenuBuilder::new(app, "Edit"); + + for item in items.by_ref() { + if matches!(item, ManifestMenuItem::Separator) { + break; + } + builder = append_manifest_item(app, builder, item)?; + } + + builder = builder.separator().cut().copy().paste(); if let Some(paste_plain_text) = items.next() { builder = append_manifest_item(app, builder, paste_plain_text)?; diff --git a/src/App.tsx b/src/App.tsx index 11826fa3..36229d6f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -141,6 +141,21 @@ function isActiveElementInsideEditorSurface(): boolean { return Boolean(activeElement.closest(ACTIVE_EDITOR_SURFACE_SELECTOR)) } +function isTextEditingElementFocused(): boolean { + const activeElement = document.activeElement + if (!(activeElement instanceof HTMLElement)) return false + return activeElement.tagName === 'INPUT' + || activeElement.tagName === 'TEXTAREA' + || activeElement.isContentEditable + || activeElement.closest('[contenteditable="true"]') !== null +} + +function runNativeTextHistoryCommand(command: 'undo' | 'redo'): boolean { + if (!isTextEditingElementFocused()) return false + document.execCommand(command) + return true +} + // Type declarations for mock content storage and test overrides declare global { interface Window { @@ -1138,6 +1153,7 @@ function App() { handleDeleteProperty: notes.handleDeleteProperty, setToastMessage, createTypeEntry: notes.createTypeEntrySilent, onBeforeAction: flushEditorStateBeforeAction, + actionHistory: notes.actionHistory, }) const resolveVaultPathForNotePath = useCallback((path: string) => { @@ -1557,6 +1573,14 @@ function App() { }) const activeEditorVaultPath = activeTab ? vaultPathForEntry(activeTab.entry, resolvedPath) : resolvedPath const commandAiActions = useAppCommandAiActions(aiFeaturesEnabled, dialogs, aiAgentsStatus, vaultAiGuidanceStatus, restoreVaultAiGuidanceCommand, aiAgentPreferences) + const undoCommand = useCallback(() => { + if (runNativeTextHistoryCommand('undo')) return + void notes.handleUndo() + }, [notes]) + const redoCommand = useCallback(() => { + if (runNativeTextHistoryCommand('redo')) return + void notes.handleRedo() + }, [notes]) const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, @@ -1574,6 +1598,12 @@ function App() { onCreateNote: notes.handleCreateNoteImmediate, onCreateNoteOfType: notes.handleCreateNoteImmediate, onSave: appSave.handleSave, + onUndo: undoCommand, + onRedo: redoCommand, + canUndo: notes.canUndo, + canRedo: notes.canRedo, + undoLabel: notes.undoLabel, + redoLabel: notes.redoLabel, onOpenSettings: handleOpenSettings, onOpenFeedback: openFeedback, onDeleteNote: deleteActions.handleDeleteNote, diff --git a/src/hooks/appCommandCatalog.ts b/src/hooks/appCommandCatalog.ts index 30e247b2..30bed2e7 100644 --- a/src/hooks/appCommandCatalog.ts +++ b/src/hooks/appCommandCatalog.ts @@ -38,6 +38,8 @@ type SimpleHandlerKey = | 'onQuickOpen' | 'onSave' | 'onFindInNote' + | 'onUndo' + | 'onRedo' | 'onReplaceInNote' | 'onPastePlainText' | 'onSearch' @@ -305,6 +307,15 @@ function normalizeShortcutKey(key: string): string { return key.length === 1 ? key.toLowerCase() : key } +function isPlatformRedoAlternate(event: ShortcutEventLike): boolean { + return !isMac() + && !event.altKey + && !event.metaKey + && event.ctrlKey + && !event.shiftKey + && normalizeShortcutKey(event.key) === 'y' +} + for (const [id, definition] of Object.entries(APP_COMMAND_DEFINITIONS) as Array<[AppCommandId, AppCommandDefinition]>) { const shortcut = definition.shortcut if (!shortcut) continue @@ -391,6 +402,8 @@ export function findShortcutCommandId( } export function findShortcutCommandIdForEvent(event: ShortcutEventLike): AppCommandId | null { + if (isPlatformRedoAlternate(event)) return APP_COMMAND_IDS.editRedo + for (const combo of shortcutCombosForEvent(event)) { const commandId = findShortcutCommandId(combo, event.key, event.code) if (commandId) return commandId diff --git a/src/hooks/appCommandDispatcher.test.ts b/src/hooks/appCommandDispatcher.test.ts index 9903803e..aeb64537 100644 --- a/src/hooks/appCommandDispatcher.test.ts +++ b/src/hooks/appCommandDispatcher.test.ts @@ -34,6 +34,8 @@ function makeHandlers(): AppCommandHandlers { onCreateType: vi.fn(), onQuickOpen: vi.fn(), onSave: vi.fn(), + onUndo: vi.fn(), + onRedo: vi.fn(), onOpenSettings: vi.fn(), onToggleInspector: vi.fn(), onCommandPalette: vi.fn(), @@ -133,6 +135,8 @@ describe('appCommandDispatcher', () => { it('finds raw editor, AI, and plain-text paste shortcuts from the shared catalog', () => { expect(findShortcutCommandId('command-or-ctrl', 'o', 'KeyO')).toBe(APP_COMMAND_IDS.fileQuickOpen) + expect(findShortcutCommandId('command-or-ctrl', 'z', 'KeyZ')).toBe(APP_COMMAND_IDS.editUndo) + expect(findShortcutCommandId('command-or-ctrl-shift', 'z', 'KeyZ')).toBe(APP_COMMAND_IDS.editRedo) expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor) expect(findShortcutCommandId('command-or-ctrl-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat) expect(findShortcutCommandId('command-or-ctrl-shift', 'T', 'KeyT')).toBe(APP_COMMAND_IDS.viewToggleTableOfContents) @@ -200,6 +204,16 @@ describe('appCommandDispatcher', () => { expectShortcutEventCommand({ key: 'l', code: 'KeyL', ctrlKey: true, shiftKey: true }, APP_COMMAND_IDS.viewToggleAiChat) expectShortcutEventCommand({ key: 'T', code: 'KeyT', metaKey: true, shiftKey: true }, APP_COMMAND_IDS.viewToggleTableOfContents) expectShortcutEventCommand({ key: 'V', code: 'KeyV', metaKey: true, shiftKey: true }, APP_COMMAND_IDS.editPastePlainText) + expectShortcutEventCommand({ key: 'z', code: 'KeyZ', metaKey: true }, APP_COMMAND_IDS.editUndo) + expectShortcutEventCommand({ key: 'z', code: 'KeyZ', metaKey: true, shiftKey: true }, APP_COMMAND_IDS.editRedo) + }) + + it('maps Ctrl+Y to redo only off macOS', () => { + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + expectShortcutEventCommand({ key: 'y', code: 'KeyY', ctrlKey: true }, APP_COMMAND_IDS.editRedo) + + setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') + expectShortcutEventCommand({ key: 'y', code: 'KeyY', ctrlKey: true }, null) }) it('ignores macOS Control-only shortcuts so native text editing bindings pass through', () => { @@ -242,6 +256,14 @@ describe('appCommandDispatcher', () => { expect(handlers.onPastePlainText).toHaveBeenCalled() }) + it('dispatches undo and redo through the shared command path', () => { + const handlers = makeHandlers() + expect(dispatchAppCommand(APP_COMMAND_IDS.editUndo, handlers)).toBe(true) + expect(dispatchAppCommand(APP_COMMAND_IDS.editRedo, handlers)).toBe(true) + expect(handlers.onUndo).toHaveBeenCalledOnce() + expect(handlers.onRedo).toHaveBeenCalledOnce() + }) + it('uses the active note for note-scoped commands', () => { const handlers = makeHandlers() expect(dispatchAppCommand(APP_COMMAND_IDS.noteToggleFavorite, handlers)).toBe(true) diff --git a/src/hooks/appCommandDispatcher.ts b/src/hooks/appCommandDispatcher.ts index 528b5ff1..ed45f463 100644 --- a/src/hooks/appCommandDispatcher.ts +++ b/src/hooks/appCommandDispatcher.ts @@ -44,6 +44,8 @@ export interface AppCommandHandlers { onArchiveNote: (path: string) => void onDeleteNote: (path: string) => void onFindInNote?: () => void + onUndo?: () => void + onRedo?: () => void onReplaceInNote?: () => void onPastePlainText: () => void onSearch: () => void @@ -81,6 +83,8 @@ type SimpleHandlerKey = keyof Pick< | 'onQuickOpen' | 'onSave' | 'onFindInNote' + | 'onUndo' + | 'onRedo' | 'onReplaceInNote' | 'onPastePlainText' | 'onSearch' @@ -126,6 +130,8 @@ const SIMPLE_HANDLER_EXECUTORS: readonly [SimpleHandlerKey, SimpleHandlerExecuto ['onQuickOpen', (handlers) => handlers.onQuickOpen()], ['onSave', (handlers) => handlers.onSave()], ['onFindInNote', (handlers) => handlers.onFindInNote?.()], + ['onUndo', (handlers) => handlers.onUndo?.()], + ['onRedo', (handlers) => handlers.onRedo?.()], ['onReplaceInNote', (handlers) => handlers.onReplaceInNote?.()], ['onPastePlainText', (handlers) => handlers.onPastePlainText()], ['onSearch', (handlers) => handlers.onSearch()], diff --git a/src/hooks/appKeyboardShortcuts.ts b/src/hooks/appKeyboardShortcuts.ts index d77bc182..9ebcdf04 100644 --- a/src/hooks/appKeyboardShortcuts.ts +++ b/src/hooks/appKeyboardShortcuts.ts @@ -15,6 +15,8 @@ export type KeyboardActions = Pick< | 'onSearch' | 'onCreateNote' | 'onSave' + | 'onUndo' + | 'onRedo' | 'onFindInNote' | 'onReplaceInNote' | 'onPastePlainText' @@ -40,6 +42,8 @@ export type KeyboardActions = Pick< const TEXT_EDITING_KEYS = new Set(['Backspace', 'Delete']) const TEXT_EDITING_BLOCKED_COMMANDS = new Set([ + APP_COMMAND_IDS.editUndo, + APP_COMMAND_IDS.editRedo, APP_COMMAND_IDS.viewGoBack, APP_COMMAND_IDS.viewGoForward, ]) diff --git a/src/hooks/commands/localizeCommands.ts b/src/hooks/commands/localizeCommands.ts index ef6f0ee7..12deada2 100644 --- a/src/hooks/commands/localizeCommands.ts +++ b/src/hooks/commands/localizeCommands.ts @@ -80,22 +80,45 @@ function parenthesizedSuffix(label: string): string | null { return label.match(/\(([^)]+)\)$/)?.[1] ?? null } -function localizeNoteStateCommand(command: CommandAction, t: Translate): string | null { - if (command.id === 'archive-note') { - return t(command.label === 'Unarchive Note' ? 'command.note.unarchiveNote' : 'command.note.archiveNote') +function localizeUndoRedoCommand(command: CommandAction, t: Translate): string | null { + if (command.id === 'undo-action') { + const action = stripKnownPrefix(command.label, 'Undo ') + return action && action !== command.label + ? t('command.note.undoAction', { action }) + : t('command.note.undo') } - if (command.id === 'toggle-favorite') { - return t(command.label === 'Remove from Favorites' ? 'command.note.removeFavorite' : 'command.note.addFavorite') - } - - if (command.id === 'toggle-organized') { - return t(command.label === 'Mark as Unorganized' ? 'command.note.markUnorganized' : 'command.note.markOrganized') + if (command.id === 'redo-action') { + const action = stripKnownPrefix(command.label, 'Redo ') + return action && action !== command.label + ? t('command.note.redoAction', { action }) + : t('command.note.redo') } return null } +function noteToggleLabelKey(command: CommandAction): TranslationKey | null { + switch (command.id) { + case 'archive-note': + return command.label === 'Unarchive Note' ? 'command.note.unarchiveNote' : 'command.note.archiveNote' + case 'toggle-favorite': + return command.label === 'Remove from Favorites' ? 'command.note.removeFavorite' : 'command.note.addFavorite' + case 'toggle-organized': + return command.label === 'Mark as Unorganized' ? 'command.note.markUnorganized' : 'command.note.markOrganized' + default: + return null + } +} + +function localizeNoteStateCommand(command: CommandAction, t: Translate): string | null { + const undoRedoLabel = localizeUndoRedoCommand(command, t) + if (undoRedoLabel) return undoRedoLabel + + const toggleKey = noteToggleLabelKey(command) + return toggleKey ? t(toggleKey) : null +} + function localizeColumnsCommand(command: CommandAction, t: Translate): string { if (command.label === 'Customize All Notes columns') return t('noteList.properties.customizeAllColumns') if (command.label === 'Customize Inbox columns') return t('noteList.properties.customizeInboxColumns') diff --git a/src/hooks/commands/noteCommands.ts b/src/hooks/commands/noteCommands.ts index f4c11926..1127d8e0 100644 --- a/src/hooks/commands/noteCommands.ts +++ b/src/hooks/commands/noteCommands.ts @@ -13,6 +13,12 @@ interface NoteCommandsConfig { onCreateType?: () => void currentFolderCreateOptions?: ImmediateCreateOptions onSave: () => void + onUndo?: () => void + onRedo?: () => void + canUndo?: boolean + canRedo?: boolean + undoLabel?: string | null + redoLabel?: string | null onFindInNote?: () => void onReplaceInNote?: () => void onPastePlainText: () => void @@ -101,6 +107,7 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] { enabled: config.hasActiveNote, execute: config.onSave, }), + ...buildHistoryNoteCommands(config), createNoteCommand({ id: 'paste-plain-text', label: 'Paste without formatting', @@ -113,6 +120,31 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] { ] } +function historyCommandLabel(action: string, label?: string | null): string { + return [action, label].filter(Boolean).join(' ') +} + +function buildHistoryNoteCommands(config: NoteCommandsConfig): CommandAction[] { + return [ + createNoteCommand({ + id: 'undo-action', + label: historyCommandLabel('Undo', config.undoLabel), + shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.editUndo), + keywords: ['undo', 'revert', 'history'], + enabled: Boolean(config.canUndo && config.onUndo), + execute: config.onUndo, + }), + createNoteCommand({ + id: 'redo-action', + label: historyCommandLabel('Redo', config.redoLabel), + shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.editRedo), + keywords: ['redo', 'repeat', 'history'], + enabled: Boolean(config.canRedo && config.onRedo), + execute: config.onRedo, + }), + ] +} + function buildPathNoteCommands(config: NoteCommandsConfig): CommandAction[] { return [ ...buildDestructiveNoteCommands(config), diff --git a/src/hooks/useActionHistory.test.ts b/src/hooks/useActionHistory.test.ts new file mode 100644 index 00000000..0cfd0948 --- /dev/null +++ b/src/hooks/useActionHistory.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useActionHistory } from './useActionHistory' + +describe('useActionHistory', () => { + it('undos and redos entries in stack order', async () => { + const calls: string[] = [] + const { result } = renderHook(() => useActionHistory()) + + act(() => { + result.current.record({ + label: 'First', + undo: () => calls.push('undo:first'), + redo: () => calls.push('redo:first'), + }) + result.current.record({ + label: 'Second', + undo: () => calls.push('undo:second'), + redo: () => calls.push('redo:second'), + }) + }) + + expect(result.current.canUndo).toBe(true) + expect(result.current.undoLabel).toBe('Second') + + await act(async () => { + expect(await result.current.undo()).toBe(true) + }) + expect(calls).toEqual(['undo:second']) + expect(result.current.redoLabel).toBe('Second') + + await act(async () => { + expect(await result.current.undo()).toBe(true) + expect(await result.current.redo()).toBe(true) + }) + + expect(calls).toEqual(['undo:second', 'undo:first', 'redo:first']) + expect(result.current.undoLabel).toBe('First') + expect(result.current.redoLabel).toBe('Second') + }) + + it('does not record nested actions while replaying', async () => { + const nested = vi.fn() + const { result } = renderHook(() => useActionHistory()) + + act(() => { + result.current.record({ + label: 'Outer', + undo: () => { + result.current.record({ + label: 'Nested', + undo: nested, + redo: nested, + }) + }, + redo: vi.fn(), + }) + }) + + await act(async () => { + await result.current.undo() + }) + + expect(result.current.canUndo).toBe(false) + expect(result.current.redoLabel).toBe('Outer') + expect(nested).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useActionHistory.ts b/src/hooks/useActionHistory.ts new file mode 100644 index 00000000..7164bae7 --- /dev/null +++ b/src/hooks/useActionHistory.ts @@ -0,0 +1,98 @@ +import { useCallback, useMemo, useRef, useState } from 'react' + +export interface ActionHistoryEntry { + label: string + undo: () => void | Promise + redo: () => void | Promise +} + +export interface ActionHistoryController { + canUndo: boolean + canRedo: boolean + undoLabel: string | null + redoLabel: string | null + isReplaying: () => boolean + record: (entry: ActionHistoryEntry) => void + undo: () => Promise + redo: () => Promise + withoutRecording: (run: () => T | Promise) => Promise +} + +interface ActionHistorySnapshot { + canUndo: boolean + canRedo: boolean + undoLabel: string | null + redoLabel: string | null +} + +function snapshot( + undoStack: readonly ActionHistoryEntry[], + redoStack: readonly ActionHistoryEntry[], +): ActionHistorySnapshot { + return { + canUndo: undoStack.length > 0, + canRedo: redoStack.length > 0, + undoLabel: undoStack.at(-1)?.label ?? null, + redoLabel: redoStack.at(-1)?.label ?? null, + } +} + +export function useActionHistory(): ActionHistoryController { + const undoStackRef = useRef([]) + const redoStackRef = useRef([]) + const replayDepthRef = useRef(0) + const [state, setState] = useState(() => snapshot([], [])) + + const publish = useCallback(() => { + setState(snapshot(undoStackRef.current, redoStackRef.current)) + }, []) + + const isReplaying = useCallback(() => replayDepthRef.current > 0, []) + + const withoutRecording = useCallback(async (run: () => T | Promise): Promise => { + replayDepthRef.current += 1 + try { + return await run() + } finally { + replayDepthRef.current = Math.max(0, replayDepthRef.current - 1) + } + }, []) + + const record = useCallback((entry: ActionHistoryEntry) => { + if (isReplaying()) return + undoStackRef.current = [...undoStackRef.current, entry] + redoStackRef.current = [] + publish() + }, [isReplaying, publish]) + + const undo = useCallback(async () => { + const entry = undoStackRef.current.at(-1) + if (!entry) return false + + await withoutRecording(entry.undo) + undoStackRef.current = undoStackRef.current.slice(0, -1) + redoStackRef.current = [...redoStackRef.current, entry] + publish() + return true + }, [publish, withoutRecording]) + + const redo = useCallback(async () => { + const entry = redoStackRef.current.at(-1) + if (!entry) return false + + await withoutRecording(entry.redo) + redoStackRef.current = redoStackRef.current.slice(0, -1) + undoStackRef.current = [...undoStackRef.current, entry] + publish() + return true + }, [publish, withoutRecording]) + + return useMemo(() => ({ + ...state, + isReplaying, + record, + undo, + redo, + withoutRecording, + }), [isReplaying, record, redo, state, undo, withoutRecording]) +} diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index a0635b55..82966b36 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -28,6 +28,12 @@ interface AppCommandsConfig { onCommandPalette: () => void onSearch: () => void onFindInNote?: () => void + onUndo?: () => void + onRedo?: () => void + canUndo?: boolean + canRedo?: boolean + undoLabel?: string | null + redoLabel?: string | null onReplaceInNote?: () => void onPastePlainText: () => void onCreateNote: (type?: string, options?: ImmediateCreateOptions) => void @@ -158,6 +164,12 @@ type CommandRegistryCoreActions = Pick< | 'onCreateNote' | 'onCreateNoteOfType' | 'onSave' + | 'onUndo' + | 'onRedo' + | 'canUndo' + | 'canRedo' + | 'undoLabel' + | 'redoLabel' | 'onFindInNote' | 'onReplaceInNote' | 'onPastePlainText' @@ -267,6 +279,8 @@ function createKeyboardActions( onPastePlainText: config.onPastePlainText, onCreateNote: config.onCreateNote, onSave: config.onSave, + onUndo: config.onUndo, + onRedo: config.onRedo, onOpenSettings: config.onOpenSettings, onDeleteNote: config.onDeleteNote, onSetViewMode: config.onSetViewMode, @@ -317,6 +331,8 @@ function createMenuEventActionHandlers( | 'onZoomReset' | 'onDeleteNote' | 'onFindInNote' + | 'onUndo' + | 'onRedo' | 'onReplaceInNote' | 'onPastePlainText' | 'onSearch' @@ -344,6 +360,8 @@ function createMenuEventActionHandlers( onZoomReset: config.onZoomReset, onDeleteNote: config.onDeleteNote, onFindInNote: config.onFindInNote, + onUndo: config.onUndo, + onRedo: config.onRedo, onReplaceInNote: config.onReplaceInNote, onPastePlainText: config.onPastePlainText, onSearch: config.onSearch, @@ -450,6 +468,12 @@ function createCommandRegistryCoreConfig( onCreateNote: config.onCreateNote, onCreateNoteOfType: config.onCreateNoteOfType, onSave: config.onSave, + onUndo: config.onUndo, + onRedo: config.onRedo, + canUndo: config.canUndo, + canRedo: config.canRedo, + undoLabel: config.undoLabel, + redoLabel: config.redoLabel, onOpenSettings: config.onOpenSettings, onOpenFeedback: config.onOpenFeedback, onDeleteNote: config.onDeleteNote, diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts index efe078ab..faeb3232 100644 --- a/src/hooks/useAppKeyboard.test.ts +++ b/src/hooks/useAppKeyboard.test.ts @@ -40,6 +40,8 @@ function makeActions() { onSearch: vi.fn(), onCreateNote: vi.fn(), onSave: vi.fn(), + onUndo: vi.fn(), + onRedo: vi.fn(), onOpenSettings: vi.fn(), onDeleteNote: vi.fn(), onArchiveNote: vi.fn(), @@ -307,11 +309,47 @@ describe('useAppKeyboard', () => { }) }) - function withFocusedInput(fn: () => void) { + it('Cmd+Z and Cmd+Shift+Z run app history when text is not focused', () => { + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + + fireKey('z', { metaKey: true, code: 'KeyZ' }) + fireKey('z', { metaKey: true, shiftKey: true, code: 'KeyZ' }) + + expect(actions.onUndo).toHaveBeenCalledOnce() + expect(actions.onRedo).toHaveBeenCalledOnce() + }) + + it('Ctrl+Y runs redo on Windows-style platforms', () => { + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + + fireKey('y', { ctrlKey: true, code: 'KeyY' }) + + expect(actions.onRedo).toHaveBeenCalledOnce() + }) + + it('lets focused text inputs own undo and redo shortcuts', () => { + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + + withFocusedInput((input) => { + const undo = fireKeyOnTarget(input, 'z', { metaKey: true, code: 'KeyZ' }) + const redo = fireKeyOnTarget(input, 'z', { metaKey: true, shiftKey: true, code: 'KeyZ' }) + + expect(undo.defaultPrevented).toBe(false) + expect(redo.defaultPrevented).toBe(false) + expect(actions.onUndo).not.toHaveBeenCalled() + expect(actions.onRedo).not.toHaveBeenCalled() + }) + }) + + function withFocusedInput(fn: (input: HTMLInputElement) => void) { const input = document.createElement('input') document.body.appendChild(input) input.focus() - try { fn() } finally { document.body.removeChild(input) } + try { fn(input) } finally { document.body.removeChild(input) } } function withFocusedContentEditable(fn: (editable: HTMLDivElement) => void) { diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 5fd48ef4..dcfbda79 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -332,6 +332,53 @@ describe('useCommandRegistry', () => { expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined() }) + it('exposes undo and redo commands only when action history has entries', () => { + const onUndo = vi.fn() + const onRedo = vi.fn() + const { result, rerender } = renderHook( + (props) => useCommandRegistry(props), + { + initialProps: makeConfig({ + onUndo, + onRedo, + canUndo: true, + canRedo: false, + undoLabel: 'Archive Note', + redoLabel: null, + }), + }, + ) + + expect(findCommand(result.current, 'undo-action')).toMatchObject({ + enabled: true, + label: 'Undo Archive Note', + shortcut: formatShortcutDisplay({ display: '⌘Z' }), + }) + expect(findCommand(result.current, 'redo-action')).toMatchObject({ + enabled: false, + label: 'Redo', + shortcut: formatShortcutDisplay({ display: '⌘⇧Z' }), + }) + + findCommand(result.current, 'undo-action')?.execute() + expect(onUndo).toHaveBeenCalledOnce() + + rerender(makeConfig({ + onUndo, + onRedo, + canUndo: false, + canRedo: true, + undoLabel: null, + redoLabel: 'Archive Note', + })) + + expect(findCommand(result.current, 'undo-action')?.enabled).toBe(false) + expect(findCommand(result.current, 'redo-action')).toMatchObject({ + enabled: true, + label: 'Redo Archive Note', + }) + }) + it('removes AI commands when AI features are disabled', () => { const config = makeConfig({ aiFeaturesEnabled: false, diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 83c2cc95..23e40023 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -69,6 +69,12 @@ interface CommandRegistryConfig { onCreateNote: (type?: string, options?: ImmediateCreateOptions) => void onCreateNoteOfType: (type: string) => void onSave: () => void + onUndo?: () => void + onRedo?: () => void + canUndo?: boolean + canRedo?: boolean + undoLabel?: string | null + redoLabel?: string | null onPastePlainText: () => void onOpenSettings: () => void onOpenFeedback?: () => void @@ -142,7 +148,8 @@ function currentFolderCreateOptions(selection: SidebarSelection | undefined): Im export function useCommandRegistry(config: CommandRegistryConfig): import('./commands/types').CommandAction[] { const { activeTabPath, entries, modifiedCount, - onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onPastePlainText, onOpenSettings, onOpenFeedback, + onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onUndo, onRedo, canUndo, canRedo, undoLabel, redoLabel, + onPastePlainText, onOpenSettings, onOpenFeedback, onDeleteNote, onArchiveNote, onUnarchiveNote, onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onToggleTableOfContents, onOpenVault, onCreateEmptyVault, @@ -207,6 +214,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com const noteCommands = useMemo(() => buildNoteCommands({ hasActiveNote, activeTabPath, activeFileKind: activeEntry?.fileKind ?? 'markdown', isArchived, currentFolderCreateOptions: folderCreateOptions, onCreateNote, onCreateType, onSave, + onUndo, onRedo, canUndo, canRedo, undoLabel, redoLabel, onFindInNote, onReplaceInNote, onPastePlainText, onDeleteNote, onArchiveNote, onUnarchiveNote, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder, @@ -217,7 +225,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onRestoreDeletedNote, canRestoreDeletedNote, }), [ hasActiveNote, activeTabPath, activeEntry?.fileKind, isArchived, - folderCreateOptions, onCreateNote, onCreateType, onSave, onFindInNote, onReplaceInNote, onPastePlainText, onDeleteNote, onArchiveNote, onUnarchiveNote, + folderCreateOptions, onCreateNote, onCreateType, onSave, onUndo, onRedo, canUndo, canRedo, undoLabel, redoLabel, + onFindInNote, onReplaceInNote, onPastePlainText, onDeleteNote, onArchiveNote, onUnarchiveNote, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal, diff --git a/src/hooks/useEntryActions.history.test.ts b/src/hooks/useEntryActions.history.test.ts new file mode 100644 index 00000000..af5c29d9 --- /dev/null +++ b/src/hooks/useEntryActions.history.test.ts @@ -0,0 +1,171 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { VaultEntry } from '../types' +import { useEntryActions } from './useEntryActions' +import type { ActionHistoryController, ActionHistoryEntry } from './useActionHistory' + +const NOTE_PATH = '/vault/note/test.md' + +const makeEntry = (overrides: Partial = {}): VaultEntry => ({ + path: NOTE_PATH, + filename: 'test.md', + title: 'Test Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + archived: false, + modifiedAt: 1700000000, + createdAt: 1700000000, + fileSize: 100, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: false, + ...overrides, +}) + +function makeActionHistory(records: ActionHistoryEntry[]) { + let replaying = false + const controller: ActionHistoryController = { + canUndo: false, + canRedo: false, + undoLabel: null, + redoLabel: null, + isReplaying: () => replaying, + record: (entry) => { records.push(entry) }, + undo: vi.fn().mockResolvedValue(false), + redo: vi.fn().mockResolvedValue(false), + withoutRecording: (run) => Promise.resolve(run()), + } + const replay = async (entry: ActionHistoryEntry, direction: 'undo' | 'redo') => { + replaying = true + try { + if (direction === 'undo') { + await entry.undo() + } else { + await entry.redo() + } + } finally { + replaying = false + } + } + return { controller, replay } +} + +describe('useEntryActions action history', () => { + const createTypeEntry = vi.fn() + const handleDeleteProperty = vi.fn().mockResolvedValue(undefined) + const handleUpdateFrontmatter = vi.fn().mockResolvedValue(undefined) + const onFrontmatterPersisted = vi.fn() + const setToastMessage = vi.fn() + const updateEntry = vi.fn() + + function setup(entries: VaultEntry[], actionHistory: ActionHistoryController) { + return renderHook(() => + useEntryActions({ + entries, + updateEntry, + handleUpdateFrontmatter, + handleDeleteProperty, + setToastMessage, + createTypeEntry, + onFrontmatterPersisted, + actionHistory, + }) + ) + } + + async function expectReplayableChange({ + entries = [makeEntry()], + label, + run, + verifyRedo, + verifyUndo, + }: { + entries?: VaultEntry[] + label: string + run: (actions: ReturnType) => Promise + verifyRedo: () => void + verifyUndo: () => void + }) { + const records: ActionHistoryEntry[] = [] + const history = makeActionHistory(records) + const { result } = setup(entries, history.controller) + + await act(() => run(result.current)) + + const record = records[0] + expect(record?.label).toBe(label) + vi.clearAllMocks() + await act(() => history.replay(record!, 'undo')) + verifyUndo() + + vi.clearAllMocks() + await act(() => history.replay(record!, 'redo')) + verifyRedo() + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('records replayable archive changes after persistence succeeds', async () => { + await expectReplayableChange({ + label: 'Archive Note', + run: (actions) => actions.handleArchiveNote(NOTE_PATH), + verifyUndo: () => expect(handleDeleteProperty).toHaveBeenCalledWith(NOTE_PATH, '_archived', { silent: true }), + verifyRedo: () => expect(handleUpdateFrontmatter).toHaveBeenCalledWith(NOTE_PATH, '_archived', true, { silent: true }), + }) + }) + + it('records replayable favorite changes after persistence succeeds', async () => { + await expectReplayableChange({ + label: 'Favorite', + run: (actions) => actions.handleToggleFavorite(NOTE_PATH), + verifyUndo: () => { + expect(handleDeleteProperty).toHaveBeenCalledWith(NOTE_PATH, '_favorite', { silent: true }) + expect(handleDeleteProperty).toHaveBeenCalledWith(NOTE_PATH, '_favorite_index', { silent: true }) + }, + verifyRedo: () => { + expect(handleUpdateFrontmatter).toHaveBeenCalledWith(NOTE_PATH, '_favorite', true, { silent: true }) + expect(handleUpdateFrontmatter).toHaveBeenCalledWith(NOTE_PATH, '_favorite_index', 1, { silent: true }) + }, + }) + }) + + it('records replayable organized changes after persistence succeeds', async () => { + await expectReplayableChange({ + label: 'Organized', + run: (actions) => actions.handleToggleOrganized(NOTE_PATH), + verifyUndo: () => expect(handleDeleteProperty).toHaveBeenCalledWith(NOTE_PATH, '_organized', { silent: true }), + verifyRedo: () => expect(handleUpdateFrontmatter).toHaveBeenCalledWith(NOTE_PATH, '_organized', true, { silent: true }), + }) + }) + + it('does not record failed favorite changes', async () => { + const records: ActionHistoryEntry[] = [] + const history = makeActionHistory(records) + handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full')) + const { result } = setup([makeEntry()], history.controller) + + await act(() => result.current.handleToggleFavorite(NOTE_PATH)) + + expect(records).toEqual([]) + }) +}) diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index e4241cbe..b35319a2 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -3,6 +3,7 @@ import type { VaultEntry } from '../types' import { isMissingFrontmatterTargetError, type FrontmatterOpOptions } from './frontmatterOps' import { trackEvent } from '../lib/telemetry' import { findTypeDefinition } from '../utils/typeDefinitions' +import type { ActionHistoryController, ActionHistoryEntry } from './useActionHistory' interface EntryActionsConfig { entries: VaultEntry[] @@ -14,6 +15,7 @@ interface EntryActionsConfig { onFrontmatterPersisted?: () => void /** Called before trash/archive to flush unsaved editor content to disk. */ onBeforeAction?: (path: string) => Promise + actionHistory?: ActionHistoryController } type ArchiveActionDeps = Pick +type SetFavoriteState = (path: string, state: FavoriteState) => Promise +type SetOrganizedState = (path: string, organized: boolean) => Promise + +function recordEntryActionHistory( + actionHistory: EntryActionsConfig['actionHistory'], + entry: ActionHistoryEntry, +): void { + if (!actionHistory || actionHistory.isReplaying()) return + actionHistory.record(entry) +} + +function recordAppliedEntryActionHistory( + applied: boolean, + actionHistory: EntryActionsConfig['actionHistory'], + entry: ActionHistoryEntry, +): void { + if (!applied) return + recordEntryActionHistory(actionHistory, entry) +} + +function recordFavoriteEntryHistory( + actionHistory: EntryActionsConfig['actionHistory'], + path: string, + result: FavoriteToggleResult | null, + setFavoriteState: SetFavoriteState, +): void { + if (!result) return + recordEntryActionHistory(actionHistory, { + label: 'Favorite', + undo: async () => { await setFavoriteState(path, result.before) }, + redo: async () => { await setFavoriteState(path, result.after) }, + }) +} + +function recordOrganizedEntryHistory( + actionHistory: EntryActionsConfig['actionHistory'], + path: string, + toggled: boolean, + before: boolean, + setOrganizedState: SetOrganizedState, +): void { + if (!toggled) return + recordEntryActionHistory(actionHistory, { + label: 'Organized', + undo: async () => { await setOrganizedState(path, before) }, + redo: async () => { await setOrganizedState(path, !before) }, + }) +} + +function favoriteRollbackState(entry: VaultEntry): FavoriteState { + return { favorite: entry.favorite, favoriteIndex: entry.favoriteIndex } +} + +function maxFavoriteIndex(entries: readonly VaultEntry[]): number { + return entries + .filter((candidate) => candidate.favorite) + .reduce((max, candidate) => Math.max(max, candidate.favoriteIndex ?? 0), 0) +} + +function nextFavoriteState(entry: VaultEntry, entries: readonly VaultEntry[]): FavoriteState { + return entry.favorite + ? { favorite: false, favoriteIndex: null } + : { favorite: true, favoriteIndex: maxFavoriteIndex(entries) + 1 } +} + +function trackFavoriteState(state: FavoriteState): void { + trackEvent(state.favorite ? 'note_favorited' : 'note_unfavorited') +} + +async function persistFavoriteIndex( + path: string, + favoriteIndex: number | null, + deps: FavoritePersistenceDeps, +): Promise { + if (favoriteIndex === null) { + await deps.handleDeleteProperty(path, '_favorite_index', { silent: true }) + return + } + await deps.handleUpdateFrontmatter(path, '_favorite_index', favoriteIndex, { silent: true }) +} + +async function persistFavoriteState( + path: string, + state: FavoriteState, + deps: FavoritePersistenceDeps, +): Promise { + if (!state.favorite) { + await deps.handleDeleteProperty(path, '_favorite', { silent: true }) + await deps.handleDeleteProperty(path, '_favorite_index', { silent: true }) + return + } + + await deps.handleUpdateFrontmatter(path, '_favorite', true, { silent: true }) + await persistFavoriteIndex(path, state.favoriteIndex, deps) +} + function logOptimisticRollback(label: string, error: unknown): void { if (isMissingFrontmatterTargetError(error)) { console.warn(label, error) @@ -147,7 +256,9 @@ function useArchiveActions({ updateEntry(path, { archived: false }) setToastMessage('Failed to archive note — rolled back') logOptimisticRollback('Optimistic archive rollback:', err) + return false } + return true }, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted]) const handleUnarchiveNote = useCallback(async (path: string) => { @@ -161,7 +272,9 @@ function useArchiveActions({ updateEntry(path, { archived: true }) setToastMessage('Failed to unarchive note — rolled back') logOptimisticRollback('Optimistic unarchive rollback:', err) + return false } + return true }, [handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted]) return { handleArchiveNote, handleUnarchiveNote } @@ -216,35 +329,33 @@ function useFavoriteAction({ setToastMessage, onFrontmatterPersisted, }: EntryStateActionDeps) { - return useCallback(async (path: string) => { + const setFavoriteState = useCallback(async (path: string, state: FavoriteState): Promise => { const entry = entries.find((candidate) => candidate.path === path) - if (!entry) return - if (entry.favorite) { - trackEvent('note_unfavorited') - updateEntry(path, { favorite: false, favoriteIndex: null }) - try { - await handleDeleteProperty(path, '_favorite', { silent: true }) - await handleDeleteProperty(path, '_favorite_index', { silent: true }) - onFrontmatterPersisted?.() - } catch { - updateEntry(path, { favorite: true, favoriteIndex: entry.favoriteIndex }) - setToastMessage('Failed to unfavorite — rolled back') - } - } else { - trackEvent('note_favorited') - const maxIndex = entries.filter((candidate) => candidate.favorite).reduce((max, candidate) => Math.max(max, candidate.favoriteIndex ?? 0), 0) - const newIndex = maxIndex + 1 - updateEntry(path, { favorite: true, favoriteIndex: newIndex }) - try { - await handleUpdateFrontmatter(path, '_favorite', true, { silent: true }) - await handleUpdateFrontmatter(path, '_favorite_index', newIndex, { silent: true }) - onFrontmatterPersisted?.() - } catch { - updateEntry(path, { favorite: false, favoriteIndex: null }) - setToastMessage('Failed to favorite — rolled back') - } + if (!entry) return false + const rollback = favoriteRollbackState(entry) + updateEntry(path, state) + try { + await persistFavoriteState(path, state, { handleUpdateFrontmatter, handleDeleteProperty }) + onFrontmatterPersisted?.() + return true + } catch { + updateEntry(path, rollback) + setToastMessage(`Failed to ${state.favorite ? 'favorite' : 'unfavorite'} — rolled back`) + return false } }, [entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted]) + + const toggleFavorite = useCallback(async (path: string): Promise => { + const entry = entries.find((candidate) => candidate.path === path) + if (!entry) return null + const before = favoriteRollbackState(entry) + const after = nextFavoriteState(entry, entries) + trackFavoriteState(after) + + return await setFavoriteState(path, after) ? { before, after } : null + }, [entries, setFavoriteState]) + + return { setFavoriteState, toggleFavorite } } function useOrganizedAction({ @@ -255,35 +366,34 @@ function useOrganizedAction({ setToastMessage, onFrontmatterPersisted, }: EntryStateActionDeps) { - return useCallback(async (path: string) => { + const setOrganizedState = useCallback(async (path: string, organized: boolean): Promise => { const entry = entries.find((candidate) => candidate.path === path) if (!entry) return false - if (entry.organized) { - trackEvent('note_unorganized') - updateEntry(path, { organized: false }) - try { - await handleDeleteProperty(path, '_organized', { silent: true }) - onFrontmatterPersisted?.() - return true - } catch { - updateEntry(path, { organized: true }) - setToastMessage('Failed to unorganize — rolled back') - return false - } - } - - trackEvent('note_organized') - updateEntry(path, { organized: true }) + updateEntry(path, { organized }) try { - await handleUpdateFrontmatter(path, '_organized', true, { silent: true }) + if (organized) { + await handleUpdateFrontmatter(path, '_organized', true, { silent: true }) + } else { + await handleDeleteProperty(path, '_organized', { silent: true }) + } onFrontmatterPersisted?.() return true } catch { - updateEntry(path, { organized: false }) - setToastMessage('Failed to organize — rolled back') + updateEntry(path, { organized: entry.organized }) + setToastMessage(`Failed to ${organized ? 'organize' : 'unorganize'} — rolled back`) return false } }, [entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted]) + + const toggleOrganized = useCallback(async (path: string): Promise => { + const entry = entries.find((candidate) => candidate.path === path) + if (!entry) return false + const organized = !entry.organized + trackEvent(organized ? 'note_organized' : 'note_unorganized') + return setOrganizedState(path, organized) + }, [entries, setOrganizedState]) + + return { setOrganizedState, toggleOrganized } } function useReorderFavoritesAction({ updateEntry, handleUpdateFrontmatter, onFrontmatterPersisted }: ReorderFavoritesDeps) { @@ -301,13 +411,44 @@ function useReorderFavoritesAction({ updateEntry, handleUpdateFrontmatter, onFro export function useEntryActions(config: EntryActionsConfig) { const archiveActions = useArchiveActions(config) const typeActions = useTypeActions(config) - const handleToggleFavorite = useFavoriteAction(config) - const handleToggleOrganized = useOrganizedAction(config) + const favoriteActions = useFavoriteAction(config) + const organizedActions = useOrganizedAction(config) const handleReorderFavorites = useReorderFavoritesAction(config) + const handleArchiveNote = useCallback(async (path: string) => { + const archived = await archiveActions.handleArchiveNote(path) + recordAppliedEntryActionHistory(archived, config.actionHistory, { + label: 'Archive Note', + undo: async () => { await archiveActions.handleUnarchiveNote(path) }, + redo: async () => { await archiveActions.handleArchiveNote(path) }, + }) + }, [archiveActions, config.actionHistory]) + + const handleUnarchiveNote = useCallback(async (path: string) => { + const unarchived = await archiveActions.handleUnarchiveNote(path) + recordAppliedEntryActionHistory(unarchived, config.actionHistory, { + label: 'Unarchive Note', + undo: async () => { await archiveActions.handleArchiveNote(path) }, + redo: async () => { await archiveActions.handleUnarchiveNote(path) }, + }) + }, [archiveActions, config.actionHistory]) + + const handleToggleFavorite = useCallback(async (path: string) => { + const result = await favoriteActions.toggleFavorite(path) + recordFavoriteEntryHistory(config.actionHistory, path, result, favoriteActions.setFavoriteState) + }, [config.actionHistory, favoriteActions]) + + const handleToggleOrganized = useCallback(async (path: string) => { + const entry = config.entries.find((candidate) => candidate.path === path) + const before = entry?.organized ?? false + const toggled = await organizedActions.toggleOrganized(path) + recordOrganizedEntryHistory(config.actionHistory, path, toggled, before, organizedActions.setOrganizedState) + return toggled + }, [config.actionHistory, config.entries, organizedActions]) return { - ...archiveActions, ...typeActions, + handleArchiveNote, + handleUnarchiveNote, handleToggleFavorite, handleToggleOrganized, handleReorderFavorites, diff --git a/src/hooks/useNoteActions.hook.test.ts b/src/hooks/useNoteActions.hook.test.ts index 4b1d9e1c..acef4bdd 100644 --- a/src/hooks/useNoteActions.hook.test.ts +++ b/src/hooks/useNoteActions.hook.test.ts @@ -8,6 +8,7 @@ import { useNoteActions } from './useNoteActions' import type { NoteActionsConfig } from './useNoteActions' import { GITIGNORED_VISIBILITY_APPLIED_EVENT } from '../lib/gitignoredVisibilityEvents' import { clearNoteContentCache, getCachedNoteContentEntry } from './noteContentCache' +import { updateMockFrontmatter } from './mockFrontmatterHelpers' vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) vi.mock('../mock-tauri', () => ({ @@ -258,6 +259,50 @@ describe('useNoteActions hook', () => { expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { color: 'blue' }) }) + it('records successful frontmatter updates for undo and redo', async () => { + const entry = makeEntry({ path: '/vault/note.md', status: 'Active' }) + const { result } = renderHook(() => useNoteActions(makeConfig([entry]))) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done') + }) + + expect(result.current.canUndo).toBe(true) + expect(result.current.undoLabel).toBe('Update status') + + await act(async () => { + await result.current.handleUndo() + }) + await act(async () => { + await result.current.handleRedo() + }) + + expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: 'Done' }) + expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: 'Active' }) + expect(updateEntry).toHaveBeenLastCalledWith('/vault/note.md', { status: 'Done' }) + }) + + it('does not record silent or failed frontmatter updates', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const entry = makeEntry({ path: '/vault/note.md', status: 'Active' }) + const { result } = renderHook(() => useNoteActions(makeConfig([entry]))) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done', { silent: true }) + }) + expect(result.current.canUndo).toBe(false) + + vi.mocked(updateMockFrontmatter).mockImplementationOnce(() => { + throw new Error('disk full') + }) + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Blocked') + }) + + expect(result.current.canUndo).toBe(false) + errorSpy.mockRestore() + }) + it('handleDeleteProperty calls updateEntry with null/default values', async () => { const { result } = renderHook(() => useNoteActions(makeConfig())) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 13f138d9..fd4b7a3f 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' -import type { VaultEntry } from '../types' +import type { VaultEntry, VaultPropertyValue } from '../types' import type { FrontmatterValue } from '../components/Inspector' import { cacheNoteContent, useTabManagement } from './useTabManagement' import { @@ -15,6 +15,8 @@ import { import { runFrontmatterAndApply, type FrontmatterOpOptions } from './frontmatterOps' import { findByNotePath, notePathFilename, notePathsMatch } from '../utils/notePathIdentity' import type { VaultOption } from '../components/status-bar/types' +import { canonicalFrontmatterKey } from '../utils/systemMetadata' +import { useActionHistory, type ActionHistoryController } from './useActionHistory' export interface NoteActionsConfig { addEntry: (entry: VaultEntry) => void @@ -269,18 +271,101 @@ async function updateFrontmatterAndMaybeRename({ path, runFrontmatterOp, value, -}: UpdateFrontmatterAndMaybeRenameParams): Promise { - if (!activePathGuardAllowsMutation(path, deps.activeTabPathRef, options)) return +}: UpdateFrontmatterAndMaybeRenameParams): Promise { + if (!activePathGuardAllowsMutation(path, deps.activeTabPathRef, options)) return false const canFlush = await flushBeforeNoteMutation(path, config.flushBeforeNoteMutation) - if (!canFlush) return - if (!activePathGuardAllowsMutation(path, deps.activeTabPathRef, options)) return + if (!canFlush) return false + if (!activePathGuardAllowsMutation(path, deps.activeTabPathRef, options)) return false config.onInternalVaultWrite?.(path) const newContent = await runFrontmatterOp('update', path, key, value, options) - if (!applyFrontmatterCallbacks({ config, path, newContent })) return + if (!applyFrontmatterCallbacks({ config, path, newContent })) return false await maybeRenameAfterFrontmatterUpdate({ path, key, value, deps }) await notifyFrontmatterPersisted(config, key) + return true +} + +interface FrontmatterSnapshot { + exists: boolean + value?: FrontmatterValue +} + +const ABSENT_FRONTMATTER: FrontmatterSnapshot = { exists: false } + +function presentFrontmatter(value: FrontmatterValue | undefined): FrontmatterSnapshot { + return value === undefined ? ABSENT_FRONTMATTER : { exists: true, value } +} + +function propertyFrontmatterValue(value: VaultPropertyValue | undefined): FrontmatterValue | undefined { + if (value === undefined) return undefined + if (Array.isArray(value)) return value.map(String) + return value +} + +function presentStringList(values: readonly string[]): FrontmatterSnapshot { + return values.length > 0 ? { exists: true, value: [...values] } : ABSENT_FRONTMATTER +} + +function presentNullable(value: string | number | boolean | null | undefined): FrontmatterSnapshot { + return value === null || value === undefined ? ABSENT_FRONTMATTER : { exists: true, value } +} + +function presentNonEmptyString(value: string): FrontmatterSnapshot { + return value ? { exists: true, value } : ABSENT_FRONTMATTER +} + +function presentBooleanFlag(value: boolean): FrontmatterSnapshot { + return value ? { exists: true, value: true } : ABSENT_FRONTMATTER +} + +type FrontmatterSnapshotReader = (entry: VaultEntry) => FrontmatterSnapshot + +const ENTRY_FRONTMATTER_SNAPSHOT_READERS = new Map([ + ['title', (entry) => presentNonEmptyString(entry.title)], + ['type', (entry) => presentNullable(entry.isA)], + ['is_a', (entry) => presentNullable(entry.isA)], + ['status', (entry) => presentNullable(entry.status)], + ['color', (entry) => presentNullable(entry.color)], + ['_icon', (entry) => presentNullable(entry.icon)], + ['_sidebar_label', (entry) => presentNullable(entry.sidebarLabel)], + ['aliases', (entry) => presentStringList(entry.aliases)], + ['belongs_to', (entry) => presentStringList(entry.belongsTo)], + ['related_to', (entry) => presentStringList(entry.relatedTo)], + ['_archived', (entry) => presentBooleanFlag(entry.archived)], + ['_order', (entry) => presentNullable(entry.order)], + ['template', (entry) => presentNullable(entry.template)], + ['_sort', (entry) => presentNullable(entry.sort)], + ['view', (entry) => presentNullable(entry.view)], + ['_width', (entry) => presentNullable(entry.noteWidth)], + ['visible', (entry) => entry.visible === false ? { exists: true, value: false } : ABSENT_FRONTMATTER], + ['_organized', (entry) => presentBooleanFlag(entry.organized)], + ['_favorite', (entry) => presentBooleanFlag(entry.favorite)], + ['_favorite_index', (entry) => presentNullable(entry.favoriteIndex)], + ['_list_properties_display', (entry) => presentStringList(entry.listPropertiesDisplay)], +]) + +function frontmatterSnapshotFromProperties( + properties: VaultEntry['properties'], + canonicalKey: string, +): FrontmatterSnapshot { + const propertyKey = Object.keys(properties).find((candidate) => canonicalFrontmatterKey(candidate) === canonicalKey) + return presentFrontmatter(propertyFrontmatterValue(propertyKey ? properties[propertyKey] : undefined)) +} + +function frontmatterSnapshotFromEntry(entry: VaultEntry, key: string): FrontmatterSnapshot { + const canonicalKey = canonicalFrontmatterKey(key) + const readSnapshot = ENTRY_FRONTMATTER_SNAPSHOT_READERS.get(canonicalKey) + return readSnapshot ? readSnapshot(entry) : frontmatterSnapshotFromProperties(entry.properties, canonicalKey) +} + +function frontmatterSnapshotForMutation(entries: readonly VaultEntry[], path: string, key: string): FrontmatterSnapshot { + const entry = findByNotePath(entries, path) + return entry ? frontmatterSnapshotFromEntry(entry, key) : ABSENT_FRONTMATTER +} + +function shouldRecordFrontmatterHistory(actionHistory: ActionHistoryController, options?: FrontmatterOpOptions): boolean { + return !options?.silent && !actionHistory.isReplaying() } function buildTabManagementOptions( @@ -375,6 +460,7 @@ function useFrontmatterActionHandlers({ setToastMessage, updateTabContent, runFrontmatterOp, + actionHistory, }: { config: NoteActionsConfig onPathRenamed?: (oldPath: string, newPath: string) => void @@ -386,7 +472,72 @@ function useFrontmatterActionHandlers({ setToastMessage: (msg: string | null) => void updateTabContent: (path: string, newContent: string) => void runFrontmatterOp: RunFrontmatterOp + actionHistory: ActionHistoryController }) { + const applySnapshot = useCallback(async ( + path: string, + key: string, + snapshot: FrontmatterSnapshot, + options?: FrontmatterOpOptions, + ) => { + const currentPath = resolvePath(path) + if (snapshot.exists) { + await updateFrontmatterAndMaybeRename({ + config, + deps: { + vaultPath: config.vaultPath, + tabsRef: renameTabsRef, + reloadVault: config.reloadVault, + replaceEntry: config.replaceEntry, + onPathRenamed, + setTabs, + activeTabPathRef, + handleSwitchTab, + setToastMessage, + updateTabContent, + onInternalVaultWrite: config.onInternalVaultWrite, + }, + path: currentPath, + key, + value: snapshot.value ?? null, + options: { ...options, silent: true }, + runFrontmatterOp, + }) + return + } + + config.onInternalVaultWrite?.(currentPath) + const newContent = await runFrontmatterOp('delete', currentPath, key, undefined, { ...options, silent: true }) + if (!applyFrontmatterCallbacks({ config, path: currentPath, newContent })) return + await notifyFrontmatterPersisted(config, key) + }, [ + activeTabPathRef, + config, + handleSwitchTab, + onPathRenamed, + renameTabsRef, + resolvePath, + runFrontmatterOp, + setTabs, + setToastMessage, + updateTabContent, + ]) + + const recordFrontmatterHistory = useCallback(( + path: string, + key: string, + before: FrontmatterSnapshot, + after: FrontmatterSnapshot, + label: string, + options?: FrontmatterOpOptions, + ) => { + actionHistory.record({ + label, + undo: () => applySnapshot(path, key, before, options), + redo: () => applySnapshot(path, key, after, options), + }) + }, [actionHistory, applySnapshot]) + const handleUpdateFrontmatter = useCallback(async ( path: string, key: string, @@ -394,7 +545,11 @@ function useFrontmatterActionHandlers({ options?: FrontmatterOpOptions, ) => { const currentPath = resolvePath(path) - await updateFrontmatterAndMaybeRename({ + const shouldRecordHistory = shouldRecordFrontmatterHistory(actionHistory, options) + const before = shouldRecordHistory + ? frontmatterSnapshotForMutation(config.entries, currentPath, key) + : ABSENT_FRONTMATTER + const updated = await updateFrontmatterAndMaybeRename({ config, deps: { vaultPath: config.vaultPath, @@ -415,10 +570,17 @@ function useFrontmatterActionHandlers({ options, runFrontmatterOp, }) - }, [activeTabPathRef, config, handleSwitchTab, onPathRenamed, renameTabsRef, resolvePath, runFrontmatterOp, setTabs, setToastMessage, updateTabContent]) + if (updated && shouldRecordHistory) { + recordFrontmatterHistory(currentPath, key, before, { exists: true, value }, `Update ${key}`, options) + } + }, [actionHistory, activeTabPathRef, config, handleSwitchTab, onPathRenamed, recordFrontmatterHistory, renameTabsRef, resolvePath, runFrontmatterOp, setTabs, setToastMessage, updateTabContent]) const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => { const currentPath = resolvePath(path) + const shouldRecordHistory = shouldRecordFrontmatterHistory(actionHistory, options) + const before = shouldRecordHistory + ? frontmatterSnapshotForMutation(config.entries, currentPath, key) + : ABSENT_FRONTMATTER if (!activePathGuardAllowsMutation(currentPath, activeTabPathRef, options)) return const canFlush = await flushBeforeNoteMutation(currentPath, config.flushBeforeNoteMutation) if (!canFlush) return @@ -428,10 +590,17 @@ function useFrontmatterActionHandlers({ const newContent = await runFrontmatterOp('delete', currentPath, key, undefined, options) if (!applyFrontmatterCallbacks({ config, path: currentPath, newContent })) return await notifyFrontmatterPersisted(config, key) - }, [activeTabPathRef, config, resolvePath, runFrontmatterOp]) + if (shouldRecordHistory) { + recordFrontmatterHistory(currentPath, key, before, ABSENT_FRONTMATTER, `Delete ${key}`, options) + } + }, [actionHistory, activeTabPathRef, config, recordFrontmatterHistory, resolvePath, runFrontmatterOp]) const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => { const currentPath = resolvePath(path) + const shouldRecordHistory = shouldRecordFrontmatterHistory(actionHistory, options) + const before = shouldRecordHistory + ? frontmatterSnapshotForMutation(config.entries, currentPath, key) + : ABSENT_FRONTMATTER if (!activePathGuardAllowsMutation(currentPath, activeTabPathRef, options)) return const canFlush = await flushBeforeNoteMutation(currentPath, config.flushBeforeNoteMutation) if (!canFlush) return @@ -441,7 +610,10 @@ function useFrontmatterActionHandlers({ const newContent = await runFrontmatterOp('update', currentPath, key, value, options) if (!applyFrontmatterCallbacks({ config, path: currentPath, newContent })) return await notifyFrontmatterPersisted(config, key) - }, [activeTabPathRef, config, resolvePath, runFrontmatterOp]) + if (shouldRecordHistory) { + recordFrontmatterHistory(currentPath, key, before, { exists: true, value }, `Update ${key}`, options) + } + }, [actionHistory, activeTabPathRef, config, recordFrontmatterHistory, resolvePath, runFrontmatterOp]) return { handleUpdateFrontmatter, @@ -506,8 +678,51 @@ function useRenamedNotePathResolver(onPathRenamed?: (oldPath: string, newPath: s return { handlePathRenamed, resolveActionPath } } +interface NoteActionsResultParts { + actionHistory: ActionHistoryController + creation: ReturnType + frontmatterActions: ReturnType + handleNavigateWikilink: (target: string) => void + rename: ReturnType + tabMgmt: ReturnType +} + +function buildNoteActionsResult({ + actionHistory, + creation, + frontmatterActions, + handleNavigateWikilink, + rename, + tabMgmt, +}: NoteActionsResultParts) { + return { + ...tabMgmt, + handleNavigateWikilink, + handleCreateNote: creation.handleCreateNote, + handleCreateNoteImmediate: creation.handleCreateNoteImmediate, + handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship, + handleCreateType: creation.handleCreateType, + createTypeEntrySilent: creation.createTypeEntrySilent, + handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter, + handleDeleteProperty: frontmatterActions.handleDeleteProperty, + handleAddProperty: frontmatterActions.handleAddProperty, + handleRenameNote: rename.handleRenameNote, + handleRenameFilename: rename.handleRenameFilename, + handleMoveNoteToFolder: rename.handleMoveNoteToFolder, + handleMoveNoteToWorkspace: rename.handleMoveNoteToWorkspace, + actionHistory, + canUndo: actionHistory.canUndo, + canRedo: actionHistory.canRedo, + undoLabel: actionHistory.undoLabel, + redoLabel: actionHistory.redoLabel, + handleUndo: actionHistory.undo, + handleRedo: actionHistory.redo, + } +} + export function useNoteActions(config: NoteActionsConfig) { const { entries, setToastMessage, updateEntry } = config + const actionHistory = useActionHistory() const { handlePathRenamed, resolveActionPath } = useRenamedNotePathResolver(config.onPathRenamed) const tabMgmt = useTabManagement(buildTabManagementOptions(config)) const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt @@ -557,22 +772,15 @@ export function useNoteActions(config: NoteActionsConfig) { setToastMessage, updateTabContent, runFrontmatterOp, + actionHistory, }) - return { - ...tabMgmt, + return buildNoteActionsResult({ + actionHistory, + creation, + frontmatterActions, handleNavigateWikilink, - handleCreateNote: creation.handleCreateNote, - handleCreateNoteImmediate: creation.handleCreateNoteImmediate, - handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship, - handleCreateType: creation.handleCreateType, - createTypeEntrySilent: creation.createTypeEntrySilent, - handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter, - handleDeleteProperty: frontmatterActions.handleDeleteProperty, - handleAddProperty: frontmatterActions.handleAddProperty, - handleRenameNote: rename.handleRenameNote, - handleRenameFilename: rename.handleRenameFilename, - handleMoveNoteToFolder: rename.handleMoveNoteToFolder, - handleMoveNoteToWorkspace: rename.handleMoveNoteToWorkspace, - } + rename, + tabMgmt, + }) } diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index fefd8267..6cee50b2 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -147,6 +147,7 @@ describe('useVaultLoader', () => { mockIsTauri = false backendInvokeFn.mockReset() backendInvokeFn.mockImplementation(defaultMockInvoke) + window.history.replaceState({}, '', '/') }) it('loads entries on mount', async () => { @@ -312,6 +313,40 @@ describe('useVaultLoader', () => { expect(issuedCommands).not.toContain('list_vault') }) + it('uses cached initial vault entries in Tauri note-window mode', async () => { + await enableTauriMode() + window.history.replaceState( + {}, + '', + '/?window=note&path=%2Fvault%2Fnote%2Fhello.md&vault=%2Fvault&title=Hello', + ) + backendInvokeFn.mockImplementation(((cmd: string) => { + if (cmd === 'list_vault') { + return Promise.resolve([ + { ...mockEntries[0], path: '/vault/note/cached.md', filename: 'cached.md', title: 'Cached' }, + ]) + } + if (cmd === 'reload_vault') { + return Promise.resolve([ + { ...mockEntries[0], path: '/vault/note/fresh.md', filename: 'fresh.md', title: 'Fresh' }, + ]) + } + if (cmd === 'get_modified_files') return Promise.resolve([]) + if (cmd === 'list_vault_folders') return Promise.resolve([]) + if (cmd === 'list_views') return Promise.resolve([]) + return Promise.resolve(null) + }) as typeof defaultMockInvoke) + + const { result } = renderHook(() => useVaultLoader('/vault')) + + await waitFor(() => { + expect(result.current.entries.map((entry) => entry.title)).toEqual(['Cached']) + }) + const issuedCommands = backendInvokeFn.mock.calls.map(([command]) => command) + expect(issuedCommands).toContain('list_vault') + expect(issuedCommands).not.toContain('reload_vault') + }) + it('freshly reloads the active mounted workspace on startup in Tauri mode', async () => { await enableTauriMode() const brian = { label: 'Brian', path: '/brian', alias: 'brian', available: true, mounted: true } diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index c3b06f67..f0857380 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -22,6 +22,7 @@ import { tauriCall, } from './vaultLoaderCommands' import { normalizeVaultEntry } from '../utils/vaultMetadataNormalization' +import { isNoteWindow } from '../utils/windowMode' import { useUnavailableVaultState } from './useUnavailableVaultState' import { resetVaultState } from './vaultStateReset' import { @@ -40,6 +41,7 @@ interface InitialVaultLoadStateOptions { folderVaults?: VaultOption[] handleVaultAvailable: (path: string) => void path: string + forceReload: boolean handleVaultUnavailable: (path: string) => void isCurrentVaultPath: (path: string) => boolean setEntries: Dispatch> @@ -87,7 +89,7 @@ async function loadInitialVaultChromeState(options: InitialVaultChromeOptions): async function loadInitialVaultEntriesState(options: Pick< InitialVaultLoadStateOptions, - 'defaultWorkspacePath' | 'handleVaultAvailable' | 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'path' | 'setEntries' | 'vaults' + 'defaultWorkspacePath' | 'forceReload' | 'handleVaultAvailable' | 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'path' | 'setEntries' | 'vaults' >): Promise { const { handleVaultAvailable, handleVaultUnavailable, isCurrentVaultPath, path, setEntries } = options @@ -96,7 +98,7 @@ async function loadInitialVaultEntriesState(options: Pick< vaultPath: path, vaults: initialVaultsForPath(path, options.vaults), defaultWorkspacePath: options.defaultWorkspacePath, - forceReload: true, + forceReload: options.forceReload, }) if (isCurrentVaultPath(path)) { handleVaultAvailable(path) @@ -278,6 +280,7 @@ export function resolveNoteStatus({ interface InitialVaultLoadOptions { defaultWorkspacePath?: string | null folderVaults?: VaultOption[] + forceReload: boolean handleVaultAvailable: (path: string) => void handleVaultUnavailable: (path: string) => void isWorkspacePathLoaded: (path: string) => boolean @@ -297,6 +300,7 @@ interface InitialVaultLoadOptions { interface InitialVaultLoadSnapshot { defaultWorkspacePath?: string | null + forceReload: boolean vaults?: VaultOption[] } @@ -305,12 +309,16 @@ interface InitialVaultLoadEffectOptions extends Omit void } -function useInitialVaultLoadSnapshot(vaults?: VaultOption[], defaultWorkspacePath?: string | null) { - const loadOptionsRef = useRef({ vaults, defaultWorkspacePath }) +function useInitialVaultLoadSnapshot( + vaults: VaultOption[] | undefined, + defaultWorkspacePath: string | null | undefined, + forceReload: boolean, +) { + const loadOptionsRef = useRef({ vaults, defaultWorkspacePath, forceReload }) useEffect(() => { - loadOptionsRef.current = { vaults, defaultWorkspacePath } - }, [defaultWorkspacePath, vaults]) + loadOptionsRef.current = { vaults, defaultWorkspacePath, forceReload } + }, [defaultWorkspacePath, forceReload, vaults]) return loadOptionsRef } @@ -374,6 +382,7 @@ function startFreshInitialVaultLoad( vaults: loadOptions.vaults, folderVaults: options.folderVaults, defaultWorkspacePath: loadOptions.defaultWorkspacePath, + forceReload: loadOptions.forceReload, setEntries: options.setEntries, setFolders: options.setFolders, setIsLoading: options.setIsLoading, @@ -400,8 +409,9 @@ function useInitialVaultLoad(options: InitialVaultLoadOptions) { vaults, defaultWorkspacePath, folderVaults, + forceReload, } = options - const loadOptionsRef = useInitialVaultLoadSnapshot(vaults, defaultWorkspacePath) + const loadOptionsRef = useInitialVaultLoadSnapshot(vaults, defaultWorkspacePath, forceReload) const loadOptionsKey = useMemo( () => workspacePathSetKey(uniqueWorkspacePathsFromVaults(vaultPath, vaults)), [vaultPath, vaults], @@ -417,7 +427,7 @@ function useInitialVaultLoad(options: InitialVaultLoadOptions) { clearNewPaths: tracker.clear, clearUnsaved: unsaved.clearAll, setEntries, setFolders, setIsLoading, setModifiedFiles, setModifiedFilesError, setViews, - vaultPath, vaults: loadOptions.vaults, folderVaults, + vaultPath, vaults: loadOptions.vaults, folderVaults, forceReload, } const reuseLoadedWorkspaceEntries = shouldReuseLoadedWorkspaceEntries(path, loadOptions, isWorkspacePathLoaded) const preserveWorkspaceEntries = reuseLoadedWorkspaceEntries || shouldPreserveWorkspaceEntries(loadOptions) @@ -446,6 +456,7 @@ function useInitialVaultLoad(options: InitialVaultLoadOptions) { setEntries, setFolders, setIsLoading, setModifiedFiles, setModifiedFilesError, setViews, loadOptionsRef, loadOptionsKey, + forceReload, folderVaults ]) } @@ -784,6 +795,63 @@ function useVaultUnavailable(vaultPath: string, state: ReturnType void + state: ReturnType + unavailableVault: ReturnType + vaultPath: string + vaultReloads: ReturnType + vaults?: VaultOption[] +} + +function useVaultLoaderStartup(options: VaultLoaderStartupOptions) { + const { + defaultWorkspacePath, + folderVaults, + forceReload, + setInitialFolders, + state, + unavailableVault, + vaultPath, + vaultReloads, + vaults, + } = options + + const isWorkspacePathLoaded = useWorkspaceEntrySync({ + defaultWorkspacePath, + entries: state.entries, + isCurrentVaultPath: state.isCurrentVaultPath, + isLoading: state.isLoading, + setEntries: state.setEntries, + vaultPath, + vaults, + }) + + useInitialVaultLoad({ + handleVaultAvailable: unavailableVault.markVaultAvailable, + handleVaultUnavailable: unavailableVault.markVaultUnavailable, + vaultPath, + vaults, + forceReload, + folderVaults, + defaultWorkspacePath, + isWorkspacePathLoaded, + tracker: state.tracker, + unsaved: state.unsaved, + isCurrentVaultPath: state.isCurrentVaultPath, + resetReloading: vaultReloads.resetReloading, + setEntries: state.setEntries, + setFolders: setInitialFolders, + setIsLoading: state.setIsLoading, + setModifiedFiles: state.modified.setModifiedFiles, + setModifiedFilesError: state.modified.setModifiedFilesError, + setViews: state.setViews, + }) +} + function useInitialFolderSetter(folderVaults: VaultOption[] | undefined, setFolders: (folders: FolderNode[]) => void) { const folderVaultsRef = useRef(folderVaults) @@ -1151,6 +1219,7 @@ function useVaultLoaderResult({ } export function useVaultLoader(vaultPath: string, vaults?: VaultOption[], defaultWorkspacePath?: string | null, folderVaults?: VaultOption[]) { + const forceInitialReload = useMemo(() => !isNoteWindow(), []) const state = useVaultState(vaultPath) const setInitialFolders = useInitialFolderSetter(folderVaults, state.setFolders) const entryMutations = useEntryMutations(state.setEntries, state.tracker.trackNew) @@ -1173,34 +1242,16 @@ export function useVaultLoader(vaultPath: string, vaults?: VaultOption[], defaul useGitignoredVisibilityReloads(vaultReloads) useVaultChromeReloadEffect(vaultPath, vaultReloads.reloadFolders, vaultReloads.reloadViews) - const isWorkspacePathLoaded = useWorkspaceEntrySync({ + useVaultLoaderStartup({ defaultWorkspacePath, - entries: state.entries, - isCurrentVaultPath: state.isCurrentVaultPath, - isLoading: state.isLoading, - setEntries: state.setEntries, - vaultPath, - vaults, - }) - - useInitialVaultLoad({ - handleVaultAvailable: unavailableVault.markVaultAvailable, - handleVaultUnavailable: unavailableVault.markVaultUnavailable, - vaultPath, - vaults, folderVaults, - defaultWorkspacePath, - isWorkspacePathLoaded, - tracker: state.tracker, - unsaved: state.unsaved, - isCurrentVaultPath: state.isCurrentVaultPath, - resetReloading: vaultReloads.resetReloading, - setEntries: state.setEntries, - setFolders: setInitialFolders, - setIsLoading: state.setIsLoading, - setModifiedFiles: state.modified.setModifiedFiles, - setModifiedFilesError: state.modified.setModifiedFilesError, - setViews: state.setViews, + forceReload: forceInitialReload, + setInitialFolders, + state, + unavailableVault, + vaultPath, + vaultReloads, + vaults, }) return useVaultLoaderResult({ entryMutations, gitLoaders, state, unavailableVault, vaultReloads }) diff --git a/src/hooks/vaultLoaderCommands.ts b/src/hooks/vaultLoaderCommands.ts index 6587a779..ad1bddc9 100644 --- a/src/hooks/vaultLoaderCommands.ts +++ b/src/hooks/vaultLoaderCommands.ts @@ -15,6 +15,10 @@ interface VaultPathOptions { vaultPath: string } +interface VaultEntriesOptions extends VaultPathOptions { + forceReload?: boolean +} + interface MountedVaultEntriesOptions extends VaultPathOptions { defaultWorkspacePath?: string | null forceReload?: boolean @@ -158,8 +162,8 @@ function attachViewRootPath( })) } -function loadVaultEntries({ vaultPath }: VaultPathOptions): Promise { - const command = isTauri() ? 'reload_vault' : 'list_vault' +function loadVaultEntries({ vaultPath, forceReload = true }: VaultEntriesOptions): Promise { + const command = forceReload && isTauri() ? 'reload_vault' : 'list_vault' return loadVaultEntriesWithCommand({ vaultPath, command }) } @@ -230,7 +234,7 @@ export async function loadVaultData({ vaultPath, vaults, defaultWorkspacePath, f if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing') const entries = vaults?.length ? await loadMountedVaultEntries({ vaultPath, vaults, defaultWorkspacePath, forceReload }) - : await loadVaultEntries({ vaultPath }) + : await loadVaultEntries({ vaultPath, forceReload }) console.log(`Vault scan complete: ${entries.length} entries found`) return { entries } } diff --git a/src/lib/locales/be-BY.json b/src/lib/locales/be-BY.json index dd8f955c..ed13f791 100644 --- a/src/lib/locales/be-BY.json +++ b/src/lib/locales/be-BY.json @@ -38,6 +38,10 @@ "command.note.newType": "Новы тып", "command.note.newTypedNote": "Стварыць {type}", "command.note.saveNote": "Захаваць нататку", + "command.note.undo": "Адрабіць", + "command.note.undoAction": "Адмяніць {action}", + "command.note.redo": "Повторити", + "command.note.redoAction": "Паўтарыць {action}", "command.note.pastePlainText": "Уставіць як просты тэкст", "command.note.findInNote": "Знайсці ў нататцы", "command.note.replaceInNote": "Замяніць у нататцы", diff --git a/src/lib/locales/be-Latn.json b/src/lib/locales/be-Latn.json index bbd83e42..5e26294c 100644 --- a/src/lib/locales/be-Latn.json +++ b/src/lib/locales/be-Latn.json @@ -38,6 +38,10 @@ "command.note.newType": "Novy typ", "command.note.newTypedNote": "Stvaryć {type}", "command.note.saveNote": "Zachavać natatku", + "command.note.undo": "Adkacić", + "command.note.undoAction": "Adkacić {action}", + "command.note.redo": "Paŭtaryć", + "command.note.redoAction": "Paŭtaryć {action}", "command.note.pastePlainText": "Ustavić jak prosty tekst", "command.note.findInNote": "Znajsci ŭ natatcy", "command.note.replaceInNote": "Zamianić u natatcy", diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index 44688df8..7db819b0 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -38,6 +38,10 @@ "command.note.newType": "Neuer Typ", "command.note.newTypedNote": "Neuer {type}", "command.note.saveNote": "Notiz speichern", + "command.note.undo": "Rückgängig machen", + "command.note.undoAction": "{action} rückgängig machen", + "command.note.redo": "Wiederholen", + "command.note.redoAction": "{action} wiederholen", "command.note.pastePlainText": "Ohne Formatierung einfügen", "command.note.findInNote": "In Notiz suchen", "command.note.replaceInNote": "In Notiz ersetzen", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index b1533d20..99c8bc9e 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -38,6 +38,10 @@ "command.note.newType": "New Type", "command.note.newTypedNote": "New {type}", "command.note.saveNote": "Save Note", + "command.note.undo": "Undo", + "command.note.undoAction": "Undo {action}", + "command.note.redo": "Redo", + "command.note.redoAction": "Redo {action}", "command.note.pastePlainText": "Paste without formatting", "command.note.findInNote": "Find in Note", "command.note.replaceInNote": "Replace in Note", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index 0f4756eb..ae9b61cc 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -38,6 +38,10 @@ "command.note.newType": "Nuevo tipo", "command.note.newTypedNote": "Nuevo {type}", "command.note.saveNote": "Guardar nota", + "command.note.undo": "Deshacer", + "command.note.undoAction": "Deshacer {action}", + "command.note.redo": "Rehacer", + "command.note.redoAction": "Rehacer {action}", "command.note.pastePlainText": "Pegar sin formato", "command.note.findInNote": "Buscar en la nota", "command.note.replaceInNote": "Reemplazar en la nota", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index 5944ce3c..499f58c0 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -38,6 +38,10 @@ "command.note.newType": "Nuevo tipo", "command.note.newTypedNote": "Nuevo {type}", "command.note.saveNote": "Guardar nota", + "command.note.undo": "Deshacer", + "command.note.undoAction": "Deshacer {action}", + "command.note.redo": "Rehacer", + "command.note.redoAction": "Rehacer {action}", "command.note.pastePlainText": "Pegar sin formato", "command.note.findInNote": "Buscar en la nota", "command.note.replaceInNote": "Reemplazar en la nota", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index bcdb5266..1bb1480c 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -38,6 +38,10 @@ "command.note.newType": "Nouveau type", "command.note.newTypedNote": "Nouveau {type}", "command.note.saveNote": "Enregistrer la note", + "command.note.undo": "Annuler", + "command.note.undoAction": "Annuler {action}", + "command.note.redo": "Rétablir", + "command.note.redoAction": "Rétablir {action}", "command.note.pastePlainText": "Coller sans mise en forme", "command.note.findInNote": "Rechercher dans la note", "command.note.replaceInNote": "Remplacer dans la note", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index f75bd69f..9c87dfe0 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -38,6 +38,10 @@ "command.note.newType": "Nuovo tipo", "command.note.newTypedNote": "Nuovo {type}", "command.note.saveNote": "Salva nota", + "command.note.undo": "Annulla", + "command.note.undoAction": "Annulla {action}", + "command.note.redo": "Ripeti", + "command.note.redoAction": "Ripeti {action}", "command.note.pastePlainText": "Incolla senza formattazione", "command.note.findInNote": "Trova nella nota", "command.note.replaceInNote": "Sostituisci nella nota", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index dae942b7..0ec39a3f 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -38,6 +38,10 @@ "command.note.newType": "新しいタイプ", "command.note.newTypedNote": "新規{type}", "command.note.saveNote": "メモを保存", + "command.note.undo": "元に戻す", + "command.note.undoAction": "{action}を元に戻す", + "command.note.redo": "やり直し", + "command.note.redoAction": "{action}をやり直す", "command.note.pastePlainText": "書式設定せずに貼り付け", "command.note.findInNote": "ノート内で検索", "command.note.replaceInNote": "ノート内で置き換える", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index 3f902272..c865de94 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -38,6 +38,10 @@ "command.note.newType": "새 유형", "command.note.newTypedNote": "새 {type}", "command.note.saveNote": "노트 저장", + "command.note.undo": "실행 취소", + "command.note.undoAction": "{action} 실행 취소", + "command.note.redo": "다시 실행", + "command.note.redoAction": "{action} 다시 실행", "command.note.pastePlainText": "서식 없이 붙여넣기", "command.note.findInNote": "노트에서 찾기", "command.note.replaceInNote": "노트에서 바꾸기", diff --git a/src/lib/locales/pl-PL.json b/src/lib/locales/pl-PL.json index 8e1d59fa..5a947aed 100644 --- a/src/lib/locales/pl-PL.json +++ b/src/lib/locales/pl-PL.json @@ -38,6 +38,10 @@ "command.note.newType": "Nowy typ", "command.note.newTypedNote": "Nowy {type}", "command.note.saveNote": "Zapisz notatkę", + "command.note.undo": "Cofnij", + "command.note.undoAction": "Cofnij {action}", + "command.note.redo": "Ponów", + "command.note.redoAction": "Ponów {action}", "command.note.pastePlainText": "Wklej bez formatowania", "command.note.findInNote": "Znajdź w notatce", "command.note.replaceInNote": "Zamień w notatce", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index 959cb2e7..2782eb72 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -38,6 +38,10 @@ "command.note.newType": "Novo tipo", "command.note.newTypedNote": "Novo {type}", "command.note.saveNote": "Salvar nota", + "command.note.undo": "Desfazer", + "command.note.undoAction": "Desfazer {action}", + "command.note.redo": "Refazer", + "command.note.redoAction": "Refazer {action}", "command.note.pastePlainText": "Colar sem formatação", "command.note.findInNote": "Localizar na nota", "command.note.replaceInNote": "Substituir na nota", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index f41b81ef..d3b48793 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -38,6 +38,10 @@ "command.note.newType": "Novo tipo", "command.note.newTypedNote": "Novo {type}", "command.note.saveNote": "Guardar nota", + "command.note.undo": "Anular", + "command.note.undoAction": "Anular {action}", + "command.note.redo": "Refazer", + "command.note.redoAction": "Refazer {action}", "command.note.pastePlainText": "Colar sem formatação", "command.note.findInNote": "Procurar na nota", "command.note.replaceInNote": "Substituir na nota", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index 4f3275b4..b48e7b98 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -38,6 +38,10 @@ "command.note.newType": "Новый тип", "command.note.newTypedNote": "Новый {type}", "command.note.saveNote": "Сохранить заметку", + "command.note.undo": "Отменить", + "command.note.undoAction": "Отменить {action}", + "command.note.redo": "Повторить", + "command.note.redoAction": "Повторить {action}", "command.note.pastePlainText": "Вставить без форматирования", "command.note.findInNote": "Найти в заметке", "command.note.replaceInNote": "Заменить в заметке", diff --git a/src/lib/locales/vi.json b/src/lib/locales/vi.json index 8b34c405..7754e49e 100644 --- a/src/lib/locales/vi.json +++ b/src/lib/locales/vi.json @@ -38,6 +38,10 @@ "command.note.newType": "Loại mới", "command.note.newTypedNote": "{type} mới", "command.note.saveNote": "Lưu ghi chú", + "command.note.undo": "Hoàn tác", + "command.note.undoAction": "Hoàn tác {action}", + "command.note.redo": "Làm lại", + "command.note.redoAction": "Làm lại {action}", "command.note.pastePlainText": "Paste without formatting", "command.note.findInNote": "Tìm trong ghi chú", "command.note.replaceInNote": "Thay thế trong ghi chú", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index 3914994b..4fd3468f 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -38,6 +38,10 @@ "command.note.newType": "新建类型", "command.note.newTypedNote": "新建{type}", "command.note.saveNote": "保存笔记", + "command.note.undo": "撤消", + "command.note.undoAction": "撤消{action}", + "command.note.redo": "重做", + "command.note.redoAction": "重做 {action}", "command.note.pastePlainText": "粘贴时不保留格式", "command.note.findInNote": "在笔记中查找", "command.note.replaceInNote": "在笔记中替换", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index a180b1fa..ec8a3e8f 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -38,6 +38,10 @@ "command.note.newType": "新建型別", "command.note.newTypedNote": "新建{type}", "command.note.saveNote": "儲存筆記", + "command.note.undo": "復原", + "command.note.undoAction": "復原 {action}", + "command.note.redo": "重做", + "command.note.redoAction": "重做 {action}", "command.note.pastePlainText": "貼上內容(不保留格式)", "command.note.findInNote": "在筆記中查詢", "command.note.replaceInNote": "在筆記中替換", diff --git a/src/shared/appCommandManifest.json b/src/shared/appCommandManifest.json index 4e8f93b4..ecbaf683 100644 --- a/src/shared/appCommandManifest.json +++ b/src/shared/appCommandManifest.json @@ -62,6 +62,34 @@ "requiresManualNativeAcceleratorQa": true } }, + "editUndo": { + "id": "edit-undo", + "route": { "kind": "handler", "handler": "onUndo" }, + "menuOwned": true, + "preferredShortcutQaMode": "renderer-shortcut-event", + "shortcut": { + "combo": "command-or-ctrl", + "key": "z", + "code": "KeyZ", + "display": "⌘Z", + "accelerator": "CmdOrCtrl+Z", + "requiresManualNativeAcceleratorQa": true + } + }, + "editRedo": { + "id": "edit-redo", + "route": { "kind": "handler", "handler": "onRedo" }, + "menuOwned": true, + "preferredShortcutQaMode": "renderer-shortcut-event", + "shortcut": { + "combo": "command-or-ctrl-shift", + "key": "z", + "code": "KeyZ", + "display": "⌘⇧Z", + "accelerator": "CmdOrCtrl+Shift+Z", + "requiresManualNativeAcceleratorQa": true + } + }, "editFindInNote": { "id": "edit-find-in-note", "route": { "kind": "handler", "handler": "onFindInNote" }, @@ -428,6 +456,9 @@ { "label": "Edit", "items": [ + { "kind": "command", "command": "editUndo", "label": "Undo" }, + { "kind": "command", "command": "editRedo", "label": "Redo" }, + { "kind": "separator" }, { "kind": "command", "command": "editPastePlainText", "label": "Paste without Formatting" }, { "kind": "separator" }, { "kind": "command", "command": "editFindInNote", "label": "Find in Note", "enabled": false }, diff --git a/src/utils/notePathIdentity.ts b/src/utils/notePathIdentity.ts index 318c4f11..a3412794 100644 --- a/src/utils/notePathIdentity.ts +++ b/src/utils/notePathIdentity.ts @@ -35,12 +35,12 @@ export function notePathsCollide(leftPath: PathLike, rightPath: PathLike): boole return normalizeNotePathForCollision(leftPath) === normalizeNotePathForCollision(rightPath) } -export function findByNotePath(items: T[], path: PathLike): T | undefined { +export function findByNotePath(items: readonly T[], path: PathLike): T | undefined { if (!path) return undefined return items.find((item) => notePathsMatch(item.path, path)) } -export function findByCollidingNotePath(items: T[], path: PathLike): T | undefined { +export function findByCollidingNotePath(items: readonly T[], path: PathLike): T | undefined { if (!path) return undefined return items.find((item) => notePathsCollide(item.path, path)) }