From da6da48af6bdc3984169382813fc9996e7ceeccb Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:12:54 +0100 Subject: [PATCH 1/3] feat: click opens note in current tab, Cmd+Click opens new tab Regular click on a note in NoteList now replaces the current tab content instead of always creating a new tab. Cmd+Click (or Ctrl+Click) opens in a new tab. If the note is already open in any tab, clicking just switches to that tab regardless of modifier key. - NoteItem: simplified to accept single onClickNote callback - NoteList: routes click via metaKey/ctrlKey to onReplaceActiveTab or onSelectNote - useTabManagement: handleReplaceActiveTab checks all tabs before replacing - Extracted loadAndSetTab/isTabOpen/routeNoteClick helpers for code health Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 2 +- src/components/NoteItem.tsx | 6 +- src/components/NoteList.test.tsx | 116 +++++++++++++++++++++-------- src/components/NoteList.tsx | 43 +++++++---- src/hooks/useTabManagement.test.ts | 25 +++++++ src/hooks/useTabManagement.ts | 55 +++++++------- 6 files changed, 169 insertions(+), 78 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 620dfbc9..3ccf38e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -189,7 +189,7 @@ function App() {
- +
diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index 644290a5..d3856ac5 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -46,11 +46,11 @@ function TrashDateLine({ entry }: { entry: VaultEntry }) { ) } -export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: { +export function NoteItem({ entry, isSelected, typeEntryMap, onClickNote }: { entry: VaultEntry isSelected: boolean typeEntryMap: Record - onSelectNote: (entry: VaultEntry) => void + onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void }) { const te = typeEntryMap[entry.isA ?? ''] const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color) @@ -68,7 +68,7 @@ export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: { padding: isSelected ? '14px 16px 14px 13px' : '14px 16px', ...(isSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }), }} - onClick={() => onSelectNote(entry)} + onClick={(e: React.MouseEvent) => onClickNote(entry, e)} > {/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */} diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 6143e21e..7b29ddda 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -6,6 +6,7 @@ import type { VaultEntry, SidebarSelection } from '../types' const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' } const noopSelect = vi.fn() +const noopReplace = vi.fn() const mockEntries: VaultEntry[] = [ { @@ -132,38 +133,38 @@ const mockEntries: VaultEntry[] = [ describe('NoteList', () => { it('shows empty state when no entries', () => { - render() + render() expect(screen.getByText('No notes found')).toBeInTheDocument() }) it('renders all entries with All Notes filter', () => { - render() + render() expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() }) it('filters by People (section group)', () => { - render() + render() expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() }) it('filters by Events (section group)', () => { - render() + render() expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument() expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() }) it('filters by section group type', () => { - render() + render() expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument() }) it('shows entity pinned at top with grouped children', () => { render( - + ) // Entity title appears in header and pinned card expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1) @@ -178,7 +179,7 @@ describe('NoteList', () => { it('filters by topic (relatedTo references)', () => { render( - + ) // Build Laputa App has relatedTo: [[topic/software-development]] expect(screen.getByText('Build Laputa App')).toBeInTheDocument() @@ -186,7 +187,7 @@ describe('NoteList', () => { }) it('shows search input when search icon is clicked', () => { - render() + render() // Search is hidden by default expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument() // Click search icon to show it @@ -195,7 +196,7 @@ describe('NoteList', () => { }) it('filters by search query (case-insensitive substring)', () => { - render() + render() // Open search fireEvent.click(screen.getByTitle('Search notes')) const input = screen.getByPlaceholderText('Search notes...') @@ -210,21 +211,21 @@ describe('NoteList', () => { { ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' }, { ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' }, ] - render() + render() const titles = screen.getAllByText(/Oldest|Newest|Middle/) const titleTexts = titles.map((el) => el.textContent) expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest']) }) it('does not render type badge or status on note items', () => { - render() + render() // Type badges like "Project", "Note" etc. should not appear as separate badge elements // The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge expect(screen.queryByText('Active')).not.toBeInTheDocument() }) it('header shows search and plus icons instead of count badge', () => { - render() + render() expect(screen.getByTitle('Search notes')).toBeInTheDocument() expect(screen.getByTitle('Create new note')).toBeInTheDocument() }) @@ -234,7 +235,7 @@ describe('NoteList', () => { [mockEntries[2].path]: 'Met with [[project/26q1-laputa-app]] team.', } render( - + ) expect(screen.getByText('Backlinks')).toBeInTheDocument() expect(screen.getByText('Matteo Cellini')).toBeInTheDocument() @@ -242,7 +243,7 @@ describe('NoteList', () => { it('context view collapses and expands groups', () => { render( - + ) // Children group is expanded by default expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument() @@ -257,13 +258,68 @@ describe('NoteList', () => { it('context view shows prominent card with entity snippet', () => { render( - + ) // Snippet appears in the prominent card expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument() }) }) +describe('NoteList click behavior', () => { + beforeEach(() => { + noopSelect.mockClear() + noopReplace.mockClear() + }) + + it('regular click calls onReplaceActiveTab (opens in current tab)', () => { + render() + fireEvent.click(screen.getByText('Build Laputa App')) + expect(noopReplace).toHaveBeenCalledWith(mockEntries[0]) + expect(noopSelect).not.toHaveBeenCalled() + }) + + it('Cmd+Click calls onSelectNote (opens in new tab)', () => { + render() + fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true }) + expect(noopSelect).toHaveBeenCalledWith(mockEntries[0]) + expect(noopReplace).not.toHaveBeenCalled() + }) + + it('Ctrl+Click calls onSelectNote (opens in new tab, Windows/Linux)', () => { + render() + fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true }) + expect(noopSelect).toHaveBeenCalledWith(mockEntries[0]) + expect(noopReplace).not.toHaveBeenCalled() + }) + + it('Cmd+Click on entity pinned card calls onSelectNote', () => { + render( + + ) + fireEvent.click(screen.getByText('Build a personal knowledge management app.'), { metaKey: true }) + expect(noopSelect).toHaveBeenCalledWith(mockEntries[0]) + expect(noopReplace).not.toHaveBeenCalled() + }) + + it('regular click on entity pinned card calls onReplaceActiveTab', () => { + render( + + ) + fireEvent.click(screen.getByText('Build a personal knowledge management app.')) + expect(noopReplace).toHaveBeenCalledWith(mockEntries[0]) + expect(noopSelect).not.toHaveBeenCalled() + }) + + it('click on child note in entity view calls onReplaceActiveTab', () => { + render( + + ) + fireEvent.click(screen.getByText('Facebook Ads Strategy')) + expect(noopReplace).toHaveBeenCalledWith(mockEntries[1]) + expect(noopSelect).not.toHaveBeenCalled() + }) +}) + describe('getSortComparator', () => { const makeEntry = (overrides: Partial): VaultEntry => ({ path: '/test.md', @@ -402,21 +458,21 @@ describe('NoteList sort controls', () => { it('shows sort button in note list header for flat view', () => { render( - + ) expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument() }) it('shows sort dropdown per relationship subsection in entity view', () => { render( - + ) expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument() }) it('opens sort menu on click and shows all options', () => { render( - + ) fireEvent.click(screen.getByTestId('sort-button-__list__')) expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument() @@ -433,7 +489,7 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }), ] render( - + ) // Default sort: by modified (Zebra first) let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent) @@ -450,7 +506,7 @@ describe('NoteList sort controls', () => { it('closes sort menu after selecting an option', () => { render( - + ) fireEvent.click(screen.getByTestId('sort-button-__list__')) expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument() @@ -460,7 +516,7 @@ describe('NoteList sort controls', () => { it('shows direction arrows in sort dropdown menu', () => { render( - + ) fireEvent.click(screen.getByTestId('sort-button-__list__')) // Each option should have asc and desc direction buttons @@ -477,7 +533,7 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }), ] render( - + ) // Default sort: modified descending (Zebra first at 3000) let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent) @@ -498,7 +554,7 @@ describe('NoteList sort controls', () => { makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }), ] render( - + ) // Select title sort with desc direction fireEvent.click(screen.getByTestId('sort-button-__list__')) @@ -511,7 +567,7 @@ describe('NoteList sort controls', () => { it('shows direction icon on the sort button that reflects current direction', () => { render( - + ) // Default: modified desc → should have ArrowDown icon expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument() @@ -548,7 +604,7 @@ describe('NoteList sort controls', () => { const entries = [parent, child1, child2] render( - + ) // Default sort: by modified — Zebra Note (3000) before Alpha Note (1000) @@ -664,31 +720,31 @@ describe('NoteList — trash view', () => { const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' } it('shows "Trash" header when trash filter is active', () => { - render() + render() expect(screen.getByText('Trash')).toBeInTheDocument() }) it('shows only trashed entries in trash view', () => { - render() + render() expect(screen.getByText('Old Draft Notes')).toBeInTheDocument() expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument() expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() }) it('shows TRASHED badge on trashed entries', () => { - render() + render() const badges = screen.getAllByText('TRASHED') expect(badges.length).toBeGreaterThanOrEqual(1) }) it('shows 30-day warning banner when expired notes exist', () => { - render() + render() expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument() expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument() }) it('shows "Trash is empty" when no trashed entries', () => { - render() + render() expect(screen.getByText('Trash is empty')).toBeInTheDocument() }) }) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 0d8d633c..c3ccc55e 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -22,13 +22,14 @@ interface NoteListProps { allContent: Record modifiedFiles?: ModifiedFile[] onSelectNote: (entry: VaultEntry) => void + onReplaceActiveTab: (entry: VaultEntry) => void onCreateNote: () => void } -function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: { +function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: { entry: VaultEntry typeEntryMap: Record - onSelectNote: (entry: VaultEntry) => void + onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void showDate?: boolean }) { const te = typeEntryMap[entry.isA ?? ''] @@ -36,7 +37,7 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: { const bgColor = getTypeLightColor(entry.isA ?? '', te?.color) const Icon = getTypeIcon(entry.isA, te?.icon) return ( -
onSelectNote(entry)}> +
onClickNote(entry, e)}> {/* eslint-disable-next-line react-hooks/static-components */}
{entry.title}
@@ -112,16 +113,16 @@ function useTypeEntryMap(entries: VaultEntry[]) { // --- View sub-components --- -function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onSelectNote }: { +function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onClickNote }: { entity: VaultEntry; groups: RelationshipGroup[]; query: string collapsedGroups: Set; sortPrefs: Record onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void renderItem: (entry: VaultEntry) => React.ReactNode - typeEntryMap: Record; onSelectNote: (entry: VaultEntry) => void + typeEntryMap: Record; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void }) { return (
- + {groups.length === 0 ? : groups.map((group) => ( @@ -132,16 +133,16 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl ) } -function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onSelectNote }: { +function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onClickNote }: { typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number searched: VaultEntry[]; query: string renderItem: (entry: VaultEntry) => React.ReactNode - typeEntryMap: Record; onSelectNote: (entry: VaultEntry) => void + typeEntryMap: Record; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void }) { const emptyText = isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found') return (
- {typeDocument && } + {typeDocument && } {searched.length === 0 ? @@ -167,6 +168,16 @@ function countExpiredTrash(entries: VaultEntry[]): number { return entries.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length } +// --- Click routing --- + +function routeNoteClick( + entry: VaultEntry, e: React.MouseEvent, + onSelectNote: (entry: VaultEntry) => void, + onReplaceActiveTab: (entry: VaultEntry) => void, +) { + if (e.metaKey || e.ctrlKey) { onSelectNote(entry) } else { onReplaceActiveTab(entry) } +} + // --- Data hooks --- interface NoteListDataParams { @@ -205,7 +216,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list // --- Main component --- -function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onReplaceActiveTab, onCreateNote }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) @@ -226,9 +237,13 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF const listDirection = listConfig.direction const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedFiles }) + const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => { + routeNoteClick(entry, e, onSelectNote, onReplaceActiveTab) + }, [onSelectNote, onReplaceActiveTab]) + const renderItem = useCallback((entry: VaultEntry) => ( - - ), [selectedNote?.path, onSelectNote, typeEntryMap]) + + ), [selectedNote?.path, handleClickNote, typeEntryMap]) return (
@@ -253,9 +268,9 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
{isEntityView && selection.kind === 'entity' ? ( - + ) : ( - + )}
diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index 607776e2..749a2c79 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -270,6 +270,31 @@ describe('useTabManagement', () => { expect(result.current.tabs).toHaveLength(1) expect(result.current.activeTabPath).toBe('/vault/a.md') }) + + it('switches to existing tab instead of replacing when note is already open', async () => { + const { result } = renderHook(() => useTabManagement()) + + // Open two tabs: A (active) and B + await act(async () => { + await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' })) + }) + await act(async () => { + await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' })) + }) + + // Switch back to A + act(() => { result.current.handleSwitchTab('/vault/a.md') }) + + // Replace active tab with B — but B is already open, so it should just switch + await act(async () => { + await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' })) + }) + + // Should still have 2 tabs (not replace A), and B should be active + expect(result.current.tabs).toHaveLength(2) + expect(result.current.activeTabPath).toBe('/vault/b.md') + expect(result.current.tabs.map(t => t.entry.title)).toEqual(['A', 'B']) + }) }) describe('closeAllTabs', () => { diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index f204ed74..7d7a3498 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -76,6 +76,24 @@ function restoreOrder(prev: Tab[], savedOrder: string[]): Tab[] { return ordered } +function isTabOpen(tabs: Tab[], path: string): boolean { + return tabs.some((t) => t.entry.path === path) +} + +async function loadAndSetTab( + entry: VaultEntry, + updater: (prev: Tab[], content: string) => Tab[], + setTabs: React.Dispatch>, +) { + try { + const content = await loadNoteContent(entry.path) + setTabs((prev) => updater(prev, content)) + } catch (err) { + console.warn('Failed to load note content:', err) + setTabs((prev) => updater(prev, '')) + } +} + export type { Tab } export function useTabManagement() { @@ -88,54 +106,31 @@ export function useTabManagement() { const handleCloseTabRef = useRef<(path: string) => void>(() => {}) const handleSelectNote = useCallback(async (entry: VaultEntry) => { - if (tabsRef.current.some((t) => t.entry.path === entry.path)) { - setActiveTabPath(entry.path) - return - } - try { - const content = await loadNoteContent(entry.path) - setTabs((prev) => addTabIfAbsent(prev, entry, content)) - } catch (err) { - console.warn('Failed to load note content:', err) - setTabs((prev) => addTabIfAbsent(prev, entry, '')) - } + if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return } + await loadAndSetTab(entry, (prev, content) => addTabIfAbsent(prev, entry, content), setTabs) setActiveTabPath(entry.path) }, []) const handleCloseTab = useCallback((path: string) => { setTabs((prev) => { const next = prev.filter((t) => t.entry.path !== path) - if (path === activeTabPathRef.current) { - setActiveTabPath(resolveNextActiveTab(prev, path)) - } + if (path === activeTabPathRef.current) { setActiveTabPath(resolveNextActiveTab(prev, path)) } return next }) }, []) useEffect(() => { handleCloseTabRef.current = handleCloseTab }) - const handleSwitchTab = useCallback((path: string) => { - setActiveTabPath(path) - }, []) + const handleSwitchTab = useCallback((path: string) => { setActiveTabPath(path) }, []) const handleReorderTabs = useCallback((fromIndex: number, toIndex: number) => { - setTabs((prev) => { - const next = reorderArray(prev, fromIndex, toIndex) - saveTabOrder(next) - return next - }) + setTabs((prev) => { const next = reorderArray(prev, fromIndex, toIndex); saveTabOrder(next); return next }) }, []) const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { + if (isTabOpen(tabsRef.current, entry.path)) { setActiveTabPath(entry.path); return } const currentPath = activeTabPathRef.current if (!currentPath) { handleSelectNote(entry); return } - if (currentPath === entry.path) return - try { - const content = await loadNoteContent(entry.path) - setTabs((prev) => replaceTabEntry(prev, currentPath, entry, content)) - } catch (err) { - console.warn('Failed to load note content for replace:', err) - setTabs((prev) => replaceTabEntry(prev, currentPath, entry, '')) - } + await loadAndSetTab(entry, (prev, content) => replaceTabEntry(prev, currentPath, entry, content), setTabs) setActiveTabPath(entry.path) }, [handleSelectNote]) From d5b89de0b2a62b3f8fc4e6ed831e59b1c8a18625 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:14:23 +0100 Subject: [PATCH 2/3] design: click-opens-new-tab wireframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two frames showing click vs Cmd+Click behavior: 1. Click — Replace Current Tab (note replaces active tab content) 2. Cmd+Click — Open New Tab (new tab created, original preserved) Co-Authored-By: Claude Opus 4.6 --- design/click-opens-new-tab.pen | 387 +++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 design/click-opens-new-tab.pen diff --git a/design/click-opens-new-tab.pen b/design/click-opens-new-tab.pen new file mode 100644 index 00000000..4bc397ad --- /dev/null +++ b/design/click-opens-new-tab.pen @@ -0,0 +1,387 @@ +{ + "version": 1, + "children": [ + { + "type": "frame", + "id": "click-replace-tab", + "x": 0, + "y": 0, + "name": "Click — Replace Current Tab", + "clip": true, + "width": 900, + "height": 500, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "click-title", + "text": "Click on note = Replace current tab", + "fontSize": 20, + "fontWeight": 700, + "fill": "$--foreground", + "padding": { "top": 16, "left": 16, "right": 16, "bottom": 8 } + }, + { + "type": "text", + "id": "click-desc", + "text": "Regular click on a note in NoteList replaces the active tab's content.\nNo new tab is created — the current tab simply shows the clicked note.\nIf the note is already open in another tab, that tab becomes active instead.", + "fontSize": 14, + "fill": "$--muted-foreground", + "padding": { "top": 0, "left": 16, "right": 16, "bottom": 16 } + }, + { + "type": "frame", + "id": "click-diagram", + "name": "Click Flow Diagram", + "width": "fill_container", + "height": 300, + "fill": "$--card", + "layout": "horizontal", + "padding": { "top": 24, "left": 24, "right": 24, "bottom": 24 }, + "gap": 24, + "children": [ + { + "type": "frame", + "id": "click-before", + "name": "Before Click", + "width": 200, + "height": 250, + "fill": "$--muted", + "layout": "vertical", + "cornerRadius": 8, + "children": [ + { + "type": "text", + "id": "click-before-label", + "text": "BEFORE", + "fontSize": 10, + "fontWeight": 600, + "fill": "$--muted-foreground", + "padding": { "top": 8, "left": 12, "right": 12, "bottom": 4 } + }, + { + "type": "frame", + "id": "click-before-tab", + "name": "Tab Bar", + "width": "fill_container", + "height": 32, + "fill": "$--sidebar", + "children": [ + { + "type": "text", + "id": "click-before-tab-text", + "text": "Note A (active)", + "fontSize": 12, + "fill": "$--foreground", + "padding": { "top": 8, "left": 12 } + } + ] + }, + { + "type": "frame", + "id": "click-before-list", + "name": "Note List", + "width": "fill_container", + "height": 160, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "click-note-a", + "text": "Note A", + "fontSize": 12, + "fill": "$--foreground", + "padding": { "top": 8, "left": 12 } + }, + { + "type": "frame", + "id": "click-note-b-highlight", + "width": "fill_container", + "height": 32, + "fill": "$--accent-blue-light", + "stroke": { "align": "inside", "thickness": { "left": 3 }, "color": "$--accent-blue" }, + "children": [ + { + "type": "text", + "id": "click-note-b", + "text": "Note B <-- Click", + "fontSize": 12, + "fontWeight": 600, + "fill": "$--accent-blue", + "padding": { "top": 8, "left": 12 } + } + ] + }, + { + "type": "text", + "id": "click-note-c", + "text": "Note C", + "fontSize": 12, + "fill": "$--foreground", + "padding": { "top": 8, "left": 12 } + } + ] + } + ] + }, + { + "type": "text", + "id": "click-arrow", + "text": "-->", + "fontSize": 24, + "fill": "$--muted-foreground", + "padding": { "top": 100 } + }, + { + "type": "frame", + "id": "click-after", + "name": "After Click", + "width": 200, + "height": 250, + "fill": "$--muted", + "layout": "vertical", + "cornerRadius": 8, + "children": [ + { + "type": "text", + "id": "click-after-label", + "text": "AFTER", + "fontSize": 10, + "fontWeight": 600, + "fill": "$--accent-green", + "padding": { "top": 8, "left": 12, "right": 12, "bottom": 4 } + }, + { + "type": "frame", + "id": "click-after-tab", + "name": "Tab Bar — same tab, new content", + "width": "fill_container", + "height": 32, + "fill": "$--accent-blue-light", + "children": [ + { + "type": "text", + "id": "click-after-tab-text", + "text": "Note B (replaced A)", + "fontSize": 12, + "fontWeight": 600, + "fill": "$--accent-blue", + "padding": { "top": 8, "left": 12 } + } + ] + }, + { + "type": "text", + "id": "click-after-note", + "text": "Still 1 tab.\nTab content changed\nfrom A to B.", + "fontSize": 12, + "fill": "$--muted-foreground", + "padding": { "top": 16, "left": 12 } + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "cmd-click-new-tab", + "x": 950, + "y": 0, + "name": "Cmd+Click — Open New Tab", + "clip": true, + "width": 900, + "height": 500, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "cmd-title", + "text": "Cmd+Click on note = Open new tab", + "fontSize": 20, + "fontWeight": 700, + "fill": "$--foreground", + "padding": { "top": 16, "left": 16, "right": 16, "bottom": 8 } + }, + { + "type": "text", + "id": "cmd-desc", + "text": "Cmd+Click (macOS) or Ctrl+Click (Windows/Linux) opens the note in a new tab.\nIf the note is already open in any tab, that tab becomes active (no duplicates).\nThis matches the browser-standard pattern used by Bear, Obsidian, etc.", + "fontSize": 14, + "fill": "$--muted-foreground", + "padding": { "top": 0, "left": 16, "right": 16, "bottom": 16 } + }, + { + "type": "frame", + "id": "cmd-diagram", + "name": "Cmd+Click Flow Diagram", + "width": "fill_container", + "height": 300, + "fill": "$--card", + "layout": "horizontal", + "padding": { "top": 24, "left": 24, "right": 24, "bottom": 24 }, + "gap": 24, + "children": [ + { + "type": "frame", + "id": "cmd-before", + "name": "Before Cmd+Click", + "width": 200, + "height": 250, + "fill": "$--muted", + "layout": "vertical", + "cornerRadius": 8, + "children": [ + { + "type": "text", + "id": "cmd-before-label", + "text": "BEFORE", + "fontSize": 10, + "fontWeight": 600, + "fill": "$--muted-foreground", + "padding": { "top": 8, "left": 12, "right": 12, "bottom": 4 } + }, + { + "type": "frame", + "id": "cmd-before-tab", + "name": "Tab Bar — 1 tab", + "width": "fill_container", + "height": 32, + "fill": "$--sidebar", + "children": [ + { + "type": "text", + "id": "cmd-before-tab-text", + "text": "Note A (active)", + "fontSize": 12, + "fill": "$--foreground", + "padding": { "top": 8, "left": 12 } + } + ] + }, + { + "type": "frame", + "id": "cmd-before-list", + "name": "Note List", + "width": "fill_container", + "height": 160, + "fill": "$--background", + "layout": "vertical", + "children": [ + { + "type": "text", + "id": "cmd-note-a", + "text": "Note A", + "fontSize": 12, + "fill": "$--foreground", + "padding": { "top": 8, "left": 12 } + }, + { + "type": "frame", + "id": "cmd-note-b-highlight", + "width": "fill_container", + "height": 32, + "fill": "$--accent-purple-light", + "stroke": { "align": "inside", "thickness": { "left": 3 }, "color": "$--accent-purple" }, + "children": [ + { + "type": "text", + "id": "cmd-note-b", + "text": "Note B <-- Cmd+Click", + "fontSize": 12, + "fontWeight": 600, + "fill": "$--accent-purple", + "padding": { "top": 8, "left": 12 } + } + ] + }, + { + "type": "text", + "id": "cmd-note-c", + "text": "Note C", + "fontSize": 12, + "fill": "$--foreground", + "padding": { "top": 8, "left": 12 } + } + ] + } + ] + }, + { + "type": "text", + "id": "cmd-arrow", + "text": "-->", + "fontSize": 24, + "fill": "$--muted-foreground", + "padding": { "top": 100 } + }, + { + "type": "frame", + "id": "cmd-after", + "name": "After Cmd+Click", + "width": 200, + "height": 250, + "fill": "$--muted", + "layout": "vertical", + "cornerRadius": 8, + "children": [ + { + "type": "text", + "id": "cmd-after-label", + "text": "AFTER", + "fontSize": 10, + "fontWeight": 600, + "fill": "$--accent-green", + "padding": { "top": 8, "left": 12, "right": 12, "bottom": 4 } + }, + { + "type": "frame", + "id": "cmd-after-tabs", + "name": "Tab Bar — 2 tabs", + "width": "fill_container", + "height": 32, + "fill": "$--sidebar", + "layout": "horizontal", + "children": [ + { + "type": "text", + "id": "cmd-after-tab-a", + "text": "Note A", + "fontSize": 12, + "fill": "$--muted-foreground", + "padding": { "top": 8, "left": 12 } + }, + { + "type": "text", + "id": "cmd-after-tab-b", + "text": "Note B (new)", + "fontSize": 12, + "fontWeight": 600, + "fill": "$--accent-purple", + "padding": { "top": 8, "left": 12 } + } + ] + }, + { + "type": "text", + "id": "cmd-after-note", + "text": "Now 2 tabs.\nNew tab created for B.\nA stays open.", + "fontSize": 12, + "fill": "$--muted-foreground", + "padding": { "top": 16, "left": 12 } + } + ] + } + ] + } + ] + } + ], + "variables": {}, + "themes": {}, + "fonts": [] +} \ No newline at end of file From 288003a2074fe508e88baa4ab9d1f22d4e0eff03 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 20:48:37 +0100 Subject: [PATCH 3/3] fix: cargo fmt vault.rs formatting --- src-tauri/src/vault.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 32d36bb5..bab8c813 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -565,8 +565,7 @@ pub fn get_note_content(path: &str) -> Result { pub fn save_note_content(path: &str, content: &str) -> Result<(), String> { let file_path = Path::new(path); validate_save_path(file_path, path)?; - fs::write(file_path, content) - .map_err(|e| format!("Failed to save {}: {}", path, e)) + fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e)) } fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> { @@ -2061,7 +2060,9 @@ References: fn test_save_note_content_nonexistent_parent() { let result = save_note_content("/nonexistent/parent/dir/file.md", "content"); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Parent directory does not exist")); + assert!(result + .unwrap_err() + .contains("Parent directory does not exist")); } #[test] @@ -2093,7 +2094,8 @@ References: let original = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nOriginal body."; create_test_file(dir.path(), "note.md", original); - let updated = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nUpdated body with changes."; + let updated = + "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nUpdated body with changes."; save_note_content(file_path.to_str().unwrap(), updated).unwrap(); let saved = fs::read_to_string(&file_path).unwrap();