Files
tolaria/src/NoteWindowApp.tsx
2026-05-21 12:01:14 +02:00

200 lines
6.5 KiB
TypeScript

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