Merge branch 'main' into pr-736

This commit is contained in:
github-actions[bot]
2026-05-25 10:22:29 +00:00
committed by GitHub
10 changed files with 54 additions and 376 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

@@ -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 (
<button
data-testid="mock-note-window-editor"
type="button"
onClick={() => props.onContentChange?.(props.activeTabPath ?? '', 'Updated content')}
>
{props.tabs[0]?.content}
</button>
)
},
}))
vi.mock('./components/Toast', () => ({
Toast: ({ message }: { message: string | null }) => (
<div data-testid="mock-toast">{message}</div>
),
}))
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(<NoteWindowApp />)
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',
})
})
})

View File

@@ -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<NoteWindowLoadState, { kind: 'ready' }>
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<T>(command: NoteWindowCommand, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
async function resolveNoteWindowEntry(params: NoteWindowParams): Promise<VaultEntry | null> {
for (const path of getNoteWindowPathCandidates(params)) {
try {
const entry = await tauriCall<VaultEntry | null>('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<Extract<NoteWindowLoadState, { kind: 'ready' }>> {
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<string>('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<NoteWindowLoadState>(() => 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<number | null>): 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<number | null>(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<NoteStatus>('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 (
<Editor
tabs={[tab]}
activeTabPath={state.entry.path}
entries={[state.entry]}
onNavigateWikilink={noop}
inspectorCollapsed
onToggleInspector={noop}
inspectorWidth={320}
onInspectorResize={noop}
inspectorEntry={state.entry}
inspectorContent={tabContent}
gitHistory={[]}
onContentChange={updateContent}
getNoteStatus={() => 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 (
<div className="app-shell">
<div className="app">
<div className="app__editor">
{state.kind === 'ready' && params ? (
<NoteWindowEditor params={params} state={state} />
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{state.kind === 'error' ? state.message : null}
</div>
)}
</div>
</div>
{state.kind === 'error' && <Toast message={state.message} onDismiss={noop} />}
</div>
)
}

View File

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

View File

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