Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim

This commit is contained in:
github-actions[bot]
2026-05-21 10:13:52 +00:00
committed by GitHub
4 changed files with 373 additions and 5 deletions

148
src/NoteWindowApp.test.tsx Normal file
View 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
View 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>
)
}

View File

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

View File

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