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:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
src/App.tsx
15
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' ? (
|
||||
<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
169
src/NoteWindow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
77
src/components/note-list/noteListUtils.test.ts
Normal file
77
src/components/note-list/noteListUtils.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
23
src/utils/openNoteWindow.ts
Normal file
23
src/utils/openNoteWindow.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
73
src/utils/windowMode.test.ts
Normal file
73
src/utils/windowMode.test.ts
Normal 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
24
src/utils/windowMode.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user