Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim
This commit is contained in:
148
src/NoteWindowApp.test.tsx
Normal file
148
src/NoteWindowApp.test.tsx
Normal file
@@ -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 (
|
||||
<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',
|
||||
})
|
||||
})
|
||||
})
|
||||
199
src/NoteWindowApp.tsx
Normal file
199
src/NoteWindowApp.tsx
Normal file
@@ -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<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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
12
src/main.tsx
12
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')!, {
|
||||
<StrictMode>
|
||||
<TooltipProvider>
|
||||
<LinuxTitlebar />
|
||||
<App />
|
||||
<FrontendReadyMarker />
|
||||
<Suspense fallback={null}>
|
||||
<RootApp />
|
||||
<FrontendReadyMarker />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user