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:
387
design/click-opens-new-tab.pen
Normal file
387
design/click-opens-new-tab.pen
Normal 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": []
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user