Merge pull request #22 from refactoringhq/task/click-opens-new-tab

feat: click opens note in current tab, Cmd+Click opens new tab
This commit is contained in:
Luca Rossi
2026-02-23 21:01:16 +01:00
committed by GitHub
7 changed files with 556 additions and 78 deletions

View File

@@ -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": []
}

View File

@@ -189,7 +189,7 @@ function App() {
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} onSelectNote={notes.handleSelectNote} onCreateNote={handleCreateNoteImmediate} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={handleCreateNoteImmediate} />
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
<div className="app__editor">

View File

@@ -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<string, VaultEntry>
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 */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />

View File

@@ -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(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('No notes found')).toBeInTheDocument()
})
it('renders all entries with All Notes filter', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
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(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('filters by Events (section group)', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('filters by section group type', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('shows entity pinned at top with grouped children', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
// 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(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
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(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={allContent} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={allContent} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
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(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
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(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
expect(noopReplace).toHaveBeenCalledWith(mockEntries[1])
expect(noopSelect).not.toHaveBeenCalled()
})
})
describe('getSortComparator', () => {
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md',
@@ -402,21 +458,21 @@ describe('NoteList sort controls', () => {
it('shows sort button in note list header for flat view', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
})
it('shows sort dropdown per relationship subsection in entity view', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
})
it('opens sort menu on click and shows all options', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
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(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// 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(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('Trash')).toBeInTheDocument()
})
it('shows only trashed entries in trash view', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
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(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
const badges = screen.getAllByText('TRASHED')
expect(badges.length).toBeGreaterThanOrEqual(1)
})
it('shows 30-day warning banner when expired notes exist', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
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(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
})
})

View File

@@ -22,13 +22,14 @@ interface NoteListProps {
allContent: Record<string, string>
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<string, VaultEntry>
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 (
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={() => onSelectNote(entry)}>
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={(e: React.MouseEvent) => onClickNote(entry, e)}>
{/* eslint-disable-next-line react-hooks/static-components */}
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
@@ -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<string>; sortPrefs: Record<string, SortConfig>
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
return (
<div className="h-full overflow-y-auto">
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} showDate />
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onClickNote={onClickNote} showDate />
{groups.length === 0
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
: 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<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
const emptyText = isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
return (
<div className="h-full overflow-y-auto">
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />}
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
{searched.length === 0
? <EmptyMessage text={emptyText} />
@@ -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<Set<string>>(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) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
), [selectedNote?.path, onSelectNote, typeEntryMap])
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap])
return (
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
@@ -253,9 +268,9 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
<ListView typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
)}
</div>
</div>

View File

@@ -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', () => {

View File

@@ -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<React.SetStateAction<Tab[]>>,
) {
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])