fix: multiselect uses only Shift+click; add Cmd+Del/Cmd+E bulk actions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -288,27 +288,27 @@ describe('NoteList click behavior', () => {
|
||||
expect(noopSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Click toggles multi-select on a note', () => {
|
||||
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()
|
||||
expect(screen.getByTestId('multi-selected-item')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Ctrl+Click toggles multi-select (Windows/Linux)', () => {
|
||||
it('Ctrl+Click calls onSelectNote (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()
|
||||
expect(screen.getByTestId('multi-selected-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+Click on entity pinned card toggles multi-select', () => {
|
||||
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()} />
|
||||
)
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1], { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -996,40 +996,10 @@ describe('NoteList — multi-select', () => {
|
||||
noopReplace.mockClear()
|
||||
})
|
||||
|
||||
it('Cmd+Click selects multiple notes', () => {
|
||||
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 })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
|
||||
const selected = screen.getAllByTestId('multi-selected-item')
|
||||
expect(selected).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('Cmd+Click toggles off an already-selected note', () => {
|
||||
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(screen.getAllByTestId('multi-selected-item')).toHaveLength(1)
|
||||
// Toggle off
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('regular click clears multi-select and opens note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// Multi-select two notes
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
|
||||
expect(screen.getAllByTestId('multi-selected-item')).toHaveLength(2)
|
||||
// Regular click clears selection and opens note
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'))
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[2])
|
||||
})
|
||||
|
||||
it('Shift+Click selects a range of notes', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// First Cmd+Click to set anchor
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
// Regular click to set anchor
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
// Shift+Click to select range
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'), { shiftKey: true })
|
||||
// Should select all notes between Build Laputa App and Matteo Cellini (inclusive)
|
||||
@@ -1037,10 +1007,34 @@ describe('NoteList — multi-select', () => {
|
||||
expect(selected.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('regular click clears multi-select and opens note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// Select range via Shift+click
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(1)
|
||||
// Regular click clears selection and opens note
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'))
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[2])
|
||||
})
|
||||
|
||||
it('Cmd+Click clears multi-select and opens in new tab', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// Select range via Shift+click
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(1)
|
||||
// Cmd+click clears selection and opens in new tab
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'), { metaKey: true })
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[2])
|
||||
})
|
||||
|
||||
it('shows bulk action bar with correct count', () => {
|
||||
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 })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1048,30 +1042,65 @@ describe('NoteList — multi-select', () => {
|
||||
it('bulk archive calls onBulkArchive and clears selection', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path])
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk trash calls onBulkTrash and clears selection', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path])
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clear button on bulk action bar clears selection', () => {
|
||||
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 })
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('bulk-clear-btn'))
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+E archives selected notes when multiselect is active', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'e', metaKey: true })
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+Backspace trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'Backspace', metaKey: true })
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+Delete trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.keyDown(window, { key: 'Delete', metaKey: true })
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('no bulk action bar when nothing is selected', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
|
||||
@@ -197,15 +197,16 @@ function countExpiredTrash(entries: VaultEntry[]): number {
|
||||
|
||||
// --- Click routing ---
|
||||
|
||||
type MultiSelectActions = { toggle: (path: string) => void; selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
|
||||
type MultiSelectActions = { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
|
||||
|
||||
function routeNoteClick(
|
||||
entry: VaultEntry, e: React.MouseEvent,
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void,
|
||||
onSelectNote: (entry: VaultEntry) => void,
|
||||
multiSelect: MultiSelectActions,
|
||||
) {
|
||||
if (e.shiftKey) { multiSelect.selectRange(entry.path) }
|
||||
else if (e.metaKey || e.ctrlKey) { multiSelect.toggle(entry.path) }
|
||||
else if (e.metaKey || e.ctrlKey) { multiSelect.clear(); onSelectNote(entry) }
|
||||
else { multiSelect.clear(); multiSelect.setAnchor(entry.path); onReplaceActiveTab(entry) }
|
||||
}
|
||||
|
||||
@@ -276,7 +277,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
|
||||
|
||||
const defaultGetNoteStatus = (): NoteStatus => 'clean'
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, sidebarCollapsed, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -314,10 +315,23 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
routeNoteClick(entry, e, onReplaceActiveTab, multiSelect)
|
||||
}, [onReplaceActiveTab, multiSelect])
|
||||
routeNoteClick(entry, e, onReplaceActiveTab, onSelectNote, multiSelect)
|
||||
}, [onReplaceActiveTab, onSelectNote, multiSelect])
|
||||
|
||||
// Keyboard: Escape to clear, Cmd+A to select all
|
||||
const handleBulkArchive = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkArchive?.(paths)
|
||||
}, [multiSelect, onBulkArchive])
|
||||
|
||||
const handleBulkTrash = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkTrash?.(paths)
|
||||
}, [multiSelect, onBulkTrash])
|
||||
|
||||
// Keyboard: Escape to clear, Cmd+A to select all, Cmd+E/Cmd+Delete for bulk actions
|
||||
// Uses capture phase so bulk shortcuts preempt the global single-note handler
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && multiSelect.isMultiSelecting) {
|
||||
@@ -332,22 +346,21 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
multiSelect.selectAll()
|
||||
}
|
||||
}
|
||||
if (multiSelect.isMultiSelecting && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.key === 'e') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleBulkArchive()
|
||||
} else if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleBulkTrash()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [multiSelect, isEntityView])
|
||||
|
||||
const handleBulkArchive = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkArchive?.(paths)
|
||||
}, [multiSelect, onBulkArchive])
|
||||
|
||||
const handleBulkTrash = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkTrash?.(paths)
|
||||
}, [multiSelect, onBulkTrash])
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [multiSelect, isEntityView, handleBulkArchive, handleBulkTrash])
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
|
||||
Reference in New Issue
Block a user