diff --git a/design/notelist-multiselect.pen b/design/notelist-multiselect.pen new file mode 100644 index 00000000..8109f8c9 --- /dev/null +++ b/design/notelist-multiselect.pen @@ -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": {} +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8f54e68b..09e1aa69 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -119,6 +119,28 @@ fn migrate_is_a_to_type(vault_path: String) -> Result { vault::migrate_is_a_to_type(&vault_path) } +#[tauri::command] +fn batch_archive_notes(paths: Vec) -> Result { + 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) -> Result { + 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, diff --git a/src/App.tsx b/src/App.tsx index 46f74f31..8ebb5498 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,23 @@ declare global { const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } +function useBulkActions( + entryActions: { handleArchiveNote: (path: string) => Promise; handleTrashNote: (path: string) => Promise }, + 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 && ( <>
- +
diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx new file mode 100644 index 00000000..406c1acb --- /dev/null +++ b/src/components/BulkActionBar.tsx @@ -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 ( +
+ + {count} selected + +
+ + + +
+
+ ) +} + +export const BulkActionBar = memo(BulkActionBarInner) diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index 42026e6d..aed4d14c 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -52,9 +52,10 @@ const NOTE_STATUS_DOT: Record onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void @@ -68,14 +69,16 @@ export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap
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 */} diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 22664f11..34bbc15c 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -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() 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() 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( ) - // 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() + 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() + 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() + // 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() + // 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() + 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() + 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() + 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() + 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() + expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 8b2e6321..089d9f05 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -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>(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) => ( - - ), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus]) + + ), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths]) return (
@@ -316,6 +361,10 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF )}
+ + {multiSelect.isMultiSelecting && ( + + )}
) } diff --git a/src/hooks/useMultiSelect.ts b/src/hooks/useMultiSelect.ts new file mode 100644 index 00000000..d091c46c --- /dev/null +++ b/src/hooks/useMultiSelect.ts @@ -0,0 +1,67 @@ +import { useState, useCallback, useRef } from 'react' +import type { VaultEntry } from '../types' + +export interface MultiSelectState { + selectedPaths: Set + isMultiSelecting: boolean + toggle: (path: string) => void + selectRange: (toPath: string) => void + clear: () => void + selectAll: () => void +} + +export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState { + const [selectedPaths, setSelectedPaths] = useState>(new Set()) + const lastClickedRef = useRef(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, + } +} diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index eeff84e0..bbeb795e 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -196,6 +196,8 @@ export const mockHandlers: Record 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 }