diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe31f8..4f74384 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) - Added a button to view the changelog after an update. Revisit the changelog anytime from Help or Settings. [#163](https://github.com/bholmesdev/hubble.md/pull/163) ### Changed diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index 643ce48..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, @@ -39,6 +40,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,6 +86,11 @@ type RenameItem = }; type SidebarSelectableRow = Extract; +export type SidebarActionSelection = { + files: SidebarFile[]; + folders: string[]; + count: number; +}; export type SidebarSelectionState = { selectedKeys: Set; @@ -306,7 +313,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; @@ -543,6 +550,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 @@ -604,6 +626,61 @@ 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) && + keymatch(event.nativeEvent, "CmdOrCtrl+Backspace") + ) { + 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") { @@ -833,7 +910,7 @@ export function Sidebar({ {rows.length === 0 && emptyState} {rows.map((row, index) => { @@ -842,6 +919,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" && @@ -913,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 text-selected-foreground", !isActive && !isSelected && isFocused && "bg-accent", isActive && "bg-sidebar-accent text-sidebar-accent-foreground font-medium", @@ -964,6 +1050,7 @@ export function Sidebar({ ) return; event.preventDefault(); + if (!isSelected) replaceSelection(row); setOpenActionsPath( row.kind === "file" ? row.file.path : row.id, ); @@ -1051,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 via-25% to-selected" + : "bg-linear-to-r from-transparent from-0% via-accent via-25% to-accent", )} /> )} @@ -1094,6 +1183,10 @@ export function Sidebar({ : undefined } onDeleteFolder={onDeleteFolder} + selection={actionSelection} + onDeleteFiles={onDeleteFile} + onTogglePinnedFile={onTogglePinnedFile} + getDisplayPath={getDisplayPath} /> )} {canTogglePinnedFile && ( @@ -1134,6 +1227,9 @@ export function Sidebar({ } onTogglePinnedFile={onTogglePinnedFile} onDeleteFile={onDeleteFile} + selection={actionSelection} + onDeleteFolders={onDeleteFolder} + getDisplayPath={getDisplayPath} /> )} @@ -1525,6 +1621,46 @@ 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 }; +} + +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]; @@ -1749,6 +1885,7 @@ function FolderActionsMenu({ label, open, onOpenChange, + selection, onRevealFolder, revealLabel, onCreateFile, @@ -1756,11 +1893,15 @@ function FolderActionsMenu({ onCreateFolder, onRenameFolder, onDeleteFolder, + onDeleteFiles, + onTogglePinnedFile, + getDisplayPath, }: { id: string; label: string; open: boolean; onOpenChange: (open: boolean) => void; + selection: SidebarActionSelection; onRevealFolder?: (id: string) => void; revealLabel?: string; onCreateFile?: (id: string) => void; @@ -1768,7 +1909,26 @@ function FolderActionsMenu({ onCreateFolder?: (id: string) => void; 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 ( + + + + + ); + } return ( {onRevealFolder && ( @@ -1817,6 +1977,7 @@ function FolderActionsMenu({ } + shortcut={formatShortcut("CmdOrCtrl+Backspace")} onClick={() => { if (!window.confirm(`Delete ${label} and all its contents?`)) return; @@ -1835,24 +1996,46 @@ function FileActionsMenu({ label, open, onOpenChange, + selection, onRevealFile, onCopyFilePath, revealLabel, onRenameFile, onTogglePinnedFile, onDeleteFile, + onDeleteFolders, + getDisplayPath, }: { 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; + getDisplayPath: (path: string) => string; }) { + if (selection.count > 1) { + return ( + + + + + ); + } return ( {onRevealFile && ( @@ -1893,6 +2076,7 @@ function FileActionsMenu({ } + shortcut={formatShortcut("CmdOrCtrl+Backspace")} onClick={() => { if (!window.confirm(`Delete ${label}?`)) return; onDeleteFile(file.path); @@ -1905,6 +2089,70 @@ 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; +}) { + 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 ${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 {actionable.count} {actionable.count === 1 ? "item" : "items"} + + ); +} + function ActionsMenu({ label, open, 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", };