From 9d5f42c6e8d963d0480f832b5228605077fbb50f Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Tue, 30 Jun 2026 17:03:10 -0400 Subject: [PATCH 1/6] Make sidebar menu selection-aware Co-Authored-By: Oz --- packages/ui/src/components/Sidebar.tsx | 102 +++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index 643ce48..2547fa7 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -84,6 +84,11 @@ type RenameItem = }; type SidebarSelectableRow = Extract; +type SidebarActionSelection = { + files: SidebarFile[]; + folders: string[]; + count: number; +}; export type SidebarSelectionState = { selectedKeys: Set; @@ -543,6 +548,21 @@ export function Sidebar({ }, [rows], ); + const replaceSelection = useCallback( + (row: SidebarRow) => { + const targetKey = sidebarRowKey(row); + setSelection((current) => + applySidebarSelection({ + anchorKey: current.anchorKey, + mode: "replace", + rows, + selectedKeys: current.selectedKeys, + targetKey, + }), + ); + }, + [rows], + ); const handleRowClick = useCallback( (row: SidebarSelectableRow, event: React.MouseEvent) => { const mode: SidebarSelectionMode = event.shiftKey @@ -842,6 +862,13 @@ export function Sidebar({ const isFocused = focusedIndex === index; const rowKey = sidebarRowKey(row); const isSelected = rowKey ? selectedKeys.has(rowKey) : false; + const actionSelection = + rowKey && isSelected + ? sidebarActionSelection(rows, selectedKeys) + : sidebarActionSelection( + rows, + rowKey ? new Set([rowKey]) : new Set(), + ); const isRenaming = (row.kind === "file" && renamingItem?.kind === "file" && @@ -964,6 +991,7 @@ export function Sidebar({ ) return; event.preventDefault(); + if (!isSelected) replaceSelection(row); setOpenActionsPath( row.kind === "file" ? row.file.path : row.id, ); @@ -1094,6 +1122,8 @@ export function Sidebar({ : undefined } onDeleteFolder={onDeleteFolder} + selection={actionSelection} + onDeleteFiles={onDeleteFile} /> )} {canTogglePinnedFile && ( @@ -1134,6 +1164,8 @@ export function Sidebar({ } onTogglePinnedFile={onTogglePinnedFile} onDeleteFile={onDeleteFile} + selection={actionSelection} + onDeleteFolders={onDeleteFolder} /> )} @@ -1525,6 +1557,21 @@ function rowSelectionGroup({ }; } +function sidebarActionSelection( + rows: SidebarRow[], + selectedKeys: Set, +): SidebarActionSelection { + const files: SidebarFile[] = []; + const folders: string[] = []; + for (const row of rows) { + const key = sidebarRowKey(row); + if (!key || !selectedKeys.has(key)) continue; + if (row.kind === "file") files.push(row.file); + else if (row.kind === "folder") folders.push(row.id); + } + return { files, folders, count: files.length + folders.length }; +} + function previousSidebarItem(rows: SidebarRow[], index: number) { for (let cursor = index - 1; cursor >= 0; cursor--) { const row = rows[cursor]; @@ -1749,6 +1796,7 @@ function FolderActionsMenu({ label, open, onOpenChange, + selection, onRevealFolder, revealLabel, onCreateFile, @@ -1756,11 +1804,13 @@ function FolderActionsMenu({ onCreateFolder, onRenameFolder, onDeleteFolder, + onDeleteFiles, }: { id: string; label: string; open: boolean; onOpenChange: (open: boolean) => void; + selection: SidebarActionSelection; onRevealFolder?: (id: string) => void; revealLabel?: string; onCreateFile?: (id: string) => void; @@ -1768,7 +1818,19 @@ function FolderActionsMenu({ onCreateFolder?: (id: string) => void; onRenameFolder?: (id: string, label: string) => void; onDeleteFolder?: (id: string) => void; + onDeleteFiles?: (path: string) => void; }) { + if (selection.count > 1) { + return ( + + + + ); + } return ( {onRevealFolder && ( @@ -1835,24 +1897,39 @@ function FileActionsMenu({ label, open, onOpenChange, + selection, onRevealFile, onCopyFilePath, revealLabel, onRenameFile, onTogglePinnedFile, onDeleteFile, + onDeleteFolders, }: { file: SidebarFile; label: string; open: boolean; onOpenChange: (open: boolean) => void; + selection: SidebarActionSelection; onRevealFile?: (path: string) => void; onCopyFilePath?: (path: string) => void; revealLabel?: string; onRenameFile?: (file: SidebarFile, label: string) => void; onTogglePinnedFile?: (path: string) => void; onDeleteFile?: (path: string) => void; + onDeleteFolders?: (id: string) => void; }) { + if (selection.count > 1) { + return ( + + + + ); + } return ( {onRevealFile && ( @@ -1905,6 +1982,31 @@ function FileActionsMenu({ ); } +function BulkDeleteAction({ + selection, + onDeleteFile, + onDeleteFolder, +}: { + selection: SidebarActionSelection; + onDeleteFile?: (path: string) => void; + onDeleteFolder?: (id: string) => void; +}) { + if (!onDeleteFile && !onDeleteFolder) return null; + return ( + } + onClick={() => { + if (!window.confirm(`Delete ${selection.count} items?`)) return; + for (const file of selection.files) onDeleteFile?.(file.path); + for (const folderId of selection.folders) onDeleteFolder?.(folderId); + }} + > + Delete {selection.count} items + + ); +} + function ActionsMenu({ label, open, From e38be95144a4f44c7bc365989d61f71062eafce2 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 13:01:11 -0400 Subject: [PATCH 2/6] Add sidebar group actions --- packages/ui/src/components/Sidebar.tsx | 156 +++++++++++++++++- .../src/components/sidebarSelection.test.ts | 28 ++++ packages/ui/src/lib/shortcut.test.ts | 2 + packages/ui/src/lib/shortcut.ts | 2 + 4 files changed, 181 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index 2547fa7..fdca7ed 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -39,6 +39,7 @@ import MingcutePinLine from "~icons/mingcute/pin-line"; import MingcuteRightLine from "~icons/mingcute/right-line"; import MingcuteSortDescendingLine from "~icons/mingcute/sort-descending-line"; import { useResizeSeparator } from "../hooks/useResizeSeparator"; +import { isEditableEventTarget } from "../lib/dom"; import { dirname, fileNameFromPath, @@ -84,7 +85,7 @@ type RenameItem = }; type SidebarSelectableRow = Extract; -type SidebarActionSelection = { +export type SidebarActionSelection = { files: SidebarFile[]; folders: string[]; count: number; @@ -624,6 +625,62 @@ export function Sidebar({ navRef, activeIndex, }); + const handleDeleteSelection = useCallback( + (targetSelection: SidebarActionSelection) => { + const actionable = sidebarDeleteSelection( + targetSelection, + getDisplayPath, + Boolean(onDeleteFile), + Boolean(onDeleteFolder), + ); + if (actionable.count === 0) return; + if ( + !window.confirm( + `Delete ${actionable.count} ${actionable.count === 1 ? "item" : "items"}?`, + ) + ) + return; + for (const file of actionable.files) onDeleteFile?.(file.path); + for (const folderId of actionable.folders) onDeleteFolder?.(folderId); + }, + [getDisplayPath, onDeleteFile, onDeleteFolder], + ); + const handleTreeKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if ( + !isEditableEventTarget(event.target) && + (event.metaKey || event.ctrlKey) && + (event.key === "Backspace" || event.key === "Delete") + ) { + const focusedRow = focusedIndex === null ? null : rows[focusedIndex]; + const focusedKey = focusedRow ? sidebarRowKey(focusedRow) : null; + const activeKey = sidebarRowKey(rows[activeIndex]); + const keys = focusedKey + ? selectedKeys.has(focusedKey) + ? selectedKeys + : new Set([focusedKey]) + : selectedKeys.size > 0 + ? selectedKeys + : activeKey + ? new Set([activeKey]) + : selectedKeys; + if (keys.size > 0) { + event.preventDefault(); + handleDeleteSelection(sidebarActionSelection(rows, keys)); + return; + } + } + onKeyDown(event); + }, + [ + activeIndex, + focusedIndex, + handleDeleteSelection, + onKeyDown, + rows, + selectedKeys, + ], + ); useEffect(() => { const row = focusedIndex === null ? null : rows[focusedIndex]; if (!row || row.kind === "section") { @@ -853,7 +910,7 @@ export function Sidebar({ {rows.length === 0 && emptyState} {rows.map((row, index) => { @@ -1124,6 +1181,8 @@ export function Sidebar({ onDeleteFolder={onDeleteFolder} selection={actionSelection} onDeleteFiles={onDeleteFile} + onTogglePinnedFile={onTogglePinnedFile} + getDisplayPath={getDisplayPath} /> )} {canTogglePinnedFile && ( @@ -1166,6 +1225,7 @@ export function Sidebar({ onDeleteFile={onDeleteFile} selection={actionSelection} onDeleteFolders={onDeleteFolder} + getDisplayPath={getDisplayPath} /> )} @@ -1572,6 +1632,31 @@ function sidebarActionSelection( return { files, folders, count: files.length + folders.length }; } +export function sidebarDeleteSelection( + selection: SidebarActionSelection, + getDisplayPath: (path: string) => string, + canDeleteFiles: boolean, + canDeleteFolders: boolean, +): SidebarActionSelection { + // Deleting a folder already removes its descendants; issuing both operations can race. + const folders = canDeleteFolders + ? selection.folders.filter( + (folderId) => + !selection.folders.some( + (ancestorId) => + ancestorId !== folderId && folderId.startsWith(ancestorId), + ), + ) + : []; + const files = canDeleteFiles + ? selection.files.filter((file) => { + const displayPath = normalizeDisplayPath(getDisplayPath(file.path)); + return !folders.some((folderId) => displayPath.startsWith(folderId)); + }) + : []; + return { files, folders, count: files.length + folders.length }; +} + function previousSidebarItem(rows: SidebarRow[], index: number) { for (let cursor = index - 1; cursor >= 0; cursor--) { const row = rows[cursor]; @@ -1805,6 +1890,8 @@ function FolderActionsMenu({ onRenameFolder, onDeleteFolder, onDeleteFiles, + onTogglePinnedFile, + getDisplayPath, }: { id: string; label: string; @@ -1819,14 +1906,21 @@ function FolderActionsMenu({ onRenameFolder?: (id: string, label: string) => void; onDeleteFolder?: (id: string) => void; onDeleteFiles?: (path: string) => void; + onTogglePinnedFile?: (path: string) => void; + getDisplayPath: (path: string) => string; }) { if (selection.count > 1) { return ( + ); @@ -1879,6 +1973,7 @@ function FolderActionsMenu({ } + shortcut={formatShortcut("CmdOrCtrl+Backspace")} onClick={() => { if (!window.confirm(`Delete ${label} and all its contents?`)) return; @@ -1905,6 +2000,7 @@ function FileActionsMenu({ onTogglePinnedFile, onDeleteFile, onDeleteFolders, + getDisplayPath, }: { file: SidebarFile; label: string; @@ -1918,14 +2014,20 @@ function FileActionsMenu({ onTogglePinnedFile?: (path: string) => void; onDeleteFile?: (path: string) => void; onDeleteFolders?: (id: string) => void; + getDisplayPath: (path: string) => string; }) { if (selection.count > 1) { return ( + ); @@ -1970,6 +2072,7 @@ function FileActionsMenu({ } + shortcut={formatShortcut("CmdOrCtrl+Backspace")} onClick={() => { if (!window.confirm(`Delete ${label}?`)) return; onDeleteFile(file.path); @@ -1982,27 +2085,66 @@ function FileActionsMenu({ ); } +function BulkPinAction({ + selection, + onTogglePinnedFile, +}: { + selection: SidebarActionSelection; + onTogglePinnedFile?: (path: string) => void; +}) { + if (!onTogglePinnedFile || selection.files.length === 0) return null; + const pinning = selection.files.some((file) => !file.pinned); + const filesToToggle = pinning + ? selection.files.filter((file) => !file.pinned) + : selection.files; + const count = selection.files.length; + return ( + } + onClick={() => { + for (const file of filesToToggle) onTogglePinnedFile(file.path); + }} + > + {pinning ? "Pin" : "Unpin"} {count} {count === 1 ? "file" : "files"} + + ); +} + function BulkDeleteAction({ selection, onDeleteFile, onDeleteFolder, + getDisplayPath, }: { selection: SidebarActionSelection; onDeleteFile?: (path: string) => void; onDeleteFolder?: (id: string) => void; + getDisplayPath: (path: string) => string; }) { - if (!onDeleteFile && !onDeleteFolder) return null; + const actionable = sidebarDeleteSelection( + selection, + getDisplayPath, + Boolean(onDeleteFile), + Boolean(onDeleteFolder), + ); + if (actionable.count === 0) return null; return ( } + shortcut={formatShortcut("CmdOrCtrl+Backspace")} onClick={() => { - if (!window.confirm(`Delete ${selection.count} items?`)) return; - for (const file of selection.files) onDeleteFile?.(file.path); - for (const folderId of selection.folders) onDeleteFolder?.(folderId); + if ( + !window.confirm( + `Delete ${actionable.count} ${actionable.count === 1 ? "item" : "items"}?`, + ) + ) + return; + for (const file of actionable.files) onDeleteFile?.(file.path); + for (const folderId of actionable.folders) onDeleteFolder?.(folderId); }} > - Delete {selection.count} items + Delete {actionable.count} {actionable.count === 1 ? "item" : "items"} ); } diff --git a/packages/ui/src/components/sidebarSelection.test.ts b/packages/ui/src/components/sidebarSelection.test.ts index 69177d7..6cba910 100644 --- a/packages/ui/src/components/sidebarSelection.test.ts +++ b/packages/ui/src/components/sidebarSelection.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { applySidebarSelection, type SidebarSelectionState, + sidebarDeleteSelection, sidebarMoveCandidateFromRow, sidebarMoveItemsForDrag, sidebarRowKey, @@ -222,3 +223,30 @@ describe("sidebar selection helpers", () => { expect(items).toEqual([{ kind: "file", path: "/workspace/project/b.md" }]); }); }); + +describe("sidebarDeleteSelection", () => { + const selection = { + files: [{ path: "/workspace/project/b.md" }, { path: "/workspace/c.md" }], + folders: ["project/", "project/archive/"], + count: 4, + }; + + it("drops descendants covered by an actionable folder", () => { + expect( + sidebarDeleteSelection(selection, getDisplayPath, true, true), + ).toEqual({ + files: [{ path: "/workspace/c.md" }], + folders: ["project/"], + count: 2, + }); + }); + + it("counts only types with delete handlers", () => { + expect( + sidebarDeleteSelection(selection, getDisplayPath, true, false), + ).toEqual({ files: selection.files, folders: [], count: 2 }); + expect( + sidebarDeleteSelection(selection, getDisplayPath, false, true), + ).toEqual({ files: [], folders: ["project/"], count: 1 }); + }); +}); diff --git a/packages/ui/src/lib/shortcut.test.ts b/packages/ui/src/lib/shortcut.test.ts index 10d804d..a27d18b 100644 --- a/packages/ui/src/lib/shortcut.test.ts +++ b/packages/ui/src/lib/shortcut.test.ts @@ -15,6 +15,7 @@ describe("formatShortcut", () => { expect(formatShortcut("CmdOrCtrl+N")).toBe("⌘N"); expect(formatShortcut("CmdOrCtrl+Alt+R")).toBe("⌘⌥R"); expect(formatShortcut("CmdOrCtrl+Shift+C")).toBe("⌘⇧C"); + expect(formatShortcut("CmdOrCtrl+Backspace")).toBe("⌘⌫"); }); it("renders +-joined words on Windows/Linux", () => { @@ -22,6 +23,7 @@ describe("formatShortcut", () => { expect(formatShortcut("CmdOrCtrl+N")).toBe("Ctrl+N"); expect(formatShortcut("CmdOrCtrl+Alt+R")).toBe("Ctrl+Alt+R"); expect(formatShortcut("CmdOrCtrl+Shift+C")).toBe("Ctrl+Shift+C"); + expect(formatShortcut("CmdOrCtrl+Backspace")).toBe("Ctrl+Backspace"); }); it("uppercases bare letter keys and leaves other keys intact", () => { diff --git a/packages/ui/src/lib/shortcut.ts b/packages/ui/src/lib/shortcut.ts index 42b4440..708dbd3 100644 --- a/packages/ui/src/lib/shortcut.ts +++ b/packages/ui/src/lib/shortcut.ts @@ -16,6 +16,7 @@ const MAC_SYMBOLS: Record = { option: "⌥", opt: "⌥", shift: "⇧", + backspace: "⌫", enter: "⏎", return: "⏎", }; @@ -32,6 +33,7 @@ const OTHER_WORDS: Record = { option: "Alt", opt: "Alt", shift: "Shift", + backspace: "Backspace", enter: "Enter", return: "Enter", }; From 8f713568221084866b0f08087f9de50c9d681fe6 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 13:06:38 -0400 Subject: [PATCH 3/6] Fix dark sidebar selection contrast --- packages/ui/src/components/Sidebar.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index fdca7ed..b67e647 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -312,7 +312,7 @@ const sidebarActionIconClass = const sidebarRowContentClass = "flex min-w-0 flex-1 items-center gap-1 [padding-block:var(--row-pad-block)] [padding-inline-end:1.25rem] text-start text-[length:var(--font-size-sidebar)]"; const sidebarRowActionButtonClass = - "inline-flex size-5 shrink-0 items-center justify-center rounded-sm border border-transparent bg-transparent text-muted-foreground/70 opacity-0 outline-hidden transition-[opacity,color] hover:text-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/40 group-hover/sidebar-row:opacity-100 aria-expanded:text-foreground aria-expanded:opacity-100"; + "inline-flex size-5 shrink-0 items-center justify-center rounded-sm border border-transparent bg-transparent text-current opacity-0 outline-hidden transition-opacity group-hover/sidebar-row:opacity-100 aria-expanded:opacity-100 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/40"; const DEFAULT_SIDEBAR_WIDTH = 220; const MIN_SIDEBAR_WIDTH = 180; const MAX_SIDEBAR_WIDTH = 360; @@ -997,7 +997,9 @@ export function Sidebar({ data-selected={isSelected ? "true" : undefined} className={cn( "group/sidebar-row relative flex w-full items-center text-sidebar-foreground", - !isActive && isSelected && "bg-selected/60", + !isActive && + isSelected && + "bg-selected/60 text-selected-foreground", !isActive && !isSelected && isFocused && "bg-accent", isActive && "bg-sidebar-accent text-sidebar-accent-foreground font-medium", @@ -1136,7 +1138,9 @@ export function Sidebar({ "pointer-events-none absolute inset-y-0 end-0 w-16 rounded-e-[var(--radius-row)] opacity-0 transition-opacity group-hover/sidebar-row:opacity-100", isActive ? "bg-linear-to-r from-transparent from-0% via-sidebar-accent via-25% to-sidebar-accent" - : "bg-linear-to-r from-transparent from-0% via-accent via-25% to-accent", + : isSelected + ? "bg-linear-to-r from-transparent from-0% via-selected/60 via-25% to-selected/60" + : "bg-linear-to-r from-transparent from-0% via-accent via-25% to-accent", )} /> )} From 79d3852d5a29a7ca7faf79aebe0e9ece07899e25 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 13:08:25 -0400 Subject: [PATCH 4/6] Match sidebar selection fill --- packages/ui/src/components/Sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index b67e647..b9394ee 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -999,7 +999,7 @@ export function Sidebar({ "group/sidebar-row relative flex w-full items-center text-sidebar-foreground", !isActive && isSelected && - "bg-selected/60 text-selected-foreground", + "bg-selected text-selected-foreground", !isActive && !isSelected && isFocused && "bg-accent", isActive && "bg-sidebar-accent text-sidebar-accent-foreground font-medium", @@ -1139,7 +1139,7 @@ export function Sidebar({ isActive ? "bg-linear-to-r from-transparent from-0% via-sidebar-accent via-25% to-sidebar-accent" : isSelected - ? "bg-linear-to-r from-transparent from-0% via-selected/60 via-25% to-selected/60" + ? "bg-linear-to-r from-transparent from-0% via-selected via-25% to-selected" : "bg-linear-to-r from-transparent from-0% via-accent via-25% to-accent", )} /> From ebe274827af02b5c69b13e4629e69bb3d134cbcd Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 13:13:59 -0400 Subject: [PATCH 5/6] Use keymatch for sidebar delete --- packages/ui/src/components/Sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index b9394ee..65bbc43 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { useSensor, useSensors, } from "@dnd-kit/core"; +import { keymatch } from "keymatch"; import { type CSSProperties, forwardRef, @@ -649,8 +650,7 @@ export function Sidebar({ (event: React.KeyboardEvent) => { if ( !isEditableEventTarget(event.target) && - (event.metaKey || event.ctrlKey) && - (event.key === "Backspace" || event.key === "Delete") + keymatch(event.nativeEvent, "CmdOrCtrl+Backspace") ) { const focusedRow = focusedIndex === null ? null : rows[focusedIndex]; const focusedKey = focusedRow ? sidebarRowKey(focusedRow) : null; From 56c2325674ed6e3c52fbc80e8000ae1dfa8cfab9 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 11 Jul 2026 13:15:48 -0400 Subject: [PATCH 6/6] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96163cf..289f0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Added - Global search: press Cmd+P (or File → Go to File…) to find notes by name, by path, or by a phrase inside them. Results show a matching excerpt, and selecting one opens the note. Thanks [@zcuric](https://github.com/zcuric)! [#159](https://github.com/bholmesdev/hubble.md/pull/159) +- Pin, unpin, or trash multiple sidebar items at once, including with Cmd+Delete. [#129](https://github.com/bholmesdev/hubble.md/pull/129) ### Changed