@@ -50,6 +50,23 @@ function buildTree(items: GhTreeItem[]): TreeNode[] {
return sort(root);
}
+// Walk the tree once to seed which directory paths should be open initially.
+function collectInitialOpen(
+ nodes: TreeNode[],
+ selectedPath: string | undefined,
+ depth: number,
+ defaultOpenDepth: number,
+ acc: Set<string>,
+): void {
+ for (const n of nodes) {
+ if (n.type !== "tree") continue;
+ const shouldOpen =
+ depth < defaultOpenDepth || (selectedPath?.startsWith(n.path + "/") ?? false);
+ if (shouldOpen) acc.add(n.path);
+ if (n.children) collectInitialOpen(n.children, selectedPath, depth + 1, defaultOpenDepth, acc);
+ }
+}
+
function TreeNodeItem({
node,
owner,
@@ -58,7 +75,8 @@ function TreeNodeItem({
selectedPath,
branch,
depth,
- defaultOpenDepth,
+ openPaths,
+ onToggle,
}: {
node: TreeNode;
owner: string;
@@ -67,17 +85,15 @@ function TreeNodeItem({
selectedPath?: string;
branch: string;
depth: number;
- defaultOpenDepth: number;
+ openPaths: Set<string>;
+ onToggle: (path: string) => void;
}) {
- const [open, setOpen] = useState(
- depth < defaultOpenDepth || (selectedPath?.startsWith(node.path + "/") ?? false),
- );
-
if (node.type === "tree") {
+ const open = openPaths.has(node.path);
return (
<li>
<button
- onClick={() => setOpen((o) => !o)}
+ onClick={() => onToggle(node.path)}
className="hover:bg-accent flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-sm transition-colors"
style={{ paddingLeft: `${8 + depth * 12}px` }}
>
@@ -109,7 +125,8 @@ function TreeNodeItem({
selectedPath={selectedPath}
branch={branch}
depth={depth + 1}
- defaultOpenDepth={defaultOpenDepth}
+ openPaths={openPaths}
+ onToggle={onToggle}
/>
))}
</ul>
@@ -148,6 +165,9 @@ export function FileTree({
selectedPath,
branch,
defaultOpenDepth = 0,
+ expandAllLabel,
+ collapseAllLabel,
+ branchSelector,
}: {
items: GhTreeItem[];
owner: string;
@@ -156,24 +176,88 @@ export function FileTree({
selectedPath?: string;
branch: string;
defaultOpenDepth?: number;
+ expandAllLabel: string;
+ collapseAllLabel: string;
+ branchSelector?: React.ReactNode;
}) {
const tree = buildTree(items);
+ const [openPaths, setOpenPaths] = useState<Set<string>>(() => {
+ const set = new Set<string>();
+ collectInitialOpen(tree, selectedPath, 0, defaultOpenDepth, set);
+ return set;
+ });
+
+ const handleToggle = (path: string) => {
+ setOpenPaths((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) next.delete(path);
+ else next.add(path);
+ return next;
+ });
+ };
+
+ const allExpanded = openPaths.size === 0;
+ const expandAll = () =>
+ setOpenPaths(new Set(items.filter((i) => i.type === "tree").map((i) => i.path)));
+ const collapseAll = () => setOpenPaths(new Set());
+ const toggleAllLabel = allExpanded ? expandAllLabel : collapseAllLabel;
return (
- <ul className="space-y-0.5 text-sm">
- {tree.map((node) => (
- <TreeNodeItem
- key={node.path}
- node={node}
- owner={owner}
- repo={repo}
- lang={lang}
- selectedPath={selectedPath}
- branch={branch}
- depth={0}
- defaultOpenDepth={defaultOpenDepth}
- />
- ))}
- </ul>
+ <div className="flex h-full flex-col overflow-hidden">
+ <div className="border-border space-y-2 border-b px-3 py-2.5">
+ <div className="flex items-center justify-between gap-2">
+ <span
+ className="text-foreground truncate text-xs font-semibold tracking-wide uppercase"
+ title={repo}
+ >
+ {repo}
+ </span>
+ <button
+ type="button"
+ onClick={allExpanded ? expandAll : collapseAll}
+ title={toggleAllLabel}
+ aria-label={toggleAllLabel}
+ className="text-muted-foreground hover:text-foreground hover:bg-accent shrink-0 rounded p-1 transition-colors"
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 16 16"
+ fill="currentColor"
+ className="h-4 w-4"
+ aria-hidden
+ >
+ <path fillRule="evenodd" clipRule="evenodd" d="M9 9H4v1h5V9z" />
+ {allExpanded && (
+ <path fillRule="evenodd" clipRule="evenodd" d="M7 12V7H6v5h1z" />
+ )}
+ <path
+ fillRule="evenodd"
+ clipRule="evenodd"
+ d="M5 3l1-1h7l1 1v7l-1 1h-2v2l-1 1H3l-1-1V6l1-1h2V3zm1 2h4l1 1v4h2V3H6v2zm4 1H3v7h7V6z"
+ />
+ </svg>
+ </button>
+ </div>
+ {branchSelector}
+ </div>
+ <div className="flex-1 overflow-y-auto p-2">
+ <ul className="space-y-0.5 text-sm">
+ {tree.map((node) => (
+ <TreeNodeItem
+ key={node.path}
+ node={node}
+ owner={owner}
+ repo={repo}
+ lang={lang}
+ selectedPath={selectedPath}
+ branch={branch}
+ depth={0}
+ openPaths={openPaths}
+ onToggle={handleToggle}
+ />
+ ))}
+ </ul>
+ </div>
+ </div>
);
}