waveterm/frontend/app/treeview/treeview.tsx
Copilot f4acfc9456
Add virtualized flat-list TreeView component and preview sandbox (#2972)
This PR introduces a new frontend TreeView widget intended for
VSCode-style explorer use cases, without backend wiring yet. It provides
a reusable, backend-agnostic API with virtualization, flat visible-row
projection, and preview coverage under `frontend/preview`.

- **What this adds**
- New `TreeView` component in `frontend/app/treeview/treeview.tsx`
designed around:
    - flat `visibleRows` projection with `depth` (not recursive render)
    - TanStack Virtual row virtualization
    - responsive width constraints + horizontal/vertical scrolling
    - single-selection, expand/collapse, and basic keyboard navigation
- New preview: `frontend/preview/previews/treeview.preview.tsx` with
async mocked directory loading and width controls.
- Focused tests: `frontend/app/treeview/treeview.test.ts` for
projection/sorting/synthetic-row behavior.

- **Tree model + projection behavior**
- Defines a canonical `TreeNodeData` wrapper (separate from direct
`FileInfo` coupling) with:
- `id`, `parentId`, `isDirectory`, `mimeType`, flags, `childrenStatus`,
`childrenIds`, `capInfo`
- Builds `visibleRows` from `nodesById + expandedIds` and injects
synthetic rows for:
    - `loading`
    - `error`
    - `capped` (“Showing first N entries”)

- **Interaction model implemented**
  - Click: select row
  - Double-click directory (or chevron click): expand/collapse
  - Double-click file: emits `onOpenFile`
  - Keyboard:
    - Up/Down: move visible selection
    - Left: collapse selected dir or move to parent
    - Right: expand selected dir or move to first child

- **Sorting + icon strategy**
  - Child sorting is deterministic and stable:
    - directories first
    - case-insensitive label order
    - id/path tie-breaker
- Icon resolution supports directory/file/error states and simple
mimetype/extension fallbacks.

- **Example usage**
```tsx
<TreeView
    rootIds={["workspace:/"]}
    initialNodes={{ "workspace:/": { id: "workspace:/", isDirectory: true, childrenStatus: "unloaded" } }}
    fetchDir={async (id, limit) => ({ nodes: data[id].slice(0, limit), capped: data[id].length > limit })}
    maxDirEntries={120}
    minWidth={100}
    maxWidth={400}
    height={420}
    onSelectionChange={(id) => setSelection(id)}
/>
```

- **<screenshot>**
-
https://github.com/user-attachments/assets/6f8b8a2a-f9a1-454d-bf4f-1d4a97b6e123

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
2026-03-03 18:25:42 -08:00

522 lines
18 KiB
TypeScript

// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { makeIconClass } from "@/util/util";
import { useVirtualizer } from "@tanstack/react-virtual";
import clsx from "clsx";
import React, {
CSSProperties,
KeyboardEvent,
MouseEvent,
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
type TreeNodeChildrenStatus = "unloaded" | "loading" | "loaded" | "error" | "capped";
export interface TreeNodeData {
id: string;
parentId?: string;
label?: string;
path?: string;
isDirectory: boolean;
mimeType?: string;
icon?: string;
isReadonly?: boolean;
notfound?: boolean;
staterror?: string;
childrenStatus?: TreeNodeChildrenStatus;
childrenIds?: string[];
capInfo?: { max: number; totalKnown?: number };
}
interface FetchDirResult {
nodes: TreeNodeData[];
capped?: boolean;
totalKnown?: number;
}
export interface TreeViewVisibleRow {
id: string;
parentId?: string;
depth: number;
kind: "node" | "loading" | "error" | "capped";
label: string;
isDirectory?: boolean;
isExpanded?: boolean;
hasChildren?: boolean;
icon?: string;
node?: TreeNodeData;
}
export interface TreeViewProps {
rootIds: string[];
initialNodes: Record<string, TreeNodeData>;
fetchDir?: (id: string, limit: number) => Promise<FetchDirResult>;
maxDirEntries?: number;
rowHeight?: number;
indentWidth?: number;
overscan?: number;
minWidth?: number;
maxWidth?: number;
width?: number | string;
height?: number | string;
className?: string;
onOpenFile?: (id: string, node: TreeNodeData) => void;
onSelectionChange?: (id: string, node: TreeNodeData) => void;
}
export interface TreeViewRef {
scrollToId: (id: string) => void;
}
const DefaultRowHeight = 24;
const DefaultIndentWidth = 16;
const DefaultOverscan = 10;
const ChevronWidth = 16;
function normalizeLabel(node: TreeNodeData): string {
if (node.label?.trim()) {
return node.label;
}
const path = node.path ?? node.id;
const chunks = path.split("/").filter(Boolean);
return chunks[chunks.length - 1] ?? path;
}
function sortIdsByNode(nodesById: Map<string, TreeNodeData>, ids: string[]): string[] {
return [...ids].sort((leftId, rightId) => {
const left = nodesById.get(leftId);
const right = nodesById.get(rightId);
const leftDir = left?.isDirectory ? 0 : 1;
const rightDir = right?.isDirectory ? 0 : 1;
if (leftDir !== rightDir) {
return leftDir - rightDir;
}
const leftLabel = normalizeLabel(left ?? { id: leftId, isDirectory: false }).toLocaleLowerCase();
const rightLabel = normalizeLabel(right ?? { id: rightId, isDirectory: false }).toLocaleLowerCase();
if (leftLabel !== rightLabel) {
return leftLabel.localeCompare(rightLabel);
}
return leftId.localeCompare(rightId);
});
}
export function buildVisibleRows(
nodesById: Map<string, TreeNodeData>,
rootIds: string[],
expandedIds: Set<string>
): TreeViewVisibleRow[] {
const rows: TreeViewVisibleRow[] = [];
const appendNode = (id: string, depth: number) => {
const node = nodesById.get(id);
if (node == null) {
return;
}
const childIds = node.childrenIds ?? [];
const hasChildren = node.isDirectory && (childIds.length > 0 || node.childrenStatus !== "loaded");
const isExpanded = expandedIds.has(id);
rows.push({
id,
parentId: node.parentId,
depth,
kind: "node",
label: normalizeLabel(node),
isDirectory: node.isDirectory,
isExpanded,
hasChildren,
icon: node.icon,
node,
});
if (!isExpanded || !node.isDirectory) {
return;
}
const status = node.childrenStatus ?? "unloaded";
if (status === "loading") {
rows.push({
id: `${id}::__loading`,
parentId: id,
depth: depth + 1,
kind: "loading",
label: "Loading…",
});
return;
}
if (status === "error") {
rows.push({
id: `${id}::__error`,
parentId: id,
depth: depth + 1,
kind: "error",
label: node.staterror ? `Error: ${node.staterror}` : "Unable to load directory",
});
return;
}
const sortedChildren = sortIdsByNode(nodesById, childIds);
sortedChildren.forEach((childId) => appendNode(childId, depth + 1));
if (status === "capped") {
const capMax = node.capInfo?.max ?? childIds.length;
rows.push({
id: `${id}::__capped`,
parentId: id,
depth: depth + 1,
kind: "capped",
label: `Showing first ${capMax} entries`,
});
}
};
sortIdsByNode(nodesById, rootIds).forEach((id) => appendNode(id, 0));
return rows;
}
function getNodeIcon(node: TreeNodeData, isExpanded: boolean): string {
if (node.notfound || node.staterror) {
return "triangle-exclamation";
}
if (node.icon) {
return node.icon;
}
if (node.isDirectory) {
return isExpanded ? "folder-open" : "folder";
}
const mime = node.mimeType ?? "";
if (mime.startsWith("image/")) {
return "image";
}
if (mime === "application/pdf") {
return "file-pdf";
}
const extension = normalizeLabel(node).split(".").pop()?.toLocaleLowerCase();
if (["js", "jsx", "ts", "tsx", "go", "py", "java", "c", "cpp", "h", "hpp", "json", "yaml", "yml"].includes(extension)) {
return "file-code";
}
if (["md", "txt", "log"].includes(extension)) {
return "file-lines";
}
return "file";
}
export const TreeView = forwardRef<TreeViewRef, TreeViewProps>((props, ref) => {
const {
rootIds,
initialNodes,
fetchDir,
maxDirEntries = 500,
rowHeight = DefaultRowHeight,
indentWidth = DefaultIndentWidth,
overscan = DefaultOverscan,
minWidth = 100,
maxWidth = 400,
width = "100%",
height = 360,
className,
onOpenFile,
onSelectionChange,
} = props;
const [nodesById, setNodesById] = useState<Map<string, TreeNodeData>>(
() =>
new Map(
Object.entries(initialNodes).map(([id, node]) => [id, { ...node, childrenStatus: node.childrenStatus ?? "unloaded" }])
)
);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [selectedId, setSelectedId] = useState<string>(rootIds[0]);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setNodesById(
new Map(
Object.entries(initialNodes).map(([id, node]) => [
id,
{
...node,
childrenStatus: node.childrenStatus ?? "unloaded",
},
])
)
);
}, [initialNodes]);
const visibleRows = useMemo(() => buildVisibleRows(nodesById, rootIds, expandedIds), [nodesById, rootIds, expandedIds]);
const idToIndex = useMemo(
() => new Map(visibleRows.map((row, index) => [row.id, index])),
[visibleRows]
);
const virtualizer = useVirtualizer({
count: visibleRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan,
});
const commitSelection = (id: string) => {
const node = nodesById.get(id);
if (node == null) {
return;
}
setSelectedId(id);
onSelectionChange?.(id, node);
};
const scrollToId = (id: string) => {
const index = idToIndex.get(id);
if (index == null) {
return;
}
virtualizer.scrollToIndex(index, { align: "auto" });
};
useImperativeHandle(
ref,
() => ({
scrollToId,
}),
[idToIndex, virtualizer]
);
const loadChildren = async (id: string) => {
const currentNode = nodesById.get(id);
if (currentNode == null || !currentNode.isDirectory || currentNode.notfound || currentNode.staterror || fetchDir == null) {
return;
}
const status = currentNode.childrenStatus ?? "unloaded";
if (status !== "unloaded") {
return;
}
setNodesById((prev) => {
const next = new Map(prev);
next.set(id, { ...currentNode, childrenStatus: "loading" });
return next;
});
try {
const result = await fetchDir(id, maxDirEntries);
setNodesById((prev) => {
const next = new Map(prev);
result.nodes.forEach((node) => {
const merged: TreeNodeData = {
...node,
parentId: node.parentId ?? id,
childrenStatus: node.childrenStatus ?? (node.isDirectory ? "unloaded" : "loaded"),
};
next.set(merged.id, merged);
});
const childrenIds = sortIdsByNode(
next,
result.nodes.map((entry) => entry.id)
);
const source = next.get(id) ?? currentNode;
next.set(id, {
...source,
childrenIds,
childrenStatus: result.capped ? "capped" : "loaded",
capInfo: result.capped ? { max: maxDirEntries, totalKnown: result.totalKnown } : undefined,
});
return next;
});
} catch (error) {
setNodesById((prev) => {
const next = new Map(prev);
const source = next.get(id) ?? currentNode;
next.set(id, {
...source,
childrenStatus: "error",
staterror: error instanceof Error ? error.message : "Unknown error",
});
return next;
});
}
};
const toggleExpand = (id: string) => {
const node = nodesById.get(id);
if (node == null || !node.isDirectory || node.notfound || node.staterror) {
return;
}
const expanded = expandedIds.has(id);
if (!expanded) {
loadChildren(id);
}
setExpandedIds((prev) => {
const next = new Set(prev);
if (expanded) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
scrollToId(id);
};
const selectVisibleNodeAt = (index: number) => {
if (index < 0 || index >= visibleRows.length) {
return;
}
const row = visibleRows[index];
if (row.kind !== "node") {
return;
}
commitSelection(row.id);
scrollToId(row.id);
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const selectedIndex = selectedId != null ? idToIndex.get(selectedId) : undefined;
if (event.key === "ArrowDown") {
event.preventDefault();
const nextIndex = (selectedIndex ?? -1) + 1;
for (let idx = nextIndex; idx < visibleRows.length; idx++) {
if (visibleRows[idx].kind === "node") {
selectVisibleNodeAt(idx);
break;
}
}
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
const previousIndex = (selectedIndex ?? visibleRows.length) - 1;
for (let idx = previousIndex; idx >= 0; idx--) {
if (visibleRows[idx].kind === "node") {
selectVisibleNodeAt(idx);
break;
}
}
return;
}
const node = selectedId ? nodesById.get(selectedId) : null;
if (node == null) {
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
if (node.isDirectory && expandedIds.has(node.id)) {
toggleExpand(node.id);
return;
}
if (node.parentId != null) {
commitSelection(node.parentId);
scrollToId(node.parentId);
}
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
if (node.isDirectory && !expandedIds.has(node.id)) {
toggleExpand(node.id);
return;
}
if (node.isDirectory && expandedIds.has(node.id) && node.childrenIds?.[0]) {
commitSelection(node.childrenIds[0]);
scrollToId(node.childrenIds[0]);
}
}
};
const containerStyle: CSSProperties = {
width,
minWidth,
maxWidth,
height,
};
return (
<div
className={clsx("rounded-md border border-border bg-panel", className)}
style={containerStyle}
tabIndex={0}
onKeyDown={onKeyDown}
>
<div ref={scrollRef} className="h-full overflow-auto">
<div className="relative w-max min-w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = visibleRows[virtualRow.index];
if (row.kind === "node" && row.node == null) {
return null;
}
const selected = row.id === selectedId;
return (
<div
key={row.id}
className={clsx(
"absolute left-0 right-0 flex items-center whitespace-nowrap text-sm",
row.kind === "node" ? "cursor-pointer" : "text-muted",
selected ? "bg-accent/25 text-foreground" : "text-foreground hover:bg-muted/50"
)}
style={{
top: 0,
height: rowHeight,
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={() => row.kind === "node" && commitSelection(row.id)}
onDoubleClick={() => {
if (row.kind !== "node") {
return;
}
if (row.isDirectory) {
toggleExpand(row.id);
return;
}
if (row.node != null) {
onOpenFile?.(row.id, row.node);
}
}}
>
<div
className="flex items-center"
style={{ paddingLeft: row.depth * indentWidth, width: ChevronWidth + row.depth * indentWidth }}
>
{row.kind === "node" && row.isDirectory && row.hasChildren ? (
<button
className="h-4 w-4 rounded text-muted hover:text-foreground cursor-pointer"
onClick={(event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
toggleExpand(row.id);
}}
>
<i
className={clsx(
"fa-sharp fa-solid text-[11px]",
row.isExpanded ? "fa-chevron-down" : "fa-chevron-right"
)}
/>
</button>
) : (
<span className="inline-block h-4 w-4" />
)}
</div>
{row.kind === "node" ? (
<>
<i
className={makeIconClass(getNodeIcon(row.node, row.isExpanded), true)}
style={{
color: row.node.notfound || row.node.staterror ? "var(--color-error)" : "inherit",
}}
/>
<span
className={clsx("ml-2 pr-3", row.node.isReadonly && "text-muted")}
title={row.label}
>
{row.label}
</span>
</>
) : (
<span className="ml-6 pr-3 text-xs">{row.label}</span>
)}
</div>
);
})}
</div>
</div>
</div>
);
});
TreeView.displayName = "TreeView";