Merge branch 'main' into pr-736
This commit is contained in:
@@ -240,7 +240,7 @@ On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
|
||||
Notes can be opened in separate Tauri windows for focused editing. Secondary windows boot the same `App` shell and load the same active workspace graph as the main window, but they start in the editor-only view mode with side panels collapsed.
|
||||
|
||||
**Triggers:**
|
||||
- `Cmd+Shift+Click` on any note in the note list or sidebar
|
||||
@@ -250,8 +250,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
**Architecture:**
|
||||
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
|
||||
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
|
||||
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
|
||||
- `main.tsx` always mounts `App`; `App` checks `isNoteWindow()` at startup, keeps normal vault/workspace loading active, and `useNoteWindowLifecycle` opens the requested note after the app graph is ready
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 1.5s low-end-safe idle debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
|
||||
- Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows
|
||||
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
|
||||
|
||||
32
docs/adr/0123-full-vault-graph-for-secondary-note-windows.md
Normal file
32
docs/adr/0123-full-vault-graph-for-secondary-note-windows.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0123"
|
||||
title: "Full vault graph for secondary note windows"
|
||||
status: active
|
||||
date: 2026-05-25
|
||||
supersedes: "0118"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0118 made secondary note windows entry-scoped to avoid repeated full-vault scans. That reduced startup work, but it also removed the vault-index context that normal Tolaria capabilities depend on: properties, view actions, quick open/search, workspace-aware navigation, and other command surfaces no longer behaved like the main window.
|
||||
|
||||
The product expectation is that opening a note in a separate window creates another capable Tolaria window, not a reduced editor shell. Performance remains important, but capability parity is the stronger contract.
|
||||
|
||||
## Decision
|
||||
|
||||
**Secondary note windows load the same active vault/workspace graph as a normal Tolaria window.** They still start in editor-only view mode and auto-open the requested note from the URL parameters, but the app keeps the shared vault loader, mounted-workspace filtering, watcher scope, editor entry list, and workspace-aware note actions enabled.
|
||||
|
||||
`main.tsx` always mounts `App`; note-window mode is handled inside `App` through `getNoteWindowParams()` and `useNoteWindowLifecycle`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Entry-scoped note windows**: faster startup, but loses app-level features that require repository-wide context. Rejected because it breaks the secondary-window product contract.
|
||||
- **Full app with full active graph** (chosen): keeps one window architecture and restores feature parity. Trade-off: each secondary window performs its own vault load.
|
||||
- **Shared state from the main window over IPC**: could provide parity without duplicate scans, but adds synchronization complexity and failure modes. Deferred until profiling proves the duplicate load is a real bottleneck.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Secondary windows can use normal Tolaria capabilities such as Properties, view actions, quick open/search, wikilink navigation, and workspace-aware note operations.
|
||||
- Opening several note windows can repeat vault-loading work. This is acceptable for now because correctness and parity are more important than avoiding the scan.
|
||||
- ADR-0118 is superseded. If secondary-window startup becomes too slow, optimize with shared state or incremental loading without removing app capabilities.
|
||||
@@ -170,8 +170,9 @@ proposed → active → superseded
|
||||
| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active |
|
||||
| [0115](0115-scoped-react-context-for-shared-ui-preferences.md) | Scoped React Context for shared UI preferences | active |
|
||||
| [0116](0116-rich-raw-transition-and-serialization-ownership.md) | Rich/raw transition and serialization ownership | active |
|
||||
| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | active |
|
||||
| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | superseded -> [0123](0123-full-vault-graph-for-secondary-note-windows.md) |
|
||||
| [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active |
|
||||
| [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active |
|
||||
| [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) | AppImage external fallback for audio and video previews | active |
|
||||
| [0122](0122-scalar-array-frontmatter-properties.md) | Scalar array frontmatter properties | active |
|
||||
| [0123](0123-full-vault-graph-for-secondary-note-windows.md) | Full vault graph for secondary note windows | active |
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('App note windows', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps note windows scoped to the active entry without loading the vault index', async () => {
|
||||
it('loads the active vault graph in note windows while opening the requested note', async () => {
|
||||
renderApp(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -230,20 +230,20 @@ describe('App note windows', () => {
|
||||
vaultPath: '/vault',
|
||||
})
|
||||
})
|
||||
expect(commandResults.list_vault).not.toHaveBeenCalled()
|
||||
expect(commandResults.list_vault).toHaveBeenCalled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mock-editor-entry-titles')).toHaveTextContent(
|
||||
'Test Project',
|
||||
'Test Project|Software Development|Second Project',
|
||||
)
|
||||
})
|
||||
expect(editorSnapshots.at(-1)).toEqual({
|
||||
activeTabPath: activeEntry.path,
|
||||
entryTitles: ['Test Project'],
|
||||
entryTitles: ['Test Project', 'Software Development', 'Second Project'],
|
||||
})
|
||||
})
|
||||
|
||||
it('opens repeated note windows without repeated full-vault scans', async () => {
|
||||
it('opens repeated note windows through the full app vault loader', async () => {
|
||||
const firstWindow = renderApp(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -268,6 +268,6 @@ describe('App note windows', () => {
|
||||
})
|
||||
})
|
||||
expect(commandResults.reload_vault_entry).toHaveBeenCalledTimes(2)
|
||||
expect(commandResults.list_vault).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(commandResults.list_vault).mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -576,7 +576,7 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a note window by loading only the requested entry', async () => {
|
||||
it('opens a note window after loading the active vault graph', async () => {
|
||||
const listVault = vi.fn(() => mockEntries)
|
||||
const reloadVaultEntry = vi.fn(({ path }: { path: string }) =>
|
||||
mockEntries.find((entry) => entry.path === path) ?? null,
|
||||
@@ -599,7 +599,7 @@ describe('App', () => {
|
||||
expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' })
|
||||
await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md'))
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
|
||||
expect(listVault).not.toHaveBeenCalled()
|
||||
expect(listVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows keyboard shortcut hints', async () => {
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -356,7 +356,7 @@ function App() {
|
||||
resolvedPath,
|
||||
settings,
|
||||
vaultSwitcherLoaded: vaultSwitcher.loaded,
|
||||
windowMode: Boolean(noteWindowParams),
|
||||
windowMode: false,
|
||||
})
|
||||
const vaultWorkspaceOrder = useMemo(
|
||||
() => vaultSwitcher.allVaults.map((vault) => vault.path),
|
||||
@@ -382,12 +382,7 @@ function App() {
|
||||
windowMode: Boolean(noteWindowParams),
|
||||
})
|
||||
|
||||
const vault = useVaultLoader(
|
||||
noteWindowParams ? '' : resolvedPath,
|
||||
graphVaults,
|
||||
multiWorkspaceEnabled ? defaultWorkspacePath : null,
|
||||
folderVaults,
|
||||
)
|
||||
const vault = useVaultLoader(resolvedPath, graphVaults, multiWorkspaceEnabled ? defaultWorkspacePath : null, folderVaults)
|
||||
const gitRepositories = useMemo(() => activeGitRepositories({
|
||||
defaultVaultPath: graphDefaultWorkspacePath,
|
||||
multiWorkspaceEnabled,
|
||||
@@ -402,21 +397,20 @@ function App() {
|
||||
repositories: gitRepositories,
|
||||
})
|
||||
const watchedVaultPaths = useMemo(() => {
|
||||
if (noteWindowParams) return []
|
||||
if (visibleWorkspacePathList && visibleWorkspacePathList.length > 0) return visibleWorkspacePathList
|
||||
return resolvedPath.trim() ? [resolvedPath] : []
|
||||
}, [noteWindowParams, resolvedPath, visibleWorkspacePathList])
|
||||
}, [resolvedPath, visibleWorkspacePathList])
|
||||
const visibleEntries = useVisibleWorkspaceEntries({
|
||||
entries: vault.entries,
|
||||
multiWorkspaceEnabled,
|
||||
visibleWorkspacePathList,
|
||||
})
|
||||
const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null
|
||||
const runtimeMissingVaultPath = vault.unavailableVaultPath
|
||||
const {
|
||||
markInternalWrite: markRecentVaultWrite,
|
||||
filterExternalPaths: filterExternalVaultPaths,
|
||||
} = useRecentVaultWrites({
|
||||
vaultPath: noteWindowParams ? '' : resolvedPath,
|
||||
vaultPath: resolvedPath,
|
||||
vaultPaths: watchedVaultPaths,
|
||||
})
|
||||
const {
|
||||
@@ -608,7 +602,7 @@ function App() {
|
||||
setToastMessage,
|
||||
updateEntry: vault.updateEntry,
|
||||
vaultPath: resolvedPath,
|
||||
defaultWorkspacePath: noteWindowParams || !multiWorkspaceEnabled ? null : defaultWorkspacePath,
|
||||
defaultWorkspacePath: multiWorkspaceEnabled ? defaultWorkspacePath : null,
|
||||
vaults: graphVaults ?? [],
|
||||
addPendingSave: handleCreatedVaultEntryPersisting,
|
||||
removePendingSave: vault.removePendingSave,
|
||||
@@ -695,7 +689,7 @@ function App() {
|
||||
}
|
||||
}, [watchedVaultPaths])
|
||||
useVaultWatcher({
|
||||
vaultPath: noteWindowParams ? '' : resolvedPath,
|
||||
vaultPath: resolvedPath,
|
||||
vaultPaths: watchedVaultPaths,
|
||||
onVaultChanged: handleFocusedVaultUpdate,
|
||||
filterChangedPaths: filterExternalVaultPaths,
|
||||
@@ -1742,7 +1736,7 @@ function App() {
|
||||
tabs={notes.tabs}
|
||||
activeTabPath={notes.activeTabPath}
|
||||
isVaultLoading={isVaultContentLoading}
|
||||
entries={noteWindowParams && activeTab ? [activeTab.entry] : visibleEntries}
|
||||
entries={visibleEntries}
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={loadDiffForPath}
|
||||
onLoadDiffAtCommit={loadDiffAtCommitForPath}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,199 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -19,11 +19,10 @@ import {
|
||||
import { isRecoveredBlockNoteRenderError } from './components/blockNoteRenderRecovery'
|
||||
import { isMac, shouldUseCustomWindowChrome } 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')))
|
||||
const RootApp = lazy(() => import('./App.tsx'))
|
||||
|
||||
function dataTransferHasFiles(dataTransfer: DataTransfer | null): boolean {
|
||||
if (!dataTransfer) return false
|
||||
|
||||
@@ -36,7 +36,7 @@ export function buildRuntimeNoteWindowUrl(
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a note in a new Tauri window with a minimal editor-only layout.
|
||||
* Opens a note in a new Tauri window that starts in an editor-only layout.
|
||||
* In browser mode (non-Tauri), this is a no-op.
|
||||
*/
|
||||
export async function openNoteInNewWindow(notePath: string, vaultPath: string, noteTitle: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user