Merge branch 'main' into pr-726
This commit is contained in:
@@ -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:
|
||||
|
||||
27
docs/adr/0124-cached-secondary-note-window-startup.md
Normal file
27
docs/adr/0124-cached-secondary-note-window-startup.md
Normal file
@@ -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.
|
||||
29
docs/adr/0126-renderer-action-history.md
Normal file
29
docs/adr/0126-renderer-action-history.md
Normal file
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
30
src/App.tsx
30
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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<AppCommandId>([
|
||||
APP_COMMAND_IDS.editUndo,
|
||||
APP_COMMAND_IDS.editRedo,
|
||||
APP_COMMAND_IDS.viewGoBack,
|
||||
APP_COMMAND_IDS.viewGoForward,
|
||||
])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
68
src/hooks/useActionHistory.test.ts
Normal file
68
src/hooks/useActionHistory.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
98
src/hooks/useActionHistory.ts
Normal file
98
src/hooks/useActionHistory.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export interface ActionHistoryEntry {
|
||||
label: string
|
||||
undo: () => void | Promise<void>
|
||||
redo: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface ActionHistoryController {
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
undoLabel: string | null
|
||||
redoLabel: string | null
|
||||
isReplaying: () => boolean
|
||||
record: (entry: ActionHistoryEntry) => void
|
||||
undo: () => Promise<boolean>
|
||||
redo: () => Promise<boolean>
|
||||
withoutRecording: <T>(run: () => T | Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
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<ActionHistoryEntry[]>([])
|
||||
const redoStackRef = useRef<ActionHistoryEntry[]>([])
|
||||
const replayDepthRef = useRef(0)
|
||||
const [state, setState] = useState<ActionHistorySnapshot>(() => snapshot([], []))
|
||||
|
||||
const publish = useCallback(() => {
|
||||
setState(snapshot(undoStackRef.current, redoStackRef.current))
|
||||
}, [])
|
||||
|
||||
const isReplaying = useCallback(() => replayDepthRef.current > 0, [])
|
||||
|
||||
const withoutRecording = useCallback(async <T,>(run: () => T | Promise<T>): Promise<T> => {
|
||||
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])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
171
src/hooks/useEntryActions.history.test.ts
Normal file
171
src/hooks/useEntryActions.history.test.ts
Normal file
@@ -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> = {}): 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<typeof useEntryActions>) => Promise<unknown>
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<void>
|
||||
actionHistory?: ActionHistoryController
|
||||
}
|
||||
|
||||
type ArchiveActionDeps = Pick<EntryActionsConfig,
|
||||
@@ -50,6 +52,113 @@ interface RenameTypeSectionArgs {
|
||||
label: string
|
||||
}
|
||||
|
||||
interface FavoriteState {
|
||||
favorite: boolean
|
||||
favoriteIndex: number | null
|
||||
}
|
||||
|
||||
interface FavoriteToggleResult {
|
||||
before: FavoriteState
|
||||
after: FavoriteState
|
||||
}
|
||||
|
||||
type FavoritePersistenceDeps = Pick<EntryStateActionDeps, 'handleUpdateFrontmatter' | 'handleDeleteProperty'>
|
||||
type SetFavoriteState = (path: string, state: FavoriteState) => Promise<boolean>
|
||||
type SetOrganizedState = (path: string, organized: boolean) => Promise<boolean>
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> => {
|
||||
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<FavoriteToggleResult | null> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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,
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
if (!activePathGuardAllowsMutation(path, deps.activeTabPathRef, options)) return
|
||||
}: UpdateFrontmatterAndMaybeRenameParams): Promise<boolean> {
|
||||
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<string, FrontmatterSnapshotReader>([
|
||||
['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<typeof useNoteCreation>
|
||||
frontmatterActions: ReturnType<typeof useFrontmatterActionHandlers>
|
||||
handleNavigateWikilink: (target: string) => void
|
||||
rename: ReturnType<typeof useNoteRename>
|
||||
tabMgmt: ReturnType<typeof useTabManagement>
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<SetStateAction<VaultEntry[]>>
|
||||
@@ -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<boolean> {
|
||||
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<InitialVaultLoadOptions, 't
|
||||
clearUnsaved: () => void
|
||||
}
|
||||
|
||||
function useInitialVaultLoadSnapshot(vaults?: VaultOption[], defaultWorkspacePath?: string | null) {
|
||||
const loadOptionsRef = useRef<InitialVaultLoadSnapshot>({ vaults, defaultWorkspacePath })
|
||||
function useInitialVaultLoadSnapshot(
|
||||
vaults: VaultOption[] | undefined,
|
||||
defaultWorkspacePath: string | null | undefined,
|
||||
forceReload: boolean,
|
||||
) {
|
||||
const loadOptionsRef = useRef<InitialVaultLoadSnapshot>({ 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<typeof useVaul
|
||||
})
|
||||
}
|
||||
|
||||
interface VaultLoaderStartupOptions {
|
||||
defaultWorkspacePath?: string | null
|
||||
folderVaults?: VaultOption[]
|
||||
forceReload: boolean
|
||||
setInitialFolders: (folders: FolderNode[]) => void
|
||||
state: ReturnType<typeof useVaultState>
|
||||
unavailableVault: ReturnType<typeof useVaultUnavailable>
|
||||
vaultPath: string
|
||||
vaultReloads: ReturnType<typeof useVaultReloads>
|
||||
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 })
|
||||
|
||||
@@ -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<VaultEntry[]> {
|
||||
const command = isTauri() ? 'reload_vault' : 'list_vault'
|
||||
function loadVaultEntries({ vaultPath, forceReload = true }: VaultEntriesOptions): Promise<VaultEntry[]> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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": "Замяніць у нататцы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ノート内で置き換える",
|
||||
|
||||
@@ -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": "노트에서 바꾸기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Заменить в заметке",
|
||||
|
||||
@@ -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ú",
|
||||
|
||||
@@ -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": "在笔记中替换",
|
||||
|
||||
@@ -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": "在筆記中替換",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -35,12 +35,12 @@ export function notePathsCollide(leftPath: PathLike, rightPath: PathLike): boole
|
||||
return normalizeNotePathForCollision(leftPath) === normalizeNotePathForCollision(rightPath)
|
||||
}
|
||||
|
||||
export function findByNotePath<T extends ItemWithNotePath>(items: T[], path: PathLike): T | undefined {
|
||||
export function findByNotePath<T extends ItemWithNotePath>(items: readonly T[], path: PathLike): T | undefined {
|
||||
if (!path) return undefined
|
||||
return items.find((item) => notePathsMatch(item.path, path))
|
||||
}
|
||||
|
||||
export function findByCollidingNotePath<T extends ItemWithNotePath>(items: T[], path: PathLike): T | undefined {
|
||||
export function findByCollidingNotePath<T extends ItemWithNotePath>(items: readonly T[], path: PathLike): T | undefined {
|
||||
if (!path) return undefined
|
||||
return items.find((item) => notePathsCollide(item.path, path))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user