diff --git a/docs/adr/0026-props-down-no-global-state.md b/docs/adr/0026-props-down-no-global-state.md index 47c8078a..99a1b6ae 100644 --- a/docs/adr/0026-props-down-no-global-state.md +++ b/docs/adr/0026-props-down-no-global-state.md @@ -2,8 +2,9 @@ type: ADR id: "0026" title: "Props-down callbacks-up (no global state management)" -status: active +status: superseded date: 2026-02-15 +superseded_by: "0115" --- ## Context diff --git a/docs/adr/0115-scoped-react-context-for-shared-ui-preferences.md b/docs/adr/0115-scoped-react-context-for-shared-ui-preferences.md new file mode 100644 index 00000000..6a41967d --- /dev/null +++ b/docs/adr/0115-scoped-react-context-for-shared-ui-preferences.md @@ -0,0 +1,28 @@ +--- +type: ADR +id: "0115" +title: "Scoped React Context for shared UI preferences" +status: active +date: 2026-05-12 +--- + +## Context + +Laputa has relied on props-down callbacks-up state flow since ADR-0026 because most renderer state is orchestrated in `App.tsx` and the component tree stays understandable. Today's `date_display_format` refactor exposed a narrow exception: the same installation-local rendering preference now needs to reach note rows, property chips and cells, inspector surfaces, table-of-contents metadata, search subtitles, and date-editing controls across multiple branches of the tree. Continuing to thread that value through intermediate components would add noisy prop plumbing to components that do not conceptually own the preference. + +## Decision + +**Use a scoped React context for shared UI preferences that are read in many renderer leaves but still sourced from `App.tsx`. `AppPreferencesProvider` publishes the current installation-local preference values, and leaf components consume them through focused hooks such as `useDateDisplayFormat`; writes still flow through the existing settings/update path rather than through context mutations.** + +## Alternatives considered +- **Scoped app-preferences context** (chosen): removes prop forwarding for cross-cutting rendering preferences while keeping the source of truth in `App.tsx` and avoiding a general-purpose global store. +- **Continue prop drilling from `App.tsx`**: preserves the old rule literally, but keeps widening component signatures and couples intermediate components to preferences they do not use. +- **Adopt a broader global state/store solution**: centralizes access, but introduces more indirection and policy surface than this renderer-only preference case needs. + +## Consequences + +Leaf components can read shared formatting preferences directly, so `date_display_format` stays consistent across note-list, inspector, search, and metadata surfaces without forwarding props through unrelated layers. + +This narrows ADR-0026's blanket "no Context for data" rule. The replacement rule is: mutable application/domain state still lives in `App.tsx` plus focused hooks, while React context is allowed only for tightly scoped, cross-cutting UI preferences whose canonical value still originates from that same top-level state. + +Future additions to `AppPreferencesProvider` should stay small, renderer-local, and read-focused. If Laputa starts moving writable domain state, async workflows, or large derived objects into context, that needs a new ADR rather than quietly expanding this pattern. diff --git a/docs/adr/README.md b/docs/adr/README.md index 447c4c15..9afa3b21 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -81,7 +81,7 @@ proposed → active → superseded | [0023](0023-repair-vault-auto-bootstrap.md) | Repair Vault auto-bootstrap pattern | active | | [0024](0024-cache-outside-vault.md) | Vault cache stored outside vault directory | active | | [0025](0025-type-field-canonical.md) | type: as canonical field (replacing Is A:) | active | -| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | active | +| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | superseded → [0115](0115-scoped-react-context-for-shared-ui-preferences.md) | | [0027](0027-dual-ai-architecture.md) | Dual AI architecture (API chat + CLI agent) | superseded | | [0028](0028-cli-agent-only-no-api-key.md) | CLI agent only — no direct Anthropic API key | active | | [0029](0029-domain-command-builder-pattern.md) | Domain command builder pattern for useCommandRegistry | active | @@ -168,6 +168,7 @@ proposed → active → superseded | [0112](0112-system-theme-mode.md) | System theme mode | active | | [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active | | [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active | +| [0115](0115-scoped-react-context-for-shared-ui-preferences.md) | Scoped React Context for shared UI preferences | active | | [0116](0116-rich-raw-transition-and-serialization-ownership.md) | Rich/raw transition and serialization ownership | active | | [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | active | | [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active | diff --git a/src/App.test.tsx b/src/App.test.tsx index 0ca430fe..18506ce2 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -408,6 +408,7 @@ vi.mock('@blocknote/react', () => ({ DeleteLinkButton: () => null, SideMenuController: () => null, SuggestionMenuController: () => null, + GridSuggestionMenuController: () => null, useComponentsContext: () => ({ LinkToolbar: { Button: ({ diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index a24691d5..48c1a0aa 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -64,16 +64,17 @@ vi.mock('@blocknote/code-block', () => ({ codeBlockOptions: {}, })) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock -const mockFilterSuggestionItems = vi.fn((...args: any[]) => args[0] ?? []) +const mockFilterSuggestionItems = vi.fn((...args: unknown[]) => args[0] ?? []) vi.mock('@blocknote/core/extensions', () => ({ filterSuggestionItems: (...args: unknown[]) => mockFilterSuggestionItems(...args), })) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock -const capturedGetItemsByTrigger: Record Promise> = {} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock -let capturedGetItems: ((query: string) => Promise) | null = null +type SuggestionControllerProps = { + triggerCharacter: string + getItems: (query: string) => Promise +} +const capturedGetItemsByTrigger: Record Promise> = {} +let capturedGetItems: ((query: string) => Promise) | null = null vi.mock('@blocknote/react', () => ({ AudioBlock: () => null, AudioToExternalHTML: () => null, @@ -114,12 +115,15 @@ vi.mock('@blocknote/react', () => ({ EditLinkButton: () => null, DeleteLinkButton: () => null, SideMenuController: () => null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock - SuggestionMenuController: (props: any) => { + SuggestionMenuController: (props: SuggestionControllerProps) => { capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems if (props.triggerCharacter === '[[') capturedGetItems = props.getItems return null }, + GridSuggestionMenuController: (props: SuggestionControllerProps) => { + capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems + return null + }, useComponentsContext: () => ({ LinkToolbar: { Button: ({ diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index 9714552f..d22203c9 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -30,6 +30,7 @@ vi.mock('@blocknote/react', () => ({ children?: ReactNode editable?: boolean className?: string + emojiPicker?: boolean formattingToolbar?: boolean linkToolbar?: boolean slashMenu?: boolean @@ -46,6 +47,7 @@ vi.mock('@blocknote/react', () => ({ children, editable, className, + emojiPicker, formattingToolbar, linkToolbar, slashMenu, @@ -53,6 +55,7 @@ vi.mock('@blocknote/react', () => ({ ...restProps } = props state.capturedBlockNoteOnChange = props.onChange ?? null + void emojiPicker void formattingToolbar void slashMenu void sideMenu @@ -83,6 +86,10 @@ vi.mock('@blocknote/react', () => ({ state.capturedSuggestionProps[String(props.triggerCharacter)] = props return
}, + GridSuggestionMenuController: (props: Record) => { + state.capturedSuggestionProps[String(props.triggerCharacter)] = props + return
+ }, useComponentsContext: () => ({ LinkToolbar: { Button: ({ @@ -693,6 +700,34 @@ describe('SingleEditorView', () => { } }) + it('inserts the selected emoji from shortcode suggestions', async () => { + const editor = createEditor() + + render( + , + ) + + const getEmojiItems = state.capturedSuggestionProps[':'].getItems as ( + query: string + ) => Promise void }>> + + const italyItems = await getEmojiItems(':it') + expect(italyItems[0]).toMatchObject({ id: '🇮🇹' }) + expect(italyItems[0].name).toMatch(/italy/i) + + const items = await getEmojiItems(':rocket') + const rocketItem = items.find(item => item.id === '🚀') + + expect(rocketItem).toMatchObject({ name: 'rocket' }) + rocketItem!.onItemClick() + + expect(editor.insertInlineContent).toHaveBeenCalledWith('🚀', { updateSelection: true }) + }) + it('guards stale click handlers stored on wikilink suggestion items', async () => { const editor = createEditor() editor.domElement = document.createElement('div') diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 7e680b33..bdfa7053 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -2,6 +2,7 @@ import { ArrowSquareOut as ExternalLink, Copy } from '@phosphor-icons/react' import { Component, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { invoke } from '@tauri-apps/api/core' import { + GridSuggestionMenuController, BlockNoteViewRaw, ComponentsContext, DeleteLinkButton, @@ -13,6 +14,7 @@ import { useComponentsContext, useCreateBlockNote, useDictionary, + type DefaultReactGridSuggestionItem, type LinkToolbarProps, } from '@blocknote/react' import { components } from '@blocknote/mantine' @@ -26,6 +28,7 @@ import { useImageLightbox } from '../hooks/useImageLightbox' import { createTranslator, type AppLocale } from '../lib/i18n' import { isTauri } from '../mock-tauri' import { buildTypeEntryMap } from '../utils/typeColors' +import { searchEmojis, type EmojiEntry } from '../utils/emoji' import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions' import { attachClickHandlers, enrichSuggestionItems, hasMultipleSuggestionWorkspaces } from '../utils/suggestionEnrichment' @@ -95,6 +98,7 @@ const TOOLBAR_MOUSE_DOWN_ALLOW_SELECTOR = [ '[contenteditable="true"]', ].join(', ') const MAX_BLOCKNOTE_RENDER_RECOVERY_RETRIES = 1 +const EMOJI_SHORTCODE_RESULT_LIMIT = 80 type TestTableBlock = { type?: string @@ -102,6 +106,10 @@ type TestTableBlock = { } type SuggestionAction = () => void type SuggestionItemWithClick = { onItemClick?: SuggestionAction } +type EmojiSuggestionItem = DefaultReactGridSuggestionItem & { + group: string + name: string +} type BlockNoteRenderRecoveryState = { error: unknown recoveryKey: number @@ -364,6 +372,16 @@ function normalizeSuggestionQuery(query: string, triggerCharacter: string): stri : query } +function emojiSuggestionRank(entry: EmojiEntry, query: string): number { + const normalizedName = entry.name.toLowerCase() + const tokens = normalizedName.split(/[^a-z0-9]+/).filter(Boolean) + if (normalizedName === query) return 0 + if (tokens.some(token => token === query)) return 1 + if (tokens.some(token => token.startsWith(query))) return 2 + if (normalizedName.startsWith(query)) return 3 + return 4 +} + const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]' const CLIPBOARD_INLINE_FORMAT_SELECTOR = 'a, b, code, em, i, s, span, strong, u' const CODE_BLOCK_COPY_RESET_MS = 1200 @@ -1132,6 +1150,30 @@ function useSuggestionMenuItems(options: { buildItems(query, '@') ?? [] ), [buildItems]) + const getEmojiItems = useCallback(async (query: string): Promise => { + const normalizedQuery = normalizeSuggestionQuery(query, ':').trim().toLowerCase() + if (!normalizedQuery) return [] + + return searchEmojis(normalizedQuery) + .sort((left, right) => { + const rankDelta = emojiSuggestionRank(left, normalizedQuery) - emojiSuggestionRank(right, normalizedQuery) + return rankDelta || left.name.localeCompare(right.name) + }) + .slice(0, EMOJI_SHORTCODE_RESULT_LIMIT) + .map((entry) => ({ + id: entry.emoji, + icon: {entry.emoji}, + name: entry.name, + group: entry.group, + onItemClick: () => { + runEditorAction(() => { + editor.insertInlineContent(entry.emoji, { updateSelection: true }) + trackEvent('emoji_shortcode_inserted', { group: entry.group }) + }) + }, + })) + }, [editor, runEditorAction]) + const getSlashMenuItems = useCallback(async (query: string) => { try { return guardSuggestionMenuItems( @@ -1148,6 +1190,7 @@ function useSuggestionMenuItems(options: { return { getWikilinkItems, + getEmojiItems, getPersonMentionItems, getSlashMenuItems, } @@ -1159,6 +1202,7 @@ type EditorInteractionControllersProps = ReturnType + ({ + invoke: (...args: unknown[]) => backendInvokeFn(...args), +})) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => mockIsTauri, + mockInvoke: (command: string, args?: Record) => backendInvokeFn(command, args), +})) + +function makeEntry(): VaultEntry { + return { + path: `${ACTIVE_VAULT_PATH}/note/recovered.md`, + filename: 'recovered.md', + title: 'Recovered', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + archived: false, + modifiedAt: 1, + createdAt: 1, + 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: true, + fileKind: 'markdown', + } +} + +function commandPath(args?: Record): string { + return typeof args?.path === 'string' ? args.path : '' +} + +function buildUpgradeStartupMock() { + let activeReloads = 0 + + return { + activeReloadCount: () => activeReloads, + invoke: (command: string, args?: Record) => { + if (command === 'reload_vault') { + if (commandPath(args) !== ACTIVE_VAULT_PATH) return Promise.resolve([]) + activeReloads += 1 + return Promise.resolve(activeReloads === 1 ? [] : [makeEntry()]) + } + if (EMPTY_ARRAY_COMMANDS.has(command)) return Promise.resolve([]) + return Promise.resolve(null) + }, + } +} + +describe('useVaultLoader startup recovery', () => { + beforeEach(() => { + mockIsTauri = true + backendInvokeFn.mockReset() + }) + + it('freshly reloads the active workspace when persisted metadata follows an empty startup scan', async () => { + const laputa: VaultOption = { label: 'Laputa', path: ACTIVE_VAULT_PATH, available: true, mounted: true } + const startupMock = buildUpgradeStartupMock() + backendInvokeFn.mockImplementation(startupMock.invoke) + + const { result, rerender } = renderHook( + ({ vaults }: { vaults?: VaultOption[] }) => useVaultLoader(ACTIVE_VAULT_PATH, vaults, ACTIVE_VAULT_PATH, vaults), + { initialProps: { vaults: undefined } }, + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + expect(result.current.entries).toEqual([]) + + rerender({ vaults: [laputa] }) + + await waitFor(() => { + expect(result.current.entries.map((entry) => entry.title)).toEqual(['Recovered']) + }) + expect(startupMock.activeReloadCount()).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 3ffac6ba..bd145730 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -888,12 +888,14 @@ function useInitialLoadedWorkspaceMarker({ }) { useEffect(() => { if (isLoading || !hasVaultPath({ vaultPath }) || initialLoadedVaultPathRef.current === vaultPath) return + const inferFallbackWorkspacePath = !vaults?.length + const loadedPaths = inferFallbackWorkspacePath && entries.length === 0 + ? [] + : loadedWorkspacePathsFromEntries(entries, vaultPath, { inferFallbackWorkspacePath }) initialLoadedVaultPathRef.current = vaultPath loadedWorkspacePathsRef.current = new Set([ ...loadedWorkspacePathsRef.current, - ...loadedWorkspacePathsFromEntries(entries, vaultPath, { - inferFallbackWorkspacePath: !vaults?.length, - }), + ...loadedPaths, ]) }, [entries, initialLoadedVaultPathRef, isLoading, loadedWorkspacePathsRef, vaultPath, vaults]) } @@ -967,7 +969,10 @@ function loadMissingWorkspaceEntries({ vaultPath: string vaults?: VaultOption[] }) { - void loadWorkspaceEntries(vault, defaultWorkspacePath, { reloadIfEmpty: true }) + void loadWorkspaceEntries(vault, defaultWorkspacePath, { + forceReload: vault.path === vaultPath, + reloadIfEmpty: true, + }) .then((loadedEntries) => { if (!isCurrentVaultPath(vaultPath)) return loadedPaths.add(vault.path) diff --git a/tests/smoke/blocknote-slash-menu-mouse.spec.ts b/tests/smoke/blocknote-slash-menu-mouse.spec.ts index 92aa9534..dbefaabf 100644 --- a/tests/smoke/blocknote-slash-menu-mouse.spec.ts +++ b/tests/smoke/blocknote-slash-menu-mouse.spec.ts @@ -13,11 +13,15 @@ test.afterEach(() => { removeFixtureVaultCopy(tempVaultDir) }) -async function openSlashMenuOnNewLine(page: Page) { +async function focusNewEditorLine(page: Page) { await page.locator('[data-testid="note-list-container"]').getByText('Alpha Project', { exact: true }).click() await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) await page.locator('.bn-block-content').last().click() await page.keyboard.press('Enter') +} + +async function openSlashMenuOnNewLine(page: Page) { + await focusNewEditorLine(page) await page.keyboard.type('/') const menu = page.locator('.bn-suggestion-menu') @@ -34,6 +38,15 @@ async function openFilteredSlashMenuOnNewLine(page: Page) { return menu } +async function openEmojiShortcodeMenuOnNewLine(page: Page, query: string) { + await focusNewEditorLine(page) + await page.keyboard.type(`:${query}`) + + const menu = page.locator('.bn-grid-suggestion-menu') + await expect(menu).toBeVisible({ timeout: 5_000 }) + return menu +} + test('filtered slash-menu commands can be selected with the mouse', async ({ page }) => { await openFilteredSlashMenuOnNewLine(page) @@ -56,3 +69,23 @@ test('plain slash-menu mouse selection opens follow-up pickers', async ({ page } await expect(page.locator('.bn-grid-suggestion-menu')).toBeVisible({ timeout: 5_000 }) await expect(page.locator('.bn-suggestion-menu')).not.toBeVisible() }) + +test('emoji shortcode suggestions insert the selected emoji with the keyboard', async ({ page }) => { + await openEmojiShortcodeMenuOnNewLine(page, 'it') + + await page.keyboard.press('Enter') + + await expect(page.locator('.bn-editor')).toContainText('🇮🇹') + await expect(page.locator('.bn-editor')).not.toContainText(':it') + await expect(page.locator('.bn-grid-suggestion-menu')).not.toBeVisible() +}) + +test('emoji shortcode suggestions insert the selected emoji with the mouse', async ({ page }) => { + const menu = await openEmojiShortcodeMenuOnNewLine(page, 'rocket') + + await menu.locator('.bn-grid-suggestion-menu-item').filter({ hasText: '🚀' }).click() + + await expect(page.locator('.bn-editor')).toContainText('🚀') + await expect(page.locator('.bn-editor')).not.toContainText(':rocket') + await expect(page.locator('.bn-grid-suggestion-menu')).not.toBeVisible() +}) diff --git a/tests/smoke/startup-empty-vault-recovery.spec.ts b/tests/smoke/startup-empty-vault-recovery.spec.ts new file mode 100644 index 00000000..2c7e90fb --- /dev/null +++ b/tests/smoke/startup-empty-vault-recovery.spec.ts @@ -0,0 +1,152 @@ +import { test, expect, type Page } from '@playwright/test' +import path from 'node:path' + +type CommandArgs = Record | undefined +type CommandHandler = (args?: CommandArgs) => unknown +type HandlerMap = Record + +interface TauriInternals { + invoke: (command: string, args?: CommandArgs) => Promise + transformCallback: () => string + unregisterCallback: () => void +} + +type StartupWindow = Window & typeof globalThis & { + __mockHandlers?: HandlerMap + __startupReloadCount?: () => number + __TAURI_INTERNALS__?: TauriInternals +} + +const activeVaultPath = path.join(process.cwd(), 'demo-vault-v2') +const starterVaultPath = path.join(process.cwd(), 'mock-getting-started') +const recoveredNotePath = path.join(activeVaultPath, 'startup-recovered.md') +const recoveredEntry = { + path: recoveredNotePath, + filename: 'startup-recovered.md', + title: 'Recovered Startup Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: 1_700_000_000, + createdAt: 1_700_000_000, + fileSize: 64, + snippet: 'Recovered after the startup reload.', + wordCount: 6, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: true, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: true, + fileKind: 'markdown', +} + +async function installStartupRecoveryMock(page: Page): Promise { + await page.addInitScript(({ defaultPath, vaultPath, noteEntry }) => { + localStorage.setItem('tolaria_welcome_dismissed', '1') + localStorage.setItem('tolaria:ai-agents-onboarding-dismissed', '1') + localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1') + + const startupWindow = window as StartupWindow + const noteContent = { + [noteEntry.path]: '# Recovered Startup Note\n\nRecovered content.', + } + let reloadCount = 0 + + function emptyArray(): unknown[] { + return [] + } + + function commandPath(args: CommandArgs): string { + return typeof args?.path === 'string' ? args.path : '' + } + + const handlers: HandlerMap = { + load_vault_list: () => ({ + vaults: [ + { label: 'Recovered Vault', path: vaultPath }, + { label: 'Secondary Vault', path: `${vaultPath}-secondary` }, + ], + active_vault: vaultPath, + default_workspace_path: vaultPath, + hidden_defaults: [], + }), + check_vault_exists: () => true, + get_default_vault_path: () => defaultPath, + get_settings: () => ({ + auto_pull_interval_minutes: null, + autogit_enabled: false, + autogit_idle_threshold_seconds: 90, + autogit_inactive_threshold_seconds: 30, + auto_advance_inbox_after_organize: null, + telemetry_consent: true, + crash_reporting_enabled: null, + analytics_enabled: null, + anonymous_id: null, + release_channel: null, + theme_mode: null, + ui_language: null, + note_width_mode: null, + sidebar_type_pluralization_enabled: null, + default_ai_agent: null, + default_ai_target: null, + ai_model_providers: [], + }), + get_vault_settings: () => ({ theme: null }), + reload_vault: (args) => { + if (commandPath(args) !== vaultPath) return emptyArray() + reloadCount += 1 + return reloadCount === 1 ? emptyArray() : [noteEntry] + }, + list_vault: emptyArray, + list_vault_folders: emptyArray, + list_views: emptyArray, + get_modified_files: emptyArray, + get_all_content: () => noteContent, + get_note_content: (args) => noteContent[commandPath(args) as keyof typeof noteContent] ?? '', + is_git_repo: () => true, + sync_mcp_bridge_vault: () => null, + start_vault_watcher: () => null, + stop_vault_watcher: () => null, + update_menu_state: () => null, + } + + startupWindow.__startupReloadCount = () => reloadCount + startupWindow.__TAURI_INTERNALS__ = { + transformCallback: () => crypto.randomUUID(), + unregisterCallback: () => {}, + invoke: async (command, args) => { + if (command === 'plugin:event|listen') return 1 + if (command === 'plugin:event|unlisten') return null + return handlers[command]?.(args) ?? startupWindow.__mockHandlers?.[command]?.(args) ?? null + }, + } + startupWindow.isTauri = true + }, { defaultPath: starterVaultPath, vaultPath: activeVaultPath, noteEntry: recoveredEntry }) +} + +async function startupReloadCount(page: Page): Promise { + return page.evaluate(() => (window as StartupWindow).__startupReloadCount?.() ?? 0) +} + +test('startup recovers notes after an empty first vault reload @smoke', async ({ page }) => { + await installStartupRecoveryMock(page) + await page.goto('/', { waitUntil: 'domcontentloaded' }) + + await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 }) + await expect(page.getByText('Recovered Startup Note')).toBeVisible({ timeout: 8_000 }) + await expect.poll(() => startupReloadCount(page)).toBeGreaterThanOrEqual(2) +})