feat: replace NoteWindow with full App instance for note windows

Note windows now render the main App component with panels defaulted to
hidden (editor-only view, inspector collapsed). This gives note windows
full feature parity: zoom, all keyboard shortcuts, properties editing,
and panel toggles — eliminating the maintenance burden of a separate shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-30 18:30:38 +02:00
parent 2b85640521
commit 98a98ab024
5 changed files with 38 additions and 181 deletions

View File

@@ -55,6 +55,7 @@ import type { SidebarSelection, InboxPeriod } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
import './App.css'
// Type declarations for mock content storage and test overrides
@@ -70,6 +71,7 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function App() {
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
const [inboxPeriod, setInboxPeriod] = useState<InboxPeriod>('month')
@@ -77,7 +79,7 @@ function App() {
setSelection(sel)
setNoteListFilter('open')
}, [])
const layout = useLayoutPanels()
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const dialogs = useDialogs()
@@ -92,7 +94,7 @@ function App() {
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
const vault = useVaultLoader(resolvedPath)
useVaultConfig(resolvedPath)
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
@@ -125,6 +127,28 @@ function App() {
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
// Note window: auto-open the note from URL params once vault entries load
const noteWindowOpenedRef = useRef(false)
useEffect(() => {
if (!noteWindowParams || noteWindowOpenedRef.current || vault.entries.length === 0) return
const entry = vault.entries.find(e => e.path === noteWindowParams.notePath)
if (entry) {
noteWindowOpenedRef.current = true
notes.handleSelectNote(entry)
}
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- run when entries load, params are stable
// Note window: update window title when active note changes
useEffect(() => {
if (!noteWindowParams) return
const activeEntry = notes.tabs.find(t => t.entry.path === notes.activeTabPath)?.entry
const title = activeEntry?.title ?? noteWindowParams.noteTitle
if (!isTauri()) { document.title = title; return }
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
getCurrentWindow().setTitle(title)
}).catch(() => {})
}, [noteWindowParams, notes.tabs, notes.activeTabPath])
// Keep note entry in sync with vault entries so banners (trash/archive)
// and read-only state react immediately without reopening the note.
useEffect(() => {
@@ -245,7 +269,7 @@ function App() {
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
const diffToggleRef = useRef<() => void>(() => {})
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined)
const zoom = useZoom()
const buildNumber = useBuildNumber()
@@ -352,18 +376,18 @@ function App() {
return { type: null, query: '' }
}, [selection])
// Show welcome/onboarding screen when vault doesn't exist
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
return <WelcomeView onboarding={onboarding} />
}
// Show loading spinner while checking vault
if (onboarding.state.status === 'loading') {
// Show loading spinner while checking vault (skip for note windows)
if (!noteWindowParams && onboarding.state.status === 'loading') {
return <LoadingView />
}
// Show telemetry consent dialog on first launch (or first upgrade with telemetry)
if (settingsLoaded && settings.telemetry_consent === null) {
// Show telemetry consent dialog on first launch (skip for note windows)
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
return (
<TelemetryConsentDialog
onAccept={() => {

View File

@@ -1,163 +0,0 @@
import { useCallback, useEffect, useMemo, useState } 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 { getNoteWindowParams } from './utils/windowMode'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import type { VaultEntry } from './types'
import './App.css'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
interface Tab {
entry: VaultEntry
content: string
}
/**
* Minimal app shell for secondary "note windows" opened via "Open in New Window".
* Shows only the editor — no sidebar, no note list.
*/
export default function NoteWindow() {
const params = getNoteWindowParams()
const [entries, setEntries] = useState<VaultEntry[]>([])
const [tabs, setTabs] = useState<Tab[]>([])
const [toastMessage, setToastMessage] = useState<string | null>(null)
const activeTabPath = tabs[0]?.entry.path ?? null
const layout = useLayoutPanels()
// Load vault entries + note content on mount
useEffect(() => {
if (!params) return
const { vaultPath, notePath } = params
let cancelled = false
async function load() {
const vaultEntries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
if (cancelled) return
setEntries(vaultEntries)
const entry = vaultEntries.find(e => e.path === notePath)
if (!entry) return
const content = await tauriCall<string>('get_note_content', { path: notePath })
if (cancelled) return
setTabs([{ entry, content }])
}
load().catch(err => console.error('NoteWindow load failed:', err))
return () => { cancelled = true }
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount with captured params
const vaultPath = params?.vaultPath ?? ''
// Update window title when note title changes
useEffect(() => {
const title = tabs[0]?.entry.title
if (!title) return
if (!isTauri()) { document.title = title; return }
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
getCurrentWindow().setTitle(title)
}).catch(() => {})
}, [tabs])
// Auto-save
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
setEntries(prev => prev.map(e => e.path === path ? { ...e, ...patch } : e))
setTabs(prev => prev.map(t => t.entry.path === path ? { ...t, entry: { ...t.entry, ...patch } } : t))
}, [])
const onAfterSave = useCallback(() => {}, [])
const onNotePersisted = useCallback(() => {}, [])
const { handleSave, handleContentChange } = useEditorSaveWithLinks({
updateEntry,
setTabs,
setToastMessage,
onAfterSave,
onNotePersisted,
})
// Wikilink navigation — in a note window, open wikilinks in the same window
const handleNavigateWikilink = useCallback((target: string) => {
const targetLower = target.toLowerCase()
const entry = entries.find(e =>
e.title.toLowerCase() === targetLower ||
e.aliases.some(a => a.toLowerCase() === targetLower)
)
if (!entry) return
tauriCall<string>('get_note_content', { path: entry.path }).then(content => {
setTabs([{ entry, content }])
}).catch(() => {})
}, [entries])
// Stub for close tab — in a note window, close the window
const handleCloseTab = useCallback(() => {
if (!isTauri()) return
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
getCurrentWindow().close()
}).catch(() => {})
}, [])
// Keyboard: Cmd+S to save, Cmd+W to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
if ((e.metaKey || e.ctrlKey) && e.key === 'w') {
e.preventDefault()
handleCloseTab()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSave, handleCloseTab])
const activeTab = tabs[0] ?? null
const gitHistory = useMemo(() => [], [])
if (!params) {
return <div className="app-shell"><p>Invalid note window parameters</p></div>
}
if (!activeTab) {
return (
<div className="app-shell">
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading</span>
</div>
</div>
)
}
return (
<div className="app-shell">
<div className="app">
<div className="app__editor">
<Editor
tabs={tabs}
activeTabPath={activeTabPath}
entries={entries}
onNavigateWikilink={handleNavigateWikilink}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={() => layout.setInspectorCollapsed(c => !c)}
inspectorWidth={layout.inspectorWidth}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab.entry}
inspectorContent={activeTab.content}
gitHistory={gitHistory}
onContentChange={handleContentChange}
onSave={handleSave}
vaultPath={vaultPath}
/>
</div>
</div>
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
</div>
)
}

View File

@@ -7,11 +7,11 @@ export const COLUMN_MIN_WIDTHS = {
inspector: 240,
} as const
export function useLayoutPanels() {
export function useLayoutPanels(options?: { initialInspectorCollapsed?: boolean }) {
const [sidebarWidth, setSidebarWidth] = useState(250)
const [noteListWidth, setNoteListWidth] = useState(300)
const [inspectorWidth, setInspectorWidth] = useState(280)
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
const [inspectorCollapsed, setInspectorCollapsed] = useState(options?.initialInspectorCollapsed ?? false)
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(COLUMN_MIN_WIDTHS.sidebar, Math.min(400, w + delta))), [])
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(COLUMN_MIN_WIDTHS.noteList, Math.min(500, w + delta))), [])
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(COLUMN_MIN_WIDTHS.inspector, Math.min(500, w - delta))), [])

View File

@@ -18,8 +18,8 @@ function loadViewMode(): ViewMode {
return 'all'
}
export function useViewMode() {
const [viewMode, setViewModeState] = useState<ViewMode>(loadViewMode)
export function useViewMode(initialOverride?: ViewMode) {
const [viewMode, setViewModeState] = useState<ViewMode>(initialOverride ?? loadViewMode)
// Re-sync when vault config becomes available
useEffect(() => {

View File

@@ -3,8 +3,6 @@ import { createRoot } from 'react-dom/client'
import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css'
import App from './App.tsx'
import NoteWindow from './NoteWindow.tsx'
import { isNoteWindow } from './utils/windowMode'
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
// at native level before React's synthetic events can call preventDefault).
@@ -14,12 +12,10 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
document.addEventListener('contextmenu', (e) => e.preventDefault(), true)
}
const RootComponent = isNoteWindow() ? NoteWindow : App
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TooltipProvider>
<RootComponent />
<App />
</TooltipProvider>
</StrictMode>,
)