From afc2ead14c1a74c7f226c215814faf48b8410725 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 25 May 2026 12:07:25 +0200 Subject: [PATCH] fix: restore full app note windows --- docs/ARCHITECTURE.md | 5 +- ...-vault-graph-for-secondary-note-windows.md | 32 +++ docs/adr/README.md | 3 +- src/App.note-window-properties.test.tsx | 12 +- src/App.test.tsx | 4 +- src/App.tsx | 22 +- src/NoteWindowApp.test.tsx | 148 ------------- src/NoteWindowApp.tsx | 199 ------------------ src/main.tsx | 3 +- src/utils/openNoteWindow.ts | 2 +- 10 files changed, 54 insertions(+), 376 deletions(-) create mode 100644 docs/adr/0123-full-vault-graph-for-secondary-note-windows.md delete mode 100644 src/NoteWindowApp.test.tsx delete mode 100644 src/NoteWindowApp.tsx diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index faa87f0a..2e4856bf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -240,7 +240,7 @@ On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the ## Multi-Window (Note Windows) -Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list). +Notes can be opened in separate Tauri windows for focused editing. Secondary windows boot the same `App` shell and load the same active workspace graph as the main window, but they start in the editor-only view mode with side panels collapsed. **Triggers:** - `Cmd+Shift+Click` on any note in the note list or sidebar @@ -250,8 +250,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win **Architecture:** - `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`) -- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window) -- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance +- `main.tsx` always mounts `App`; `App` checks `isNoteWindow()` at startup, keeps normal vault/workspace loading active, and `useNoteWindowLifecycle` opens the requested note after the app graph is ready - Each window has its own auto-save via `useEditorSaveWithLinks` (same 1.5s low-end-safe idle debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload - Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows - Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels diff --git a/docs/adr/0123-full-vault-graph-for-secondary-note-windows.md b/docs/adr/0123-full-vault-graph-for-secondary-note-windows.md new file mode 100644 index 00000000..6ad87541 --- /dev/null +++ b/docs/adr/0123-full-vault-graph-for-secondary-note-windows.md @@ -0,0 +1,32 @@ +--- +type: ADR +id: "0123" +title: "Full vault graph for secondary note windows" +status: active +date: 2026-05-25 +supersedes: "0118" +--- + +## Context + +ADR-0118 made secondary note windows entry-scoped to avoid repeated full-vault scans. That reduced startup work, but it also removed the vault-index context that normal Tolaria capabilities depend on: properties, view actions, quick open/search, workspace-aware navigation, and other command surfaces no longer behaved like the main window. + +The product expectation is that opening a note in a separate window creates another capable Tolaria window, not a reduced editor shell. Performance remains important, but capability parity is the stronger contract. + +## Decision + +**Secondary note windows load the same active vault/workspace graph as a normal Tolaria window.** They still start in editor-only view mode and auto-open the requested note from the URL parameters, but the app keeps the shared vault loader, mounted-workspace filtering, watcher scope, editor entry list, and workspace-aware note actions enabled. + +`main.tsx` always mounts `App`; note-window mode is handled inside `App` through `getNoteWindowParams()` and `useNoteWindowLifecycle`. + +## Alternatives considered + +- **Entry-scoped note windows**: faster startup, but loses app-level features that require repository-wide context. Rejected because it breaks the secondary-window product contract. +- **Full app with full active graph** (chosen): keeps one window architecture and restores feature parity. Trade-off: each secondary window performs its own vault load. +- **Shared state from the main window over IPC**: could provide parity without duplicate scans, but adds synchronization complexity and failure modes. Deferred until profiling proves the duplicate load is a real bottleneck. + +## Consequences + +- Secondary windows can use normal Tolaria capabilities such as Properties, view actions, quick open/search, wikilink navigation, and workspace-aware note operations. +- Opening several note windows can repeat vault-loading work. This is acceptable for now because correctness and parity are more important than avoiding the scan. +- ADR-0118 is superseded. If secondary-window startup becomes too slow, optimize with shared state or incremental loading without removing app capabilities. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9afa3b21..a1f922da 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -170,8 +170,9 @@ proposed → active → superseded | [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 | +| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | superseded -> [0123](0123-full-vault-graph-for-secondary-note-windows.md) | | [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active | | [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 | diff --git a/src/App.note-window-properties.test.tsx b/src/App.note-window-properties.test.tsx index 39f1c1c7..dc0cc05d 100644 --- a/src/App.note-window-properties.test.tsx +++ b/src/App.note-window-properties.test.tsx @@ -221,7 +221,7 @@ describe('App note windows', () => { ) }) - it('keeps note windows scoped to the active entry without loading the vault index', async () => { + it('loads the active vault graph in note windows while opening the requested note', async () => { renderApp() await waitFor(() => { @@ -230,20 +230,20 @@ describe('App note windows', () => { vaultPath: '/vault', }) }) - expect(commandResults.list_vault).not.toHaveBeenCalled() + expect(commandResults.list_vault).toHaveBeenCalled() await waitFor(() => { expect(screen.getByTestId('mock-editor-entry-titles')).toHaveTextContent( - 'Test Project', + 'Test Project|Software Development|Second Project', ) }) expect(editorSnapshots.at(-1)).toEqual({ activeTabPath: activeEntry.path, - entryTitles: ['Test Project'], + entryTitles: ['Test Project', 'Software Development', 'Second Project'], }) }) - it('opens repeated note windows without repeated full-vault scans', async () => { + it('opens repeated note windows through the full app vault loader', async () => { const firstWindow = renderApp() await waitFor(() => { @@ -268,6 +268,6 @@ describe('App note windows', () => { }) }) expect(commandResults.reload_vault_entry).toHaveBeenCalledTimes(2) - expect(commandResults.list_vault).not.toHaveBeenCalled() + expect(vi.mocked(commandResults.list_vault).mock.calls.length).toBeGreaterThanOrEqual(2) }) }) diff --git a/src/App.test.tsx b/src/App.test.tsx index 3eddbb63..1303f033 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -576,7 +576,7 @@ describe('App', () => { }) }) - it('opens a note window by loading only the requested entry', async () => { + it('opens a note window after loading the active vault graph', async () => { const listVault = vi.fn(() => mockEntries) const reloadVaultEntry = vi.fn(({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null, @@ -599,7 +599,7 @@ describe('App', () => { expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' }) await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md')) expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true') - expect(listVault).not.toHaveBeenCalled() + expect(listVault).toHaveBeenCalled() }) it('shows keyboard shortcut hints', async () => { diff --git a/src/App.tsx b/src/App.tsx index 8fa69540..d3b1bc39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -356,7 +356,7 @@ function App() { resolvedPath, settings, vaultSwitcherLoaded: vaultSwitcher.loaded, - windowMode: Boolean(noteWindowParams), + windowMode: false, }) const vaultWorkspaceOrder = useMemo( () => vaultSwitcher.allVaults.map((vault) => vault.path), @@ -382,12 +382,7 @@ function App() { windowMode: Boolean(noteWindowParams), }) - const vault = useVaultLoader( - noteWindowParams ? '' : resolvedPath, - graphVaults, - multiWorkspaceEnabled ? defaultWorkspacePath : null, - folderVaults, - ) + const vault = useVaultLoader(resolvedPath, graphVaults, multiWorkspaceEnabled ? defaultWorkspacePath : null, folderVaults) const gitRepositories = useMemo(() => activeGitRepositories({ defaultVaultPath: graphDefaultWorkspacePath, multiWorkspaceEnabled, @@ -402,21 +397,20 @@ function App() { repositories: gitRepositories, }) const watchedVaultPaths = useMemo(() => { - if (noteWindowParams) return [] if (visibleWorkspacePathList && visibleWorkspacePathList.length > 0) return visibleWorkspacePathList return resolvedPath.trim() ? [resolvedPath] : [] - }, [noteWindowParams, resolvedPath, visibleWorkspacePathList]) + }, [resolvedPath, visibleWorkspacePathList]) const visibleEntries = useVisibleWorkspaceEntries({ entries: vault.entries, multiWorkspaceEnabled, visibleWorkspacePathList, }) - const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null + const runtimeMissingVaultPath = vault.unavailableVaultPath const { markInternalWrite: markRecentVaultWrite, filterExternalPaths: filterExternalVaultPaths, } = useRecentVaultWrites({ - vaultPath: noteWindowParams ? '' : resolvedPath, + vaultPath: resolvedPath, vaultPaths: watchedVaultPaths, }) const { @@ -608,7 +602,7 @@ function App() { setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, - defaultWorkspacePath: noteWindowParams || !multiWorkspaceEnabled ? null : defaultWorkspacePath, + defaultWorkspacePath: multiWorkspaceEnabled ? defaultWorkspacePath : null, vaults: graphVaults ?? [], addPendingSave: handleCreatedVaultEntryPersisting, removePendingSave: vault.removePendingSave, @@ -695,7 +689,7 @@ function App() { } }, [watchedVaultPaths]) useVaultWatcher({ - vaultPath: noteWindowParams ? '' : resolvedPath, + vaultPath: resolvedPath, vaultPaths: watchedVaultPaths, onVaultChanged: handleFocusedVaultUpdate, filterChangedPaths: filterExternalVaultPaths, @@ -1742,7 +1736,7 @@ function App() { tabs={notes.tabs} activeTabPath={notes.activeTabPath} isVaultLoading={isVaultContentLoading} - entries={noteWindowParams && activeTab ? [activeTab.entry] : visibleEntries} + entries={visibleEntries} onNavigateWikilink={notes.handleNavigateWikilink} onLoadDiff={loadDiffForPath} onLoadDiffAtCommit={loadDiffAtCommitForPath} diff --git a/src/NoteWindowApp.test.tsx b/src/NoteWindowApp.test.tsx deleted file mode 100644 index 35d5bec9..00000000 --- a/src/NoteWindowApp.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import NoteWindowApp from './NoteWindowApp' -import type { NoteStatus, VaultEntry } from './types' - -type MockEditorProps = { - activeTabPath: string | null - entries: VaultEntry[] - getNoteStatus?: (path: string) => NoteStatus - onContentChange?: (path: string, content: string) => void - tabs: Array<{ entry: VaultEntry; content: string }> - vaultPath?: string - vaultPaths?: string[] -} - -const mocks = vi.hoisted(() => ({ - editorProps: [] as MockEditorProps[], - invoke: vi.fn(), - setTitle: vi.fn(), - warn: vi.fn(), -})) - -vi.mock('@tauri-apps/api/core', () => ({ - invoke: mocks.invoke, -})) - -vi.mock('@tauri-apps/api/window', () => ({ - getCurrentWindow: () => ({ setTitle: mocks.setTitle }), -})) - -vi.mock('./mock-tauri', () => ({ - isTauri: () => true, - mockInvoke: vi.fn(), -})) - -vi.mock('./components/Editor', () => ({ - Editor: (props: MockEditorProps) => { - mocks.editorProps.push(props) - return ( - - ) - }, -})) - -vi.mock('./components/Toast', () => ({ - Toast: ({ message }: { message: string | null }) => ( -
{message}
- ), -})) - -function makeEntry(): VaultEntry { - return { - path: 'Notes/entry.md', - filename: 'entry.md', - title: 'Entry', - isA: 'Note', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - archived: false, - modifiedAt: 1_700_000_000, - createdAt: null, - fileSize: 256, - snippet: '', - wordCount: 10, - 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', - } -} - -function setSearch(search: string) { - Object.defineProperty(window, 'location', { - writable: true, - value: { ...window.location, search }, - }) -} - -describe('NoteWindowApp', () => { - beforeEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - vi.spyOn(console, 'warn').mockImplementation(mocks.warn) - mocks.editorProps.length = 0 - setSearch('?window=note&path=%2Fvault%2FNotes%2Fentry.md&vault=%2Fvault&title=Entry') - mocks.invoke.mockImplementation(async (command: string) => { - if (command === 'sync_vault_asset_scope_for_window') return undefined - if (command === 'reload_vault_entry') return makeEntry() - if (command === 'get_note_content') return '# Entry' - if (command === 'save_note_content') return undefined - throw new Error(`Unexpected command: ${command}`) - }) - }) - - afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() - }) - - it('loads one note without booting the full vault and saves editor changes', async () => { - render() - const editor = await screen.findByTestId('mock-note-window-editor') - - expect(editor).toHaveTextContent('# Entry') - expect(mocks.editorProps.at(-1)).toEqual(expect.objectContaining({ - activeTabPath: 'Notes/entry.md', - vaultPath: '/vault', - vaultPaths: ['/vault'], - })) - expect(mocks.invoke).not.toHaveBeenCalledWith('list_vault', expect.anything()) - - vi.useFakeTimers() - fireEvent.click(editor) - expect(mocks.editorProps.at(-1)?.getNoteStatus?.('Notes/entry.md')).toBe('unsaved') - - await act(async () => { - await vi.advanceTimersByTimeAsync(700) - }) - vi.useRealTimers() - - expect(mocks.invoke).toHaveBeenCalledWith('save_note_content', { - path: 'Notes/entry.md', - content: 'Updated content', - vaultPath: '/vault', - }) - }) -}) diff --git a/src/NoteWindowApp.tsx b/src/NoteWindowApp.tsx deleted file mode 100644 index 7feb5635..00000000 --- a/src/NoteWindowApp.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' -import { invoke } from '@tauri-apps/api/core' -import { Editor } from './components/Editor' -import { Toast } from './components/Toast' -import { isTauri, mockInvoke } from './mock-tauri' -import type { NoteStatus, VaultEntry } from './types' -import { - getNoteWindowPathCandidates, - getNoteWindowParams, - type NoteWindowParams, -} from './utils/windowMode' -import './App.css' - -const NOTE_WINDOW_SAVE_DELAY_MS = 700 -const MISSING_NOTE_WINDOW_PARAMS_MESSAGE = '' -const noop = () => {} - -type NoteWindowLoadState = - | { kind: 'loading' } - | { kind: 'ready'; entry: VaultEntry; content: string } - | { kind: 'error'; message: string } - -type ReadyNoteWindowState = Extract -type SaveRequest = { path: string; content: string; vaultPath: string } -type NoteWindowCommand = - | 'get_note_content' - | 'reload_vault_entry' - | 'save_note_content' - | 'sync_vault_asset_scope_for_window' - -function tauriCall(command: NoteWindowCommand, args: Record): Promise { - return isTauri() ? invoke(command, args) : mockInvoke(command, args) -} - -async function resolveNoteWindowEntry(params: NoteWindowParams): Promise { - for (const path of getNoteWindowPathCandidates(params)) { - try { - const entry = await tauriCall('reload_vault_entry', { - path, - vaultPath: params.vaultPath, - }) - if (entry) return entry - } catch { - // Keep trying normalized path candidates before surfacing a load failure. - } - } - - return null -} - -async function loadNoteWindow(params: NoteWindowParams): Promise> { - await tauriCall('sync_vault_asset_scope_for_window', { vaultPath: params.vaultPath }).catch(() => undefined) - const entry = await resolveNoteWindowEntry(params) - if (!entry) throw new Error(`Could not open "${params.noteTitle}" in this window`) - const content = await tauriCall('get_note_content', { - path: entry.path, - vaultPath: params.vaultPath, - }) - return { kind: 'ready', entry, content } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function initialNoteWindowState(params: NoteWindowParams | null): NoteWindowLoadState { - return params ? { kind: 'loading' } : { kind: 'error', message: MISSING_NOTE_WINDOW_PARAMS_MESSAGE } -} - -function useNoteWindowState(params: NoteWindowParams | null): NoteWindowLoadState { - const [state, setState] = useState(() => initialNoteWindowState(params)) - - useEffect(() => { - if (!params) return - let cancelled = false - - loadNoteWindow(params) - .then((nextState) => { - if (!cancelled) setState(nextState) - }) - .catch((error) => { - if (!cancelled) setState({ kind: 'error', message: errorMessage(error) }) - }) - - return () => { cancelled = true } - }, [params]) - - return state -} - -function getWindowTitle(state: NoteWindowLoadState, params: NoteWindowParams | null): string | null { - return state.kind === 'ready' ? state.entry.title : params?.noteTitle ?? null -} - -function useNoteWindowTitle(state: NoteWindowLoadState, params: NoteWindowParams | null): void { - useEffect(() => { - const title = getWindowTitle(state, params) - if (!title) return - - document.title = title - if (!isTauri()) return - - import('@tauri-apps/api/window') - .then(({ getCurrentWindow }) => getCurrentWindow().setTitle(title)) - .catch((error) => console.warn('[window] Failed to update note window title:', error)) - }, [params, state]) -} - -function clearSaveTimer(saveTimerRef: MutableRefObject): void { - if (saveTimerRef.current === null) return - - window.clearTimeout(saveTimerRef.current) - saveTimerRef.current = null -} - -function saveNoteWindowContent(request: SaveRequest, setStatus: (status: NoteStatus) => void): void { - setStatus('pendingSave') - void tauriCall('save_note_content', request) - .then(() => setStatus('clean')) - .catch((error) => { - console.warn('[window] Failed to save note window content:', error) - setStatus('unsaved') - }) -} - -function useDebouncedNoteWindowSave(vaultPath: string | null, setStatus: (status: NoteStatus) => void) { - const saveTimerRef = useRef(null) - - useEffect(() => () => clearSaveTimer(saveTimerRef), []) - - return useCallback((path: string, content: string) => { - if (!vaultPath) return - setStatus('unsaved') - clearSaveTimer(saveTimerRef) - - saveTimerRef.current = window.setTimeout(() => { - saveTimerRef.current = null - saveNoteWindowContent({ path, content, vaultPath }, setStatus) - }, NOTE_WINDOW_SAVE_DELAY_MS) - }, [setStatus, vaultPath]) -} - -function NoteWindowEditor({ params, state }: { - params: NoteWindowParams - state: ReadyNoteWindowState -}) { - const [tabContent, setTabContent] = useState(state.content) - const [noteStatus, setNoteStatus] = useState('clean') - const saveContent = useDebouncedNoteWindowSave(params.vaultPath, setNoteStatus) - const tab = useMemo(() => ({ entry: state.entry, content: tabContent }), [state.entry, tabContent]) - const updateContent = useCallback((path: string, content: string) => { - setTabContent(content) - saveContent(path, content) - }, [saveContent]) - - return ( - noteStatus} - vaultPath={params.vaultPath} - vaultPaths={[params.vaultPath]} - locale="en" - /> - ) -} - -export default function NoteWindowApp() { - const params = useMemo(() => getNoteWindowParams(), []) - const state = useNoteWindowState(params) - useNoteWindowTitle(state, params) - - return ( -
-
-
- {state.kind === 'ready' && params ? ( - - ) : ( -
- {state.kind === 'error' ? state.message : null} -
- )} -
-
- {state.kind === 'error' && } -
- ) -} diff --git a/src/main.tsx b/src/main.tsx index b74671a4..85457d82 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -19,11 +19,10 @@ import { import { isRecoveredBlockNoteRenderError } from './components/blockNoteRenderRecovery' import { isMac, shouldUseCustomWindowChrome } from './utils/platform' import { reloadFrontendOnceIfStartupFailed } from './utils/frontendReady' -import { isNoteWindow } from './utils/windowMode' const TLDRAW_CONTEXT_MENU_SELECTOR = '.tldraw-whiteboard' -const RootApp = lazy(() => (isNoteWindow() ? import('./NoteWindowApp') : import('./App.tsx'))) +const RootApp = lazy(() => import('./App.tsx')) function dataTransferHasFiles(dataTransfer: DataTransfer | null): boolean { if (!dataTransfer) return false diff --git a/src/utils/openNoteWindow.ts b/src/utils/openNoteWindow.ts index 107e4357..07b1f45d 100644 --- a/src/utils/openNoteWindow.ts +++ b/src/utils/openNoteWindow.ts @@ -36,7 +36,7 @@ export function buildRuntimeNoteWindowUrl( } /** - * Opens a note in a new Tauri window with a minimal editor-only layout. + * Opens a note in a new Tauri window that starts in an editor-only layout. * In browser mode (non-Tauri), this is a no-op. */ export async function openNoteInNewWindow(notePath: string, vaultPath: string, noteTitle: string): Promise {