From 98a98ab0247e58ba8a5982776cde59cc631cecd9 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 30 Mar 2026 18:30:38 +0200 Subject: [PATCH] feat: replace NoteWindow with full App instance for note windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/App.tsx | 42 +++++++-- src/NoteWindow.tsx | 163 ----------------------------------- src/hooks/useLayoutPanels.ts | 4 +- src/hooks/useViewMode.ts | 4 +- src/main.tsx | 6 +- 5 files changed, 38 insertions(+), 181 deletions(-) delete mode 100644 src/NoteWindow.tsx diff --git a/src/App.tsx b/src/App.tsx index 81ee6ee1..7e4202d9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(DEFAULT_SELECTION) const [noteListFilter, setNoteListFilter] = useState('open') const [inboxPeriod, setInboxPeriod] = useState('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(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 } - // 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 } - // 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 ( { diff --git a/src/NoteWindow.tsx b/src/NoteWindow.tsx deleted file mode 100644 index 996e9c74..00000000 --- a/src/NoteWindow.tsx +++ /dev/null @@ -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(command: string, args: Record): Promise { - return isTauri() ? invoke(command, args) : mockInvoke(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([]) - const [tabs, setTabs] = useState([]) - const [toastMessage, setToastMessage] = useState(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('list_vault', { path: vaultPath }) - if (cancelled) return - setEntries(vaultEntries) - const entry = vaultEntries.find(e => e.path === notePath) - if (!entry) return - const content = await tauriCall('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) => { - 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('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

Invalid note window parameters

- } - - if (!activeTab) { - return ( -
-
- Loading… -
-
- ) - } - - return ( -
-
-
- layout.setInspectorCollapsed(c => !c)} - inspectorWidth={layout.inspectorWidth} - onInspectorResize={layout.handleInspectorResize} - inspectorEntry={activeTab.entry} - inspectorContent={activeTab.content} - gitHistory={gitHistory} - onContentChange={handleContentChange} - onSave={handleSave} - vaultPath={vaultPath} - /> -
-
- setToastMessage(null)} /> -
- ) -} diff --git a/src/hooks/useLayoutPanels.ts b/src/hooks/useLayoutPanels.ts index 92f1e042..34a31e65 100644 --- a/src/hooks/useLayoutPanels.ts +++ b/src/hooks/useLayoutPanels.ts @@ -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))), []) diff --git a/src/hooks/useViewMode.ts b/src/hooks/useViewMode.ts index 86218066..d8a7ad1e 100644 --- a/src/hooks/useViewMode.ts +++ b/src/hooks/useViewMode.ts @@ -18,8 +18,8 @@ function loadViewMode(): ViewMode { return 'all' } -export function useViewMode() { - const [viewMode, setViewModeState] = useState(loadViewMode) +export function useViewMode(initialOverride?: ViewMode) { + const [viewMode, setViewModeState] = useState(initialOverride ?? loadViewMode) // Re-sync when vault config becomes available useEffect(() => { diff --git a/src/main.tsx b/src/main.tsx index 75867043..a8d69661 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - + , )