DesignX
IDE@paxeer/ide/file-treeimplemented

File tree

Nested workspace tree with expand, selection, and git status chips.

Install

$pnpm exec designx add @paxeer/ide/file-tree

Preview

Loading preview...

Source code

components/designx/ide/file-tree.tsxtsx
"use client";

import { useState } from "react";
import { Button, StatusChip, cn } from "@paxeer/core";

export interface FileTreeNode {
  id: string;
  name: string;
  kind: "file" | "folder";
  children?: FileTreeNode[];
  git?: "modified" | "added" | "untracked";
}

export interface FileTreeProps {
  nodes: FileTreeNode[];
  selectedId?: string;
  onSelect?: (node: FileTreeNode) => void;
  className?: string;
}

function gitTone(git?: FileTreeNode["git"]) {
  if (git === "modified") return "tertiary" as const;
  if (git === "added") return "primary" as const;
  if (git === "untracked") return "secondary" as const;
  return undefined;
}

function TreeNode({
  node,
  depth,
  selectedId,
  onSelect,
}: {
  node: FileTreeNode;
  depth: number;
  selectedId?: string;
  onSelect?: (node: FileTreeNode) => void;
}) {
  const [open, setOpen] = useState(true);
  const selected = selectedId === node.id;
  const isFolder = node.kind === "folder";

  return (
    <li role="treeitem" aria-expanded={isFolder ? open : undefined} aria-selected={selected}>
      <Button
        variant="ghost"
        onClick={() => {
          if (isFolder) setOpen((value) => !value);
          onSelect?.(node);
        }}
        style={{ paddingLeft: 8 + depth * 14 }}
        className={cn(
          "h-7 w-full justify-start rounded-none text-on-surface",
          selected && "bg-secondary-container text-on-secondary-container",
        )}
      >
        <span aria-hidden className="w-3 text-on-surface-variant">
          {isFolder ? (open ? "▾" : "▸") : "·"}
        </span>
        <span className="truncate">{node.name}</span>
        {node.git ? (
          <StatusChip tone={gitTone(node.git)} className="ml-auto">
            {node.git === "modified" ? "M" : node.git === "added" ? "A" : "U"}
          </StatusChip>
        ) : null}
      </Button>
      {isFolder && open && node.children ? (
        <ul role="group">
          {node.children.map((child) => (
            <TreeNode
              key={child.id}
              node={child}
              depth={depth + 1}
              selectedId={selectedId}
              onSelect={onSelect}
            />
          ))}
        </ul>
      ) : null}
    </li>
  );
}

export function FileTree({ nodes, selectedId, onSelect, className }: FileTreeProps) {
  return (
    <nav aria-label="Workspace files" className={className}>
      <ul role="tree" className="py-1">
        {nodes.map((node) => (
          <TreeNode
            key={node.id}
            node={node}
            depth={0}
            selectedId={selectedId}
            onSelect={onSelect}
          />
        ))}
      </ul>
    </nav>
  );
}

Dependencies