diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6b491f..babd5965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Added +- Expand or collapse every sidebar folder with one click. Thanks [@n00ki](https://github.com/n00ki) for the suggestion! [#272](https://github.com/bholmesdev/hubble.md/pull/272) + ### Changed ### Fixed diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index f7164ec5..56c68052 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -34,6 +34,7 @@ import MingcuteCopy2Line from "~icons/mingcute/copy-2-line"; import MingcuteDeleteLine from "~icons/mingcute/delete-line"; import MingcuteEditLine from "~icons/mingcute/edit-line"; import MingcuteExternalLinkLine from "~icons/mingcute/external-link-line"; +import MingcuteFolderLine from "~icons/mingcute/folder-line"; import MingcuteFolderOpenLine from "~icons/mingcute/folder-open-line"; import MingcuteMore2Line from "~icons/mingcute/more-2-line"; import MingcuteNewFolderLine from "~icons/mingcute/new-folder-line"; @@ -476,7 +477,16 @@ export function Sidebar({ const highlightPath = pendingPath ?? currentPath; const uncompactFolderId = deleteOnCancel?.kind === "folder" ? deleteOnCancel.path : null; - const { collapseFolder, expandFolder, rows, toggleFolder } = useSidebarTree({ + const { + collapseAllFolders, + collapseFolder, + expandAllFolders, + expandFolder, + hasExpandedFolders, + hasFolders, + rows, + toggleFolder, + } = useSidebarTree({ files, folders, getDisplayPath, @@ -1271,6 +1281,9 @@ export function Sidebar({ ); const sortLabel = sortMode === "recent" ? "Recent" : "Name"; + const folderActionLabel = hasExpandedFolders + ? "Collapse all folders" + : "Expand all folders"; return ( @@ -1302,6 +1315,23 @@ export function Sidebar({ )} + {hasFolders && ( + + )} { diff --git a/packages/ui/src/components/useSidebarTree.test.ts b/packages/ui/src/components/useSidebarTree.test.ts index ea368a54..c72247a4 100644 --- a/packages/ui/src/components/useSidebarTree.test.ts +++ b/packages/ui/src/components/useSidebarTree.test.ts @@ -249,6 +249,48 @@ describe("useSidebarTree", () => { ); expect(folderRow(harness.current.rows, "gamma/delta/").expanded).toBe(true); }); + + it("expands and collapses every folder", () => { + const harness = renderTree({ + files: nestedFiles, + highlightPath: null, + storageScope: "workspace", + }); + + expect(harness.current.hasFolders).toBe(true); + expect(harness.current.hasExpandedFolders).toBe(false); + + act(() => harness.current.expandAllFolders()); + + expect(harness.current.hasExpandedFolders).toBe(true); + expect(filePaths(harness.current.rows)).toEqual([ + "/workspace/alpha/beta/one.md", + "/workspace/gamma/delta/two.md", + ]); + expect( + JSON.parse( + localStorage.getItem("hubble-sidebar-expanded-folders:workspace") ?? + "[]", + ), + ).toEqual(["alpha/", "alpha/beta/", "gamma/", "gamma/delta/"]); + + act(() => harness.current.collapseAllFolders()); + + expect(harness.current.hasExpandedFolders).toBe(false); + expect(filePaths(harness.current.rows)).toEqual([]); + expect( + localStorage.getItem("hubble-sidebar-expanded-folders:workspace"), + ).toBe("[]"); + }); + + it("reports when the tree has no folders", () => { + const harness = renderTree({ + files: [{ path: "/workspace/note.md" }], + }); + + expect(harness.current.hasFolders).toBe(false); + expect(harness.current.hasExpandedFolders).toBe(false); + }); }); const nestedFiles: SidebarFile[] = [ diff --git a/packages/ui/src/components/useSidebarTree.ts b/packages/ui/src/components/useSidebarTree.ts index b639d269..ff2cae00 100644 --- a/packages/ui/src/components/useSidebarTree.ts +++ b/packages/ui/src/components/useSidebarTree.ts @@ -106,6 +106,11 @@ export function useSidebarTree({ expandedFolders, uncompactFolderId, }); + const folderIds = getFolderIds(tree); + const hasFolders = folderIds.size > 0; + const hasExpandedFolders = rows.some( + (row) => row.kind === "folder" && row.expanded, + ); useEffect(() => { if (!highlightPath || revealedPathRef.current === highlightPath) return; @@ -147,10 +152,36 @@ export function useSidebarTree({ const collapseFolder = (id: string) => setExpanded(id, false); const toggleFolder = (id: string) => setExpanded(id, !expandedFolders.has(id)); + const expandAllFolders = () => { + setExpandedState({ key: storageKey, folders: folderIds }); + }; + const collapseAllFolders = () => { + setExpandedState({ key: storageKey, folders: new Set() }); + }; - return { collapseFolder, expandFolder, rows, toggleFolder }; + return { + collapseAllFolders, + collapseFolder, + expandAllFolders, + expandFolder, + hasExpandedFolders, + hasFolders, + rows, + toggleFolder, + }; } +function getFolderIds(root: FolderNode): Set { + const ids = new Set(); + const visit = (folder: FolderNode) => { + for (const child of folder.folders.values()) { + ids.add(child.id); + visit(child); + } + }; + visit(root); + return ids; +} function makeFolder(id: string, name: string): FolderNode { return { id,