Merge branch 'main' into pr-728

This commit is contained in:
github-actions[bot]
2026-05-23 17:26:45 +00:00
committed by GitHub
11 changed files with 432 additions and 15 deletions

View File

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

View File

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

View File

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

View File

@@ -408,6 +408,7 @@ vi.mock('@blocknote/react', () => ({
DeleteLinkButton: () => null,
SideMenuController: () => null,
SuggestionMenuController: () => null,
GridSuggestionMenuController: () => null,
useComponentsContext: () => ({
LinkToolbar: {
Button: ({

View File

@@ -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<string, (query: string) => Promise<any[]>> = {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
type SuggestionControllerProps = {
triggerCharacter: string
getItems: (query: string) => Promise<unknown[]>
}
const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<unknown[]>> = {}
let capturedGetItems: ((query: string) => Promise<unknown[]>) | 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: ({

View File

@@ -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 <div data-testid={`suggestion-${String(props.triggerCharacter)}`} />
},
GridSuggestionMenuController: (props: Record<string, unknown>) => {
state.capturedSuggestionProps[String(props.triggerCharacter)] = props
return <div data-testid={`grid-suggestion-${String(props.triggerCharacter)}`} />
},
useComponentsContext: () => ({
LinkToolbar: {
Button: ({
@@ -693,6 +700,34 @@ describe('SingleEditorView', () => {
}
})
it('inserts the selected emoji from shortcode suggestions', async () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const getEmojiItems = state.capturedSuggestionProps[':'].getItems as (
query: string
) => Promise<Array<{ id: string; name: string; onItemClick: () => 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')

View File

@@ -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<EmojiSuggestionItem[]> => {
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: <span title={entry.name}>{entry.emoji}</span>,
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<typeof useSuggestionMenuItem
}
function EditorInteractionControllers({
getEmojiItems,
getPersonMentionItems,
getSlashMenuItems,
getWikilinkItems,
@@ -1192,6 +1236,12 @@ function EditorInteractionControllers({
triggerCharacter="/"
getItems={getSlashMenuItems}
/>
<GridSuggestionMenuController
triggerCharacter=":"
columns={10}
minQueryLength={1}
getItems={getEmojiItems}
/>
<SuggestionMenuController
triggerCharacter="[["
getItems={getWikilinkItems}
@@ -1381,6 +1431,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
theme={themeMode}
onChange={handleEditorChange}
editable={editable}
emojiPicker={false}
formattingToolbar={false}
linkToolbar={false}
slashMenu={false}

View File

@@ -0,0 +1,106 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VaultOption } from '../components/status-bar/types'
import type { VaultEntry } from '../types'
import { useVaultLoader } from './useVaultLoader'
const backendInvokeFn = vi.fn()
let mockIsTauri = true
const ACTIVE_VAULT_PATH = '/laputa'
const EMPTY_ARRAY_COMMANDS = new Set(['get_modified_files', 'list_vault_folders', 'list_views'])
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => backendInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => mockIsTauri,
mockInvoke: (command: string, args?: Record<string, unknown>) => 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, unknown>): string {
return typeof args?.path === 'string' ? args.path : ''
}
function buildUpgradeStartupMock() {
let activeReloads = 0
return {
activeReloadCount: () => activeReloads,
invoke: (command: string, args?: Record<string, unknown>) => {
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)
})
})

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
import { test, expect, type Page } from '@playwright/test'
import path from 'node:path'
type CommandArgs = Record<string, unknown> | undefined
type CommandHandler = (args?: CommandArgs) => unknown
type HandlerMap = Record<string, CommandHandler>
interface TauriInternals {
invoke: (command: string, args?: CommandArgs) => Promise<unknown>
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<void> {
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<number> {
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)
})