feat: add multi-select notes with bulk archive and trash actions (#125)

* design: add multi-select notelist wireframes

Three frames: selected notes with bulk action bar, Shift+click range
select, and empty selection / hover states.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add multi-select notes with bulk archive and trash actions

Cmd/Ctrl+Click toggles individual note selection, Shift+Click selects
a range, Escape clears. A bulk action bar appears at the bottom with
Archive and Trash buttons. Includes useMultiSelect hook, BulkActionBar
component, Rust batch_archive_notes/batch_trash_notes commands, and
9 new tests covering the multi-select behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: trigger GitHub Actions run

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Luca Rossi
2026-02-27 15:14:21 +01:00
committed by GitHub
parent 6602763805
commit f3629e24d5
9 changed files with 757 additions and 21 deletions

View File

@@ -0,0 +1,422 @@
{
"children": [
{
"type": "frame",
"id": "ms_selected",
"name": "Multi-select — 2 notes selected (bulk action bar visible)",
"width": 340,
"height": "fit_content(600)",
"x": 0,
"y": 0,
"fill": "#FFFFFF",
"layout": "vertical",
"theme": { "Mode": "Light" },
"children": [
{
"type": "frame",
"id": "ms_hdr1",
"name": "header",
"width": "fill_container",
"height": 52,
"padding": [0, 16],
"alignItems": "center",
"justifyContent": "space_between",
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_h1t", "content": "Notes", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600" },
{ "type": "frame", "id": "ms_h1icons", "gap": 12, "alignItems": "center", "children": [
{ "type": "text", "id": "ms_h1s", "content": "\uD83D\uDD0D", "fontSize": 14 },
{ "type": "text", "id": "ms_h1p", "content": "+", "fontSize": 16, "fill": "#9B9A97" }
]}
]
},
{
"type": "frame",
"id": "ms_item1",
"name": "item-selected-1",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"fill": "#EBF5FF",
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{
"type": "frame",
"id": "ms_i1row",
"width": "fill_container",
"alignItems": "center",
"gap": 8,
"children": [
{ "type": "frame", "id": "ms_i1chk", "width": 16, "height": 16, "cornerRadius": 3, "fill": "#2383E2", "stroke": { "fill": "#2383E2", "thickness": 1 } },
{ "type": "text", "id": "ms_i1t", "content": "Build Laputa App", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" }
]
},
{ "type": "text", "id": "ms_i1s", "content": "Build a personal knowledge management app.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_item2",
"name": "item-normal",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{
"type": "frame",
"id": "ms_i2row",
"width": "fill_container",
"alignItems": "center",
"gap": 8,
"children": [
{ "type": "frame", "id": "ms_i2chk", "width": 16, "height": 16, "cornerRadius": 3, "stroke": { "fill": "#D4D4D4", "thickness": 1 } },
{ "type": "text", "id": "ms_i2t", "content": "Facebook Ads Strategy", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" }
]
},
{ "type": "text", "id": "ms_i2s", "content": "Lookalike audiences convert 3x better.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_item3",
"name": "item-selected-2",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"fill": "#EBF5FF",
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{
"type": "frame",
"id": "ms_i3row",
"width": "fill_container",
"alignItems": "center",
"gap": 8,
"children": [
{ "type": "frame", "id": "ms_i3chk", "width": 16, "height": 16, "cornerRadius": 3, "fill": "#2383E2", "stroke": { "fill": "#2383E2", "thickness": 1 } },
{ "type": "text", "id": "ms_i3t", "content": "Matteo Cellini", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" }
]
},
{ "type": "text", "id": "ms_i3s", "content": "Sponsorship manager.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_item4",
"name": "item-normal-2",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_i4t", "content": "Kickoff Meeting", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
{ "type": "text", "id": "ms_i4s", "content": "Project kickoff meeting notes.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{ "type": "frame", "id": "ms_spacer1", "width": "fill_container", "height": 120 },
{
"type": "frame",
"id": "ms_bulkbar",
"name": "bulk-action-bar",
"width": "fill_container",
"height": 48,
"padding": [0, 16],
"fill": "#37352F",
"alignItems": "center",
"justifyContent": "space_between",
"children": [
{ "type": "text", "id": "ms_bcount", "content": "2 selected", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
{
"type": "frame",
"id": "ms_bactions",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "ms_barchive",
"padding": [6, 12],
"cornerRadius": 6,
"fill": "#FFFFFF20",
"children": [
{ "type": "text", "id": "ms_bat1", "content": "Archive", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }
]
},
{
"type": "frame",
"id": "ms_btrash",
"padding": [6, 12],
"cornerRadius": 6,
"fill": "#E03E3E30",
"children": [
{ "type": "text", "id": "ms_bat2", "content": "Move to Trash", "fill": "#E03E3E", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }
]
},
{
"type": "frame",
"id": "ms_bclose",
"padding": [6, 8],
"children": [
{ "type": "text", "id": "ms_bx", "content": "\u2715", "fill": "#FFFFFF80", "fontSize": 14 }
]
}
]
}
]
}
]
},
{
"type": "frame",
"id": "ms_range",
"name": "Multi-select — range select Shift+click",
"width": 340,
"height": "fit_content(600)",
"x": 400,
"y": 0,
"fill": "#FFFFFF",
"layout": "vertical",
"theme": { "Mode": "Light" },
"children": [
{
"type": "frame",
"id": "ms_hdr2",
"name": "header",
"width": "fill_container",
"height": 52,
"padding": [0, 16],
"alignItems": "center",
"justifyContent": "space_between",
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_h2t", "content": "Notes", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600" },
{ "type": "frame", "id": "ms_h2icons", "gap": 12, "alignItems": "center", "children": [
{ "type": "text", "id": "ms_h2s", "content": "\uD83D\uDD0D", "fontSize": 14 },
{ "type": "text", "id": "ms_h2p", "content": "+", "fontSize": 16, "fill": "#9B9A97" }
]}
]
},
{
"type": "frame",
"id": "ms_r_annotation",
"name": "annotation",
"width": "fill_container",
"padding": [8, 16],
"fill": "#FFF8E1",
"children": [
{ "type": "text", "id": "ms_ra_t", "content": "Shift+Click: range from first-clicked to here", "fill": "#D9730D", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500" }
]
},
{
"type": "frame",
"id": "ms_r1",
"name": "range-anchor",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"fill": "#EBF5FF",
"stroke": { "align": "inside", "fill": "#2383E2", "thickness": { "left": 3, "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_r1t", "content": "Build Laputa App \u2190 anchor (first Cmd+click)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" },
{ "type": "text", "id": "ms_r1s", "content": "Build a personal knowledge management app.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_r2",
"name": "range-included",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"fill": "#EBF5FF",
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_r2t", "content": "Facebook Ads Strategy (auto-included in range)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
{ "type": "text", "id": "ms_r2s", "content": "Lookalike audiences convert 3x better.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_r3",
"name": "range-end",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"fill": "#EBF5FF",
"stroke": { "align": "inside", "fill": "#2383E2", "thickness": { "left": 3, "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_r3t", "content": "Matteo Cellini \u2190 Shift+click target", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" },
{ "type": "text", "id": "ms_r3s", "content": "Sponsorship manager.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_r4",
"name": "range-outside",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_r4t", "content": "Kickoff Meeting (outside range)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
{ "type": "text", "id": "ms_r4s", "content": "Project kickoff meeting notes.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{ "type": "frame", "id": "ms_rspacer", "width": "fill_container", "height": 80 },
{
"type": "frame",
"id": "ms_rbulkbar",
"name": "bulk-action-bar",
"width": "fill_container",
"height": 48,
"padding": [0, 16],
"fill": "#37352F",
"alignItems": "center",
"justifyContent": "space_between",
"children": [
{ "type": "text", "id": "ms_rbcount", "content": "3 selected", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
{
"type": "frame",
"id": "ms_rbactions",
"gap": 8,
"alignItems": "center",
"children": [
{ "type": "frame", "id": "ms_rbarchive", "padding": [6, 12], "cornerRadius": 6, "fill": "#FFFFFF20", "children": [
{ "type": "text", "id": "ms_rba1", "content": "Archive", "fill": "#FFFFFF", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }
]},
{ "type": "frame", "id": "ms_rbtrash", "padding": [6, 12], "cornerRadius": 6, "fill": "#E03E3E30", "children": [
{ "type": "text", "id": "ms_rba2", "content": "Move to Trash", "fill": "#E03E3E", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }
]},
{ "type": "frame", "id": "ms_rbclose", "padding": [6, 8], "children": [
{ "type": "text", "id": "ms_rbx", "content": "\u2715", "fill": "#FFFFFF80", "fontSize": 14 }
]}
]
}
]
}
]
},
{
"type": "frame",
"id": "ms_empty",
"name": "Multi-select — empty selection / hover state",
"width": 340,
"height": "fit_content(600)",
"x": 800,
"y": 0,
"fill": "#FFFFFF",
"layout": "vertical",
"theme": { "Mode": "Light" },
"children": [
{
"type": "frame",
"id": "ms_hdr3",
"name": "header",
"width": "fill_container",
"height": 52,
"padding": [0, 16],
"alignItems": "center",
"justifyContent": "space_between",
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_h3t", "content": "Notes", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600" },
{ "type": "frame", "id": "ms_h3icons", "gap": 12, "alignItems": "center", "children": [
{ "type": "text", "id": "ms_h3s", "content": "\uD83D\uDD0D", "fontSize": 14 },
{ "type": "text", "id": "ms_h3p", "content": "+", "fontSize": 16, "fill": "#9B9A97" }
]}
]
},
{
"type": "frame",
"id": "ms_e_annot",
"name": "annotation",
"width": "fill_container",
"padding": [8, 16],
"fill": "#F5F5F5",
"children": [
{ "type": "text", "id": "ms_ea_t", "content": "No selection \u2014 Cmd+Click to select, Shift+Click for range", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 11 }
]
},
{
"type": "frame",
"id": "ms_e1",
"name": "item-active",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 13, 14, 16],
"stroke": { "align": "inside", "fill": "#2383E2", "thickness": { "left": 3, "bottom": 1 } },
"fill": "#EBF5FF",
"children": [
{ "type": "text", "id": "ms_e1t", "content": "Build Laputa App (current note \u2014 active)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600" },
{ "type": "text", "id": "ms_e1s", "content": "Build a personal knowledge management app.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_e2",
"name": "item-hover",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"fill": "#F5F5F4",
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_e2t", "content": "Facebook Ads Strategy (hover state)", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
{ "type": "text", "id": "ms_e2s", "content": "Lookalike audiences convert 3x better.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_e3",
"name": "item-default",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_e3t", "content": "Matteo Cellini", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
{ "type": "text", "id": "ms_e3s", "content": "Sponsorship manager.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_e4",
"name": "item-default-2",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [14, 16],
"stroke": { "align": "inside", "fill": "#E5E5E5", "thickness": { "bottom": 1 } },
"children": [
{ "type": "text", "id": "ms_e4t", "content": "Kickoff Meeting", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500" },
{ "type": "text", "id": "ms_e4s", "content": "Project kickoff meeting notes.", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "ms_e_nobar",
"name": "no-bar-annotation",
"width": "fill_container",
"padding": [12, 16],
"fill": "#F5F5F5",
"children": [
{ "type": "text", "id": "ms_ena_t", "content": "\u2191 No bulk action bar when nothing is selected", "fill": "#9B9A97", "fontFamily": "Inter", "fontSize": 11 }
]
}
]
}
],
"variables": {}
}

View File

@@ -119,6 +119,28 @@ fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
frontmatter::update_frontmatter(path, "Archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
}
#[tauri::command]
fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut count = 0;
for path in &paths {
frontmatter::update_frontmatter(path, "Trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(path, "Trashed at", FrontmatterValue::String(now.clone()))?;
count += 1;
}
Ok(count)
}
#[tauri::command]
fn update_menu_state(app_handle: tauri::AppHandle, has_active_note: bool) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
@@ -263,6 +285,8 @@ pub fn run() {
save_image,
purge_trash,
migrate_is_a_to_type,
batch_archive_notes,
batch_trash_notes,
get_settings,
update_menu_state,
save_settings,

View File

@@ -41,6 +41,23 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
function useBulkActions(
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void> },
setToastMessage: (msg: string | null) => void,
) {
const handleBulkArchive = useCallback(async (paths: string[]) => {
for (const path of paths) await entryActions.handleArchiveNote(path)
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} archived`)
}, [entryActions, setToastMessage])
const handleBulkTrash = useCallback(async (paths: string[]) => {
for (const path of paths) await entryActions.handleTrashNote(path)
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} moved to trash`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash }
}
function useLayoutPanels() {
const [sidebarWidth, setSidebarWidth] = useState(250)
const [noteListWidth, setNoteListWidth] = useState(300)
@@ -206,6 +223,8 @@ function App() {
await notes.handleRenameNote(path, newTitle, vaultSwitcher.vaultPath, vault.replaceEntry).then(vault.loadModifiedFiles)
}, [notes, vaultSwitcher.vaultPath, vault, savePendingForPath])
const bulkActions = useBulkActions(entryActions, setToastMessage)
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
const commands = useAppCommands({
@@ -247,7 +266,7 @@ function App() {
{noteListVisible && (
<>
<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} getNoteStatus={vault.getNoteStatus} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>

View File

@@ -0,0 +1,61 @@
import { memo } from 'react'
import { Archive, Trash, X } from '@phosphor-icons/react'
interface BulkActionBarProps {
count: number
onArchive: () => void
onTrash: () => void
onClear: () => void
}
function BulkActionBarInner({ count, onArchive, onTrash, onClear }: BulkActionBarProps) {
return (
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 44,
padding: '0 12px',
background: 'var(--foreground)',
color: 'var(--background)',
}}
data-testid="bulk-action-bar"
>
<span style={{ fontSize: 13, fontWeight: 500 }}>
{count} selected
</span>
<div className="flex items-center gap-1">
<button
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
style={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 }}
onClick={onArchive}
title="Archive selected notes"
data-testid="bulk-archive-btn"
>
<Archive size={14} />
Archive
</button>
<button
className="flex items-center gap-1.5 border-none cursor-pointer"
style={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 }}
onClick={onTrash}
title="Move selected notes to trash"
data-testid="bulk-trash-btn"
>
<Trash size={14} />
Trash
</button>
<button
className="flex items-center border-none bg-transparent cursor-pointer"
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
onClick={onClear}
title="Clear selection"
data-testid="bulk-clear-btn"
>
<X size={14} />
</button>
</div>
</div>
)
}
export const BulkActionBar = memo(BulkActionBarInner)

View File

@@ -52,9 +52,10 @@ const NOTE_STATUS_DOT: Record<string, { color: string; testId: string; title: st
modified: { color: 'var(--accent-orange)', testId: 'modified-indicator', title: 'Modified (uncommitted)' },
}
export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap, onClickNote }: {
export function NoteItem({ entry, isSelected, isMultiSelected = false, noteStatus = 'clean', typeEntryMap, onClickNote }: {
entry: VaultEntry
isSelected: boolean
isMultiSelected?: boolean
noteStatus?: NoteStatus
typeEntryMap: Record<string, VaultEntry>
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
@@ -68,14 +69,16 @@ export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap
<div
className={cn(
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
isSelected && "border-l-[3px]",
!isSelected && "hover:bg-muted"
isSelected && !isMultiSelected && "border-l-[3px]",
!isSelected && !isMultiSelected && "hover:bg-muted"
)}
style={{
padding: isSelected ? '14px 16px 14px 13px' : '14px 16px',
...(isSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }),
padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px',
...(isMultiSelected && { backgroundColor: 'color-mix(in srgb, var(--accent-blue) 10%, transparent)' }),
...(isSelected && !isMultiSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }),
}}
onClick={(e: React.MouseEvent) => onClickNote(entry, e)}
data-testid={isMultiSelected ? 'multi-selected-item' : undefined}
>
{/* 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

@@ -288,28 +288,27 @@ describe('NoteList click behavior', () => {
expect(noopSelect).not.toHaveBeenCalled()
})
it('Cmd+Click calls onSelectNote (opens in new tab)', () => {
it('Cmd+Click toggles multi-select on a 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(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 calls onSelectNote (opens in new tab, Windows/Linux)', () => {
it('Ctrl+Click toggles multi-select (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 calls onSelectNote', () => {
it('Cmd+Click on entity pinned card toggles multi-select', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1], { metaKey: true })
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
expect(noopReplace).not.toHaveBeenCalled()
})
@@ -988,3 +987,93 @@ describe('NoteList — virtual list with large datasets', () => {
})
})
})
// --- Multi-select tests ---
describe('NoteList — multi-select', () => {
beforeEach(() => {
noopSelect.mockClear()
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 })
// 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)
const selected = screen.getAllByTestId('multi-selected-item')
expect(selected.length).toBeGreaterThanOrEqual(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 })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
expect(screen.getByText('2 selected')).toBeInTheDocument()
})
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.getByTestId('bulk-archive-btn'))
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].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.getByTestId('bulk-trash-btn'))
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].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 })
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('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()
})
})

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, memo } from 'react'
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
import { useDragRegion } from '../hooks/useDragRegion'
import { Virtuoso } from 'react-virtuoso'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
@@ -9,6 +9,8 @@ import {
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { NoteItem, getTypeIcon } from './NoteItem'
import { SortDropdown } from './SortDropdown'
import { BulkActionBar } from './BulkActionBar'
import { useMultiSelect } from '../hooks/useMultiSelect'
import {
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
getSortComparator,
@@ -27,6 +29,8 @@ interface NoteListProps {
onSelectNote: (entry: VaultEntry) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onCreateNote: () => void
onBulkArchive?: (paths: string[]) => void
onBulkTrash?: (paths: string[]) => void
}
function PinnedCard({ entry, typeEntryMap, onClickNote }: {
@@ -190,12 +194,16 @@ function countExpiredTrash(entries: VaultEntry[]): number {
// --- Click routing ---
type MultiSelectActions = { toggle: (path: string) => void; selectRange: (path: string) => void; clear: () => void }
function routeNoteClick(
entry: VaultEntry, e: React.MouseEvent,
onSelectNote: (entry: VaultEntry) => void,
onReplaceActiveTab: (entry: VaultEntry) => void,
multiSelect: MultiSelectActions,
) {
if (e.metaKey || e.ctrlKey) { onSelectNote(entry) } else { onReplaceActiveTab(entry) }
if (e.shiftKey) { multiSelect.selectRange(entry.path) }
else if (e.metaKey || e.ctrlKey) { multiSelect.toggle(entry.path) }
else { multiSelect.clear(); onReplaceActiveTab(entry) }
}
// --- Data hooks ---
@@ -244,7 +252,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
const defaultGetNoteStatus = (): NoteStatus => 'clean'
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, onSelectNote, onReplaceActiveTab, onCreateNote }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -280,13 +288,50 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const listDirection = listConfig.direction
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet })
const multiSelect = useMultiSelect(searched)
// Clear multi-select when sidebar selection changes
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, onSelectNote, onReplaceActiveTab)
}, [onSelectNote, onReplaceActiveTab])
routeNoteClick(entry, e, onReplaceActiveTab, multiSelect)
}, [onReplaceActiveTab, multiSelect])
// Keyboard: Escape to clear, Cmd+A to select all
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && multiSelect.isMultiSelecting) {
e.preventDefault()
multiSelect.clear()
}
if (e.key === 'a' && (e.metaKey || e.ctrlKey) && !isEntityView) {
const active = document.activeElement
const isInput = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || (active as HTMLElement)?.isContentEditable
if (!isInput) {
e.preventDefault()
multiSelect.selectAll()
}
}
}
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])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus])
<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} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths])
return (
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
@@ -316,6 +361,10 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
<ListView typeDocument={typeDocument} isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
)}
</div>
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onClear={multiSelect.clear} />
)}
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { useState, useCallback, useRef } from 'react'
import type { VaultEntry } from '../types'
export interface MultiSelectState {
selectedPaths: Set<string>
isMultiSelecting: boolean
toggle: (path: string) => void
selectRange: (toPath: string) => void
clear: () => void
selectAll: () => void
}
export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
const lastClickedRef = useRef<string | null>(null)
const toggle = useCallback((path: string) => {
setSelectedPaths((prev) => {
const next = new Set(prev)
if (next.has(path)) next.delete(path)
else next.add(path)
return next
})
lastClickedRef.current = path
}, [])
const selectRange = useCallback((toPath: string) => {
const fromPath = lastClickedRef.current
if (!fromPath) {
toggle(toPath)
return
}
const paths = visibleEntries.map((e) => e.path)
const fromIdx = paths.indexOf(fromPath)
const toIdx = paths.indexOf(toPath)
if (fromIdx === -1 || toIdx === -1) {
toggle(toPath)
return
}
const start = Math.min(fromIdx, toIdx)
const end = Math.max(fromIdx, toIdx)
setSelectedPaths((prev) => {
const next = new Set(prev)
for (let i = start; i <= end; i++) next.add(paths[i])
return next
})
lastClickedRef.current = toPath
}, [visibleEntries, toggle])
const clear = useCallback(() => {
setSelectedPaths(new Set())
lastClickedRef.current = null
}, [])
const selectAll = useCallback(() => {
setSelectedPaths(new Set(visibleEntries.map((e) => e.path)))
}, [visibleEntries])
return {
selectedPaths,
isMultiSelecting: selectedPaths.size > 0,
toggle,
selectRange,
clear,
selectAll,
}
}

View File

@@ -196,6 +196,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
purge_trash: () => [],
migrate_is_a_to_type: () => 0,
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,
github_device_flow_start: (): DeviceFlowStart => {
mockDeviceFlowPollCount = 0
return { device_code: 'mock_device_code_abc123', user_code: 'ABCD-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }