Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions packages/ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ 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";
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 {
Expand Down Expand Up @@ -476,7 +478,15 @@ export function Sidebar({
const highlightPath = pendingPath ?? currentPath;
const uncompactFolderId =
deleteOnCancel?.kind === "folder" ? deleteOnCancel.path : null;
const { collapseFolder, expandFolder, rows, toggleFolder } = useSidebarTree({
const {
collapseFolder,
expandFolder,
hasExpandedFolders,
hasFolders,
rows,
toggleAllFolders,
toggleFolder,
} = useSidebarTree({
files,
folders,
getDisplayPath,
Expand All @@ -485,6 +495,9 @@ export function Sidebar({
storageScope,
uncompactFolderId,
});
const allFoldersActionLabel = hasExpandedFolders
? "Collapse all folders"
: "Expand all folders";
const [selection, setSelection] = useState<SidebarSelectionState>({
selectedKeys: new Set(),
anchorKey: null,
Expand Down Expand Up @@ -1280,7 +1293,7 @@ export function Sidebar({
Files
</span>
)}
<div className="flex items-center gap-1">
<div className="flex shrink-0 items-center gap-1">
{onCreateFile && (
<NewFileMenu
onCreateFile={() => void createFile(creationFolderId)}
Expand Down Expand Up @@ -1338,6 +1351,21 @@ export function Sidebar({
</Select.Positioner>
</Select.Portal>
</Select.Root>
{hasFolders ? (
<Button
variant="ghost"
size="icon-xs"
aria-label={allFoldersActionLabel}
title={allFoldersActionLabel}
onClick={toggleAllFolders}
>
{hasExpandedFolders ? (
<MingcuteFoldVerticalLine className="size-3.5" />
) : (
<MingcuteUnfoldVerticalLine className="size-3.5" />
)}
</Button>
) : null}
</div>
</div>
{tree}
Expand Down
57 changes: 57 additions & 0 deletions packages/ui/src/components/useSidebarTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,63 @@ 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.toggleAllFolders());

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.toggleAllFolders());

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 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,
Expand Down
37 changes: 35 additions & 2 deletions packages/ui/src/components/useSidebarTree.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -106,6 +106,10 @@ export function useSidebarTree({
expandedFolders,
uncompactFolderId,
});
const folderIds = getFolderIds(tree, uncompactFolderId);
const hasExpandedFolders = rows.some(
(row) => row.kind === "folder" && row.expanded,
);

useEffect(() => {
if (!highlightPath || revealedPathRef.current === highlightPath) return;
Expand Down Expand Up @@ -147,8 +151,24 @@ export function useSidebarTree({
const collapseFolder = (id: string) => setExpanded(id, false);
const toggleFolder = (id: string) =>
setExpanded(id, !expandedFolders.has(id));
const toggleAllFolders = () => {
startTransition(() => {
setExpandedState({
key: storageKey,
folders: hasExpandedFolders ? new Set() : new Set(folderIds),
});
});
};

return { collapseFolder, expandFolder, rows, toggleFolder };
return {
collapseFolder,
expandFolder,
hasExpandedFolders,
hasFolders: folderIds.length > 0,
rows,
toggleAllFolders,
toggleFolder,
};
}

function makeFolder(id: string, name: string): FolderNode {
Expand Down Expand Up @@ -212,6 +232,19 @@ function ensureFolder(parent: FolderNode, name: string): FolderNode {
return folder;
}

function getFolderIds(tree: FolderNode, uncompactFolderId: string | null) {
const ids: string[] = [];
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;
}

export function flattenRows({
files,
getDisplayPath,
Expand Down
Loading