diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36589df0..04ff744e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -164,6 +164,24 @@ flowchart TD Panels are separated by `ResizeHandle` components that support drag-to-resize. +## 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). + +**Triggers:** +- `Cmd+Shift+Click` on any note in the note list or sidebar +- `Cmd+K` → "Open in New Window" (command palette, requires active note) +- `Cmd+Shift+O` keyboard shortcut +- Note → "Open in New Window" menu bar item + +**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 +- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command) +- Secondary windows are sized 800×700 with overlay title bar +- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels + ## AI System Laputa has two AI interfaces with distinct architectures: diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 2e10b17a..36195980 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,11 +3,16 @@ "identifier": "default", "description": "enables the default permissions", "windows": [ - "main" + "main", + "note-*" ], "permissions": [ "core:default", + "core:window:allow-create", "core:window:allow-start-dragging", + "core:window:allow-close", + "core:window:allow-set-title", + "core:webview:allow-create-webview-window", "dialog:default", "updater:default", "process:default", diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 1e50fe06..a539a30c 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -41,6 +41,7 @@ const GO_INBOX: &str = "go-inbox"; const NOTE_ARCHIVE: &str = "note-archive"; const NOTE_TRASH: &str = "note-trash"; const NOTE_EMPTY_TRASH: &str = "note-empty-trash"; +const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window"; const VAULT_OPEN: &str = "vault-open"; const VAULT_REMOVE: &str = "vault-remove"; @@ -87,6 +88,7 @@ const CUSTOM_IDS: &[&str] = &[ NOTE_ARCHIVE, NOTE_TRASH, NOTE_EMPTY_TRASH, + NOTE_OPEN_IN_NEW_WINDOW, VAULT_OPEN, VAULT_REMOVE, VAULT_RESTORE_GETTING_STARTED, @@ -110,6 +112,7 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[ EDIT_TOGGLE_RAW_EDITOR, EDIT_TOGGLE_DIFF, VIEW_TOGGLE_BACKLINKS, + NOTE_OPEN_IN_NEW_WINDOW, ]; /// IDs of menu items that depend on having uncommitted changes. @@ -302,6 +305,10 @@ fn build_note_menu(app: &App) -> MenuResult { let empty_trash = MenuItemBuilder::new("Empty Trash…") .id(NOTE_EMPTY_TRASH) .build(app)?; + let open_new_window = MenuItemBuilder::new("Open in New Window") + .id(NOTE_OPEN_IN_NEW_WINDOW) + .accelerator("CmdOrCtrl+Shift+O") + .build(app)?; let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor") .id(EDIT_TOGGLE_RAW_EDITOR) .accelerator("CmdOrCtrl+\\") @@ -319,6 +326,8 @@ fn build_note_menu(app: &App) -> MenuResult { .item(&trash_note) .item(&empty_trash) .separator() + .item(&open_new_window) + .separator() .item(&toggle_raw_editor) .item(&toggle_ai_chat) .item(&toggle_backlinks) @@ -488,6 +497,7 @@ mod tests { NOTE_ARCHIVE, NOTE_TRASH, NOTE_EMPTY_TRASH, + NOTE_OPEN_IN_NEW_WINDOW, VAULT_OPEN, VAULT_REMOVE, VAULT_RESTORE_GETTING_STARTED, diff --git a/src/App.tsx b/src/App.tsx index b127a349..35a73c7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ import type { SidebarSelection, VaultEntry, InboxPeriod } from './types' import type { NoteListItem } from './utils/ai-context' import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' import { openLocalFile } from './utils/url' +import { openNoteInNewWindow } from './utils/openNoteWindow' import { flushEditorContent } from './utils/autoSave' import './App.css' @@ -256,6 +257,17 @@ function App() { if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath) }, [notes.activeTabPath, handleRemoveNoteIcon]) + /** Open the active note in a new window (command palette / keyboard shortcut). */ + const handleOpenInNewWindow = useCallback(() => { + const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath) + if (activeTab) openNoteInNewWindow(activeTab.entry.path, resolvedPath, activeTab.entry.title) + }, [notes.tabs, notes.activeTabPath, resolvedPath]) + + /** Open a specific note entry in a new window (Cmd+Shift+Click). */ + const handleOpenEntryInNewWindow = useCallback((entry: VaultEntry) => { + openNoteInNewWindow(entry.path, resolvedPath, entry.title) + }, [resolvedPath]) + const { triggerIncrementalIndex } = indexing const onAfterSave = useCallback(() => { vault.loadModifiedFiles() @@ -510,6 +522,7 @@ function App() { })(), noteListFilter, onSetNoteListFilter: setNoteListFilter, + onOpenInNewWindow: handleOpenInNewWindow, }) const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null @@ -557,7 +570,7 @@ function App() { {selection.kind === 'filter' && selection.filter === 'pulse' ? ( setViewMode('all')} /> ) : ( - + )} diff --git a/src/NoteWindow.tsx b/src/NoteWindow.tsx new file mode 100644 index 00000000..8adada9b --- /dev/null +++ b/src/NoteWindow.tsx @@ -0,0 +1,169 @@ +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 { useThemeManager } from './hooks/useThemeManager' +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 + + // Apply theme + const vaultPath = params?.vaultPath ?? '' + useThemeManager(vaultPath, entries) + + // 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 ( +
+
+
+ {}} + onCloseTab={handleCloseTab} + 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} + leftPanelsCollapsed={true} + vaultPath={vaultPath} + /> +
+
+ setToastMessage(null)} /> +
+ ) +} diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 68946d1c..73d791e8 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -40,9 +40,10 @@ interface NoteListProps { onEmptyTrash?: () => void onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void updateEntry?: (path: string, patch: Partial) => void + onOpenInNewWindow?: (entry: VaultEntry) => void } -function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow }: NoteListProps) { const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus) const isSectionGroup = selection.kind === 'sectionGroup' @@ -77,8 +78,8 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => { - routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, multiSelect }) - }, [onReplaceActiveTab, onSelectNote, multiSelect]) + routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect }) + }, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect]) const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive]) const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash]) diff --git a/src/components/note-list/noteListUtils.test.ts b/src/components/note-list/noteListUtils.test.ts new file mode 100644 index 00000000..b67c6bd6 --- /dev/null +++ b/src/components/note-list/noteListUtils.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest' +import { routeNoteClick, type ClickActions } from './noteListUtils' +import type { VaultEntry } from '../../types' + +function makeEntry(path = '/test.md'): VaultEntry { + return { + path, filename: 'test.md', title: 'Test', isA: null, + aliases: [], belongsTo: [], relatedTo: [], status: null, + archived: false, trashed: false, trashedAt: null, + modifiedAt: null, createdAt: null, fileSize: 0, + snippet: '', wordCount: 0, relationships: {}, + icon: null, color: null, order: null, sidebarLabel: null, + template: null, sort: null, view: null, visible: null, + outgoingLinks: [], properties: {}, + } +} + +function makeActions(): ClickActions { + return { + onReplace: vi.fn(), + onSelect: vi.fn(), + onOpenInNewWindow: vi.fn(), + multiSelect: { + selectRange: vi.fn(), + clear: vi.fn(), + setAnchor: vi.fn(), + }, + } +} + +function makeMouseEvent(overrides: Partial = {}): React.MouseEvent { + return { metaKey: false, ctrlKey: false, shiftKey: false, ...overrides } as React.MouseEvent +} + +describe('routeNoteClick', () => { + it('plain click replaces active tab', () => { + const entry = makeEntry() + const actions = makeActions() + routeNoteClick(entry, makeMouseEvent(), actions) + expect(actions.onReplace).toHaveBeenCalledWith(entry) + expect(actions.multiSelect.clear).toHaveBeenCalled() + expect(actions.multiSelect.setAnchor).toHaveBeenCalledWith(entry.path) + }) + + it('Cmd+click opens as new tab', () => { + const entry = makeEntry() + const actions = makeActions() + routeNoteClick(entry, makeMouseEvent({ metaKey: true }), actions) + expect(actions.onSelect).toHaveBeenCalledWith(entry) + expect(actions.multiSelect.clear).toHaveBeenCalled() + }) + + it('Shift+click selects range', () => { + const entry = makeEntry() + const actions = makeActions() + routeNoteClick(entry, makeMouseEvent({ shiftKey: true }), actions) + expect(actions.multiSelect.selectRange).toHaveBeenCalledWith(entry.path) + }) + + it('Cmd+Shift+click opens in new window', () => { + const entry = makeEntry() + const actions = makeActions() + routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions) + expect(actions.onOpenInNewWindow).toHaveBeenCalledWith(entry) + expect(actions.onReplace).not.toHaveBeenCalled() + expect(actions.onSelect).not.toHaveBeenCalled() + }) + + it('Cmd+Shift+click is a no-op when handler is undefined', () => { + const entry = makeEntry() + const actions = makeActions() + actions.onOpenInNewWindow = undefined + routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions) + expect(actions.onReplace).not.toHaveBeenCalled() + expect(actions.onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/note-list/noteListUtils.ts b/src/components/note-list/noteListUtils.ts index 9e75fbee..0976c470 100644 --- a/src/components/note-list/noteListUtils.ts +++ b/src/components/note-list/noteListUtils.ts @@ -28,11 +28,13 @@ export function countExpiredTrash(entries: VaultEntry[]): number { export interface ClickActions { onReplace: (entry: VaultEntry) => void onSelect: (entry: VaultEntry) => void + onOpenInNewWindow?: (entry: VaultEntry) => void multiSelect: { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void } } export function routeNoteClick(entry: VaultEntry, e: React.MouseEvent, actions: ClickActions) { - if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) } + if ((e.metaKey || e.ctrlKey) && e.shiftKey) { actions.onOpenInNewWindow?.(entry) } + else if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) } else if (e.metaKey || e.ctrlKey) { actions.multiSelect.clear(); actions.onSelect(entry) } else { actions.multiSelect.clear(); actions.multiSelect.setAnchor(entry.path); actions.onReplace(entry) } } diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 15ceb2de..bd9342ba 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -77,6 +77,7 @@ interface AppCommandsConfig { activeNoteHasIcon?: boolean noteListFilter?: NoteListFilter onSetNoteListFilter?: (filter: NoteListFilter) => void + onOpenInNewWindow?: () => void } /** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */ @@ -124,6 +125,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onToggleAIChat: config.onToggleAIChat, onToggleRawEditor: config.onToggleRawEditor, onReopenClosedTab: config.onReopenClosedTab, + onOpenInNewWindow: config.onOpenInNewWindow, activeTabPathRef: config.activeTabPathRef, handleCloseTabRef: config.handleCloseTabRef, }) @@ -165,6 +167,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onRepairVault: config.onRepairVault, onEmptyTrash: config.onEmptyTrash, onReopenClosedTab: config.onReopenClosedTab, + onOpenInNewWindow: config.onOpenInNewWindow, activeTabPathRef: config.activeTabPathRef, handleCloseTabRef: config.handleCloseTabRef, activeTabPath: config.activeTabPath, @@ -229,6 +232,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { selection: config.selection, noteListFilter: config.noteListFilter, onSetNoteListFilter: config.onSetNoteListFilter, + onOpenInNewWindow: config.onOpenInNewWindow, }) useKeyboardNavigation({ diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts index 4c6dfa02..77ff54bc 100644 --- a/src/hooks/useAppKeyboard.test.ts +++ b/src/hooks/useAppKeyboard.test.ts @@ -217,4 +217,12 @@ describe('useAppKeyboard', () => { expect(onToggleAIChat).toHaveBeenCalled() }) }) + + it('Cmd+Shift+O triggers open in new window', () => { + const actions = makeActions() + const onOpenInNewWindow = vi.fn() + renderHook(() => useAppKeyboard({ ...actions, onOpenInNewWindow })) + fireKey('o', { metaKey: true, shiftKey: true }) + expect(onOpenInNewWindow).toHaveBeenCalled() + }) }) diff --git a/src/hooks/useAppKeyboard.ts b/src/hooks/useAppKeyboard.ts index 7ac891b4..2f650b67 100644 --- a/src/hooks/useAppKeyboard.ts +++ b/src/hooks/useAppKeyboard.ts @@ -20,6 +20,7 @@ interface KeyboardActions { onToggleAIChat?: () => void onToggleRawEditor?: () => void onReopenClosedTab?: () => void + onOpenInNewWindow?: () => void activeTabPathRef: React.MutableRefObject handleCloseTabRef: React.MutableRefObject<(path: string) => void> } @@ -65,7 +66,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record) export function useAppKeyboard({ onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, - onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, activeTabPathRef, handleCloseTabRef, + onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow, activeTabPathRef, handleCloseTabRef, }: KeyboardActions) { useEffect(() => { const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => { @@ -107,11 +108,17 @@ export function useAppKeyboard({ onReopenClosedTab?.() return } + // Cmd+Shift+O: open active note in new window + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'o' || e.key === 'O')) { + e.preventDefault() + onOpenInNewWindow?.() + return + } if (!handleViewModeKey(e, onSetViewMode)) { handleCmdKey(e, cmdKeyMap) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab]) + }, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow]) } diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 5a15e512..e7571263 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -30,6 +30,7 @@ interface CommandRegistryConfig { onRepairVault?: () => void onSetNoteIcon?: () => void onRemoveNoteIcon?: () => void + onOpenInNewWindow?: () => void onQuickOpen: () => void onCreateNote: () => void @@ -217,6 +218,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, + onOpenInNewWindow, selection, noteListFilter, onSetNoteListFilter, } = config @@ -274,6 +276,12 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon, execute: () => onRemoveNoteIcon?.(), }, + { + id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O', + keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'], + enabled: hasActiveNote, + execute: () => onOpenInNewWindow?.(), + }, // Git { id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush }, @@ -324,5 +332,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction onReindexVault, onReloadVault, onRepairVault, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, isSectionGroup, noteListFilter, onSetNoteListFilter, + onOpenInNewWindow, ]) } diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts index b44712c4..f29a43f7 100644 --- a/src/hooks/useMenuEvents.test.ts +++ b/src/hooks/useMenuEvents.test.ts @@ -37,6 +37,7 @@ function makeHandlers(): MenuEventHandlers { onReindexVault: vi.fn(), onReloadVault: vi.fn(), onReopenClosedTab: vi.fn(), + onOpenInNewWindow: vi.fn(), activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject, handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>, activeTabPath: '/vault/test.md', @@ -321,6 +322,13 @@ describe('dispatchMenuEvent', () => { expect(h.onReopenClosedTab).toHaveBeenCalled() }) + // Note: open in new window + it('note-open-in-new-window triggers open in new window', () => { + const h = makeHandlers() + dispatchMenuEvent('note-open-in-new-window', h) + expect(h.onOpenInNewWindow).toHaveBeenCalled() + }) + // Edge cases it('unknown event ID does nothing', () => { const h = makeHandlers() diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index 5eacff10..9b79a2ec 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -36,6 +36,7 @@ export interface MenuEventHandlers { onViewChanges?: () => void onInstallMcp?: () => void onReopenClosedTab?: () => void + onOpenInNewWindow?: () => void onReindexVault?: () => void onReloadVault?: () => void onRepairVault?: () => void @@ -86,6 +87,7 @@ type OptionalHandler = | 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault' | 'onEmptyTrash' | 'onReopenClosedTab' + | 'onOpenInNewWindow' const OPTIONAL_EVENT_MAP: Record = { 'view-go-back': 'onGoBack', @@ -109,6 +111,7 @@ const OPTIONAL_EVENT_MAP: Record = { 'vault-repair': 'onRepairVault', 'note-empty-trash': 'onEmptyTrash', 'file-reopen-closed-tab': 'onReopenClosedTab', + 'note-open-in-new-window': 'onOpenInNewWindow', } function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean { diff --git a/src/main.tsx b/src/main.tsx index a8d69661..75867043 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,8 @@ 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). @@ -12,10 +14,12 @@ 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( - + , ) diff --git a/src/utils/openNoteWindow.ts b/src/utils/openNoteWindow.ts new file mode 100644 index 00000000..8104d73a --- /dev/null +++ b/src/utils/openNoteWindow.ts @@ -0,0 +1,23 @@ +import { isTauri } from '../mock-tauri' + +/** + * Opens a note in a new Tauri window with a minimal editor-only layout. + * In browser mode (non-Tauri), this is a no-op. + */ +export async function openNoteInNewWindow(notePath: string, vaultPath: string, noteTitle: string): Promise { + if (!isTauri()) return + + const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') + const label = `note-${Date.now()}` + const url = `index.html?window=note&path=${encodeURIComponent(notePath)}&vault=${encodeURIComponent(vaultPath)}&title=${encodeURIComponent(noteTitle)}` + + new WebviewWindow(label, { + url, + title: noteTitle, + width: 800, + height: 700, + resizable: true, + titleBarStyle: 'Overlay', + hiddenTitle: true, + }) +} diff --git a/src/utils/windowMode.test.ts b/src/utils/windowMode.test.ts new file mode 100644 index 00000000..ec5c630b --- /dev/null +++ b/src/utils/windowMode.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { isNoteWindow, getNoteWindowParams } from './windowMode' + +describe('windowMode', () => { + let originalSearch: string + + beforeEach(() => { + originalSearch = window.location.search + }) + + afterEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: { ...window.location, search: originalSearch }, + }) + }) + + function setSearch(search: string) { + Object.defineProperty(window, 'location', { + writable: true, + value: { ...window.location, search }, + }) + } + + describe('isNoteWindow', () => { + it('returns false when no query params', () => { + setSearch('') + expect(isNoteWindow()).toBe(false) + }) + + it('returns true when window=note', () => { + setSearch('?window=note&path=/test.md&vault=/vault') + expect(isNoteWindow()).toBe(true) + }) + + it('returns false for other window values', () => { + setSearch('?window=main') + expect(isNoteWindow()).toBe(false) + }) + }) + + describe('getNoteWindowParams', () => { + it('returns null when not a note window', () => { + setSearch('') + expect(getNoteWindowParams()).toBeNull() + }) + + it('returns null when path is missing', () => { + setSearch('?window=note&vault=/vault') + expect(getNoteWindowParams()).toBeNull() + }) + + it('returns null when vault is missing', () => { + setSearch('?window=note&path=/test.md') + expect(getNoteWindowParams()).toBeNull() + }) + + it('returns params when all are present', () => { + setSearch('?window=note&path=%2Fvault%2Ftest.md&vault=%2Fvault&title=My%20Note') + expect(getNoteWindowParams()).toEqual({ + notePath: '/vault/test.md', + vaultPath: '/vault', + noteTitle: 'My Note', + }) + }) + + it('defaults title to Untitled', () => { + setSearch('?window=note&path=/test.md&vault=/vault') + const params = getNoteWindowParams() + expect(params?.noteTitle).toBe('Untitled') + }) + }) +}) diff --git a/src/utils/windowMode.ts b/src/utils/windowMode.ts new file mode 100644 index 00000000..bc4cef79 --- /dev/null +++ b/src/utils/windowMode.ts @@ -0,0 +1,24 @@ +/** + * Detects whether the current window is a secondary "note window" (opened via + * "Open in New Window") by inspecting URL query parameters. + */ + +export interface NoteWindowParams { + notePath: string + vaultPath: string + noteTitle: string +} + +export function isNoteWindow(): boolean { + return new URLSearchParams(window.location.search).get('window') === 'note' +} + +export function getNoteWindowParams(): NoteWindowParams | null { + const params = new URLSearchParams(window.location.search) + if (params.get('window') !== 'note') return null + const notePath = params.get('path') + const vaultPath = params.get('vault') + const noteTitle = params.get('title') ?? 'Untitled' + if (!notePath || !vaultPath) return null + return { notePath, vaultPath, noteTitle } +}