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
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])