diff --git a/src/NoteWindowApp.test.tsx b/src/NoteWindowApp.test.tsx
new file mode 100644
index 00000000..35d5bec9
--- /dev/null
+++ b/src/NoteWindowApp.test.tsx
@@ -0,0 +1,148 @@
+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
new file mode 100644
index 00000000..7feb5635
--- /dev/null
+++ b/src/NoteWindowApp.tsx
@@ -0,0 +1,199 @@
+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.test.ts b/src/main.test.ts
index af8ba139..e62b8152 100644
--- a/src/main.test.ts
+++ b/src/main.test.ts
@@ -14,10 +14,14 @@ const mocks = vi.hoisted(() => {
const sentryHandler = vi.fn()
const reactErrorHandler = vi.fn(() => sentryHandler)
const getShortcutEventInit = vi.fn(() => ({ key: 'x' }))
+ const loadAppModule = vi.fn()
+ const renderApp = vi.fn()
return {
createRoot,
getShortcutEventInit,
+ loadAppModule,
+ renderApp,
reactErrorHandler,
render,
sentryHandler,
@@ -27,7 +31,13 @@ const mocks = vi.hoisted(() => {
vi.mock('react-dom/client', () => ({ createRoot: mocks.createRoot }))
vi.mock('@sentry/react', () => ({ reactErrorHandler: mocks.reactErrorHandler }))
vi.mock('./App.tsx', () => ({
- default: () => createElement('div', { 'data-testid': 'mock-app' }),
+ default: (() => {
+ mocks.loadAppModule()
+ return () => {
+ mocks.renderApp()
+ return createElement('div', { 'data-testid': 'mock-app' })
+ }
+ })(),
}))
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: ReactNode }) => createElement('div', null, children),
@@ -169,6 +179,13 @@ describe('main entrypoint', () => {
expect(hasElementTypeName(renderedTree(), 'FrontendReadyMarker')).toBe(true)
})
+ it('defers app-shell module loading until React resolves the root app route', async () => {
+ await importEntrypoint()
+
+ expect(mocks.loadAppModule).not.toHaveBeenCalled()
+ expect(mocks.renderApp).not.toHaveBeenCalled()
+ })
+
it('prevents browser navigation for file drags and still lets app drop handlers run', async () => {
await importEntrypoint()
diff --git a/src/main.tsx b/src/main.tsx
index 941bc0b3..27519f02 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,9 +1,8 @@
-import { StrictMode } from 'react'
+import { lazy, StrictMode, Suspense } from 'react'
import * as Sentry from '@sentry/react'
import { createRoot } from 'react-dom/client'
import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css'
-import App from './App.tsx'
import { FrontendReadyMarker } from './components/FrontendReadyMarker'
import { LinuxTitlebar } from './components/LinuxTitlebar'
import { applyStoredThemeMode } from './lib/themeMode'
@@ -20,9 +19,12 @@ import {
import { isRecoveredBlockNoteRenderError } from './components/blockNoteRenderRecovery'
import { shouldUseLinuxWindowChrome } 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')))
+
function dataTransferHasFiles(dataTransfer: DataTransfer | null): boolean {
if (!dataTransfer) return false
if (dataTransfer.files.length > 0) return true
@@ -185,8 +187,10 @@ createRoot(document.getElementById('root')!, {
-
-
+
+
+
+
,
)