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')!, { - - + + + + , )