From 3c4d9aedc456a145161c7e7899f3a844e8df324b Mon Sep 17 00:00:00 2001 From: Noam Shemesh Date: Mon, 17 Aug 2026 11:44:05 +0300 Subject: [PATCH 1/3] Add sidebar expand and collapse all action --- packages/ui/src/components/Sidebar.tsx | 33 +++++++++++++++- .../ui/src/components/useSidebarTree.test.ts | 37 ++++++++++++++++++ packages/ui/src/components/useSidebarTree.ts | 39 ++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index f7164ec5..19e2ceee 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 MingcuteFoldVerticalLine from "~icons/mingcute/fold-vertical-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"; @@ -41,6 +42,7 @@ import MingcutePinFill from "~icons/mingcute/pin-fill"; import MingcutePinLine from "~icons/mingcute/pin-line"; import MingcuteRightLine from "~icons/mingcute/right-line"; import MingcuteSortDescendingLine from "~icons/mingcute/sort-descending-line"; +import MingcuteUnfoldVerticalLine from "~icons/mingcute/unfold-vertical-line"; import { useResizeSeparator } from "../hooks/useResizeSeparator"; import { isEditableEventTarget } from "../lib/dom"; import { @@ -476,7 +478,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, @@ -485,6 +496,9 @@ export function Sidebar({ storageScope, uncompactFolderId, }); + const allFoldersActionLabel = hasExpandedFolders + ? "Collapse all folders" + : "Expand all folders"; const [selection, setSelection] = useState({ selectedKeys: new Set(), anchorKey: null, @@ -1338,6 +1352,23 @@ export function Sidebar({ + {hasFolders ? ( + + ) : null} {tree} diff --git a/packages/ui/src/components/useSidebarTree.test.ts b/packages/ui/src/components/useSidebarTree.test.ts index ea368a54..6d092741 100644 --- a/packages/ui/src/components/useSidebarTree.test.ts +++ b/packages/ui/src/components/useSidebarTree.test.ts @@ -211,6 +211,43 @@ describe("flattenRows", () => { }); describe("useSidebarTree", () => { + it("expands and collapses every folder", () => { + const harness = renderTree({ + files: [ + { path: "/workspace/alpha/root.md", modifiedAt: 1 }, + { path: "/workspace/alpha/beta/one.md", modifiedAt: 2 }, + { path: "/workspace/gamma/delta/two.md", modifiedAt: 3 }, + ], + }); + + expect(harness.current.hasFolders).toBe(true); + expect(harness.current.hasExpandedFolders).toBe(false); + expect(filePaths(harness.current.rows)).toEqual([]); + + act(() => harness.current.expandAllFolders()); + + expect(harness.current.hasExpandedFolders).toBe(true); + expect(filePaths(harness.current.rows)).toEqual([ + "/workspace/alpha/beta/one.md", + "/workspace/alpha/root.md", + "/workspace/gamma/delta/two.md", + ]); + + act(() => harness.current.collapseAllFolders()); + + expect(harness.current.hasExpandedFolders).toBe(false); + expect(filePaths(harness.current.rows)).toEqual([]); + }); + + it("reports when there are no folders to toggle", () => { + const harness = renderTree({ + files: [{ path: "/workspace/one.md", modifiedAt: 1 }], + }); + + expect(harness.current.hasFolders).toBe(false); + expect(harness.current.hasExpandedFolders).toBe(false); + }); + it("keeps selected-file ancestors collapsed across unrelated rerenders", () => { const harness = renderTree({ files: nestedFiles, diff --git a/packages/ui/src/components/useSidebarTree.ts b/packages/ui/src/components/useSidebarTree.ts index b639d269..a87c5dba 100644 --- a/packages/ui/src/components/useSidebarTree.ts +++ b/packages/ui/src/components/useSidebarTree.ts @@ -106,6 +106,10 @@ export function useSidebarTree({ expandedFolders, uncompactFolderId, }); + const folderRowIds = getFolderRowIds(tree, uncompactFolderId); + const hasExpandedFolders = rows.some( + (row) => row.kind === "folder" && row.expanded, + ); useEffect(() => { if (!highlightPath || revealedPathRef.current === highlightPath) return; @@ -147,8 +151,23 @@ 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: new Set(folderRowIds) }); + }; + const collapseAllFolders = () => { + setExpandedState({ key: storageKey, folders: new Set() }); + }; - return { collapseFolder, expandFolder, rows, toggleFolder }; + return { + collapseAllFolders, + collapseFolder, + expandAllFolders, + expandFolder, + hasExpandedFolders, + hasFolders: folderRowIds.length > 0, + rows, + toggleFolder, + }; } function makeFolder(id: string, name: string): FolderNode { @@ -212,6 +231,24 @@ function ensureFolder(parent: FolderNode, name: string): FolderNode { return folder; } +function getFolderRowIds(tree: FolderNode, uncompactFolderId: string | null) { + const ids: string[] = []; + appendFolderRowIds(tree, uncompactFolderId, ids); + return ids; +} + +function appendFolderRowIds( + folder: FolderNode, + uncompactFolderId: string | null, + ids: string[], +) { + for (const child of folder.folders.values()) { + const compacted = compactFolder(child, uncompactFolderId); + ids.push(compacted.folder.id); + appendFolderRowIds(compacted.folder, uncompactFolderId, ids); + } +} + export function flattenRows({ files, getDisplayPath, From df3607ff92621f0678c23824c45de367c4ca83d6 Mon Sep 17 00:00:00 2001 From: Noam Shemesh Date: Mon, 17 Aug 2026 11:44:51 +0300 Subject: [PATCH 2/3] Add changelog for sidebar folder controls --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6b491f..965b0bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Added +- Expand or collapse all sidebar folders at once. Thanks [@n00ki](https://github.com/n00ki)! [#271](https://github.com/bholmesdev/hubble.md/pull/271) + ### Changed ### Fixed From b4bf794222cd1a0af43e4051c8b9f21bc4947594 Mon Sep 17 00:00:00 2001 From: Noam Shemesh Date: Mon, 17 Aug 2026 12:07:15 +0300 Subject: [PATCH 3/3] Refine sidebar folder controls --- packages/ui/src/components/Sidebar.tsx | 9 ++-- .../ui/src/components/useSidebarTree.test.ts | 24 +++++++++- packages/ui/src/components/useSidebarTree.ts | 44 +++++++++---------- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index 19e2ceee..a656247d 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -479,13 +479,12 @@ export function Sidebar({ const uncompactFolderId = deleteOnCancel?.kind === "folder" ? deleteOnCancel.path : null; const { - collapseAllFolders, collapseFolder, - expandAllFolders, expandFolder, hasExpandedFolders, hasFolders, rows, + toggleAllFolders, toggleFolder, } = useSidebarTree({ files, @@ -1294,7 +1293,7 @@ export function Sidebar({ Files )} -
+
{onCreateFile && ( void createFile(creationFolderId)} @@ -1358,9 +1357,7 @@ export function Sidebar({ size="icon-xs" aria-label={allFoldersActionLabel} title={allFoldersActionLabel} - onClick={ - hasExpandedFolders ? collapseAllFolders : expandAllFolders - } + onClick={toggleAllFolders} > {hasExpandedFolders ? ( diff --git a/packages/ui/src/components/useSidebarTree.test.ts b/packages/ui/src/components/useSidebarTree.test.ts index 6d092741..3cdf2f49 100644 --- a/packages/ui/src/components/useSidebarTree.test.ts +++ b/packages/ui/src/components/useSidebarTree.test.ts @@ -224,7 +224,7 @@ describe("useSidebarTree", () => { expect(harness.current.hasExpandedFolders).toBe(false); expect(filePaths(harness.current.rows)).toEqual([]); - act(() => harness.current.expandAllFolders()); + act(() => harness.current.toggleAllFolders()); expect(harness.current.hasExpandedFolders).toBe(true); expect(filePaths(harness.current.rows)).toEqual([ @@ -233,7 +233,7 @@ describe("useSidebarTree", () => { "/workspace/gamma/delta/two.md", ]); - act(() => harness.current.collapseAllFolders()); + act(() => harness.current.toggleAllFolders()); expect(harness.current.hasExpandedFolders).toBe(false); expect(filePaths(harness.current.rows)).toEqual([]); @@ -248,6 +248,26 @@ describe("useSidebarTree", () => { expect(harness.current.hasExpandedFolders).toBe(false); }); + it("keeps a compacted folder chain expanded when its shape changes", () => { + const nestedFile = { + path: "/workspace/alpha/beta/one.md", + modifiedAt: 1, + }; + const harness = renderTree({ files: [nestedFile] }); + + act(() => harness.current.toggleAllFolders()); + harness.rerender({ + files: [nestedFile, { path: "/workspace/alpha/root.md", modifiedAt: 2 }], + }); + + expect(folderRow(harness.current.rows, "alpha/").expanded).toBe(true); + expect(folderRow(harness.current.rows, "alpha/beta/").expanded).toBe(true); + expect(filePaths(harness.current.rows)).toEqual([ + "/workspace/alpha/beta/one.md", + "/workspace/alpha/root.md", + ]); + }); + it("keeps selected-file ancestors collapsed across unrelated rerenders", () => { const harness = renderTree({ files: nestedFiles, diff --git a/packages/ui/src/components/useSidebarTree.ts b/packages/ui/src/components/useSidebarTree.ts index a87c5dba..a3f3c41b 100644 --- a/packages/ui/src/components/useSidebarTree.ts +++ b/packages/ui/src/components/useSidebarTree.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { startTransition, useEffect, useRef, useState } from "react"; import { z } from "zod/v4"; import { fileNameFromPath, normalizeDisplayPath } from "../lib/filePath"; @@ -106,7 +106,7 @@ export function useSidebarTree({ expandedFolders, uncompactFolderId, }); - const folderRowIds = getFolderRowIds(tree, uncompactFolderId); + const folderIds = getFolderIds(tree, uncompactFolderId); const hasExpandedFolders = rows.some( (row) => row.kind === "folder" && row.expanded, ); @@ -151,21 +151,22 @@ 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: new Set(folderRowIds) }); - }; - const collapseAllFolders = () => { - setExpandedState({ key: storageKey, folders: new Set() }); + const toggleAllFolders = () => { + startTransition(() => { + setExpandedState({ + key: storageKey, + folders: hasExpandedFolders ? new Set() : new Set(folderIds), + }); + }); }; return { - collapseAllFolders, collapseFolder, - expandAllFolders, expandFolder, hasExpandedFolders, - hasFolders: folderRowIds.length > 0, + hasFolders: folderIds.length > 0, rows, + toggleAllFolders, toggleFolder, }; } @@ -231,24 +232,19 @@ function ensureFolder(parent: FolderNode, name: string): FolderNode { return folder; } -function getFolderRowIds(tree: FolderNode, uncompactFolderId: string | null) { +function getFolderIds(tree: FolderNode, uncompactFolderId: string | null) { const ids: string[] = []; - appendFolderRowIds(tree, uncompactFolderId, ids); + const walk = (folder: FolderNode) => { + for (const child of folder.folders.values()) { + const compacted = compactFolder(child, uncompactFolderId); + for (const segment of compacted.segments) ids.push(segment.id); + walk(compacted.folder); + } + }; + walk(tree); return ids; } -function appendFolderRowIds( - folder: FolderNode, - uncompactFolderId: string | null, - ids: string[], -) { - for (const child of folder.folders.values()) { - const compacted = compactFolder(child, uncompactFolderId); - ids.push(compacted.folder.id); - appendFolderRowIds(compacted.folder, uncompactFolderId, ids); - } -} - export function flattenRows({ files, getDisplayPath,