feat: open note in new window (Cmd+Shift+Click / Cmd+Shift+O)

Add multi-window support: notes can be opened in dedicated secondary
Tauri windows with editor-only layout (no sidebar, no note list).

Triggers: Cmd+Shift+Click on notes, Cmd+K → "Open in New Window",
Cmd+Shift+O shortcut, Note → "Open in New Window" menu bar item.

Secondary windows have their own auto-save, theme, and wikilink
navigation. Closing a secondary window does not affect the main window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-19 08:47:25 +01:00
parent 5df4a7a3ad
commit 7c16ebd065
18 changed files with 467 additions and 9 deletions

View File

@@ -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:

View File

@@ -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",

View File

@@ -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,

View File

@@ -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' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />

169
src/NoteWindow.tsx Normal file
View File

@@ -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<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
// 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<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}
onSwitchTab={() => {}}
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}
/>
</div>
</div>
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
</div>
)
}

View File

@@ -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<VaultEntry>) => 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])

View File

@@ -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> = {}): 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()
})
})

View File

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

View File

@@ -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({

View File

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

View File

@@ -20,6 +20,7 @@ interface KeyboardActions {
onToggleAIChat?: () => void
onToggleRawEditor?: () => void
onReopenClosedTab?: () => void
onOpenInNewWindow?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
@@ -65,7 +66,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
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])
}

View File

@@ -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,
])
}

View File

@@ -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<string | null>,
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()

View File

@@ -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<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
@@ -109,6 +111,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'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 {

View File

@@ -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(
<StrictMode>
<TooltipProvider>
<App />
<RootComponent />
</TooltipProvider>
</StrictMode>,
)

View File

@@ -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<void> {
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,
})
}

View File

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

24
src/utils/windowMode.ts Normal file
View File

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