Skip to content

Commit f4acfc9

Browse files
Copilotsawka
andauthored
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>
1 parent d47329d commit f4acfc9

5 files changed

Lines changed: 696 additions & 2 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { buildVisibleRows, TreeNodeData } from "@/app/treeview/treeview";
5+
import { describe, expect, it } from "vitest";
6+
7+
function makeNodes(entries: TreeNodeData[]): Map<string, TreeNodeData> {
8+
return new Map(entries.map((entry) => [entry.id, entry]));
9+
}
10+
11+
describe("treeview visible rows", () => {
12+
it("sorts directories before files and alphabetically", () => {
13+
const nodes = makeNodes([
14+
{
15+
id: "root",
16+
isDirectory: true,
17+
childrenStatus: "loaded",
18+
childrenIds: ["c", "a", "b"],
19+
},
20+
{ id: "a", parentId: "root", isDirectory: false, label: "z-last.txt" },
21+
{ id: "b", parentId: "root", isDirectory: true, label: "docs", childrenStatus: "loaded", childrenIds: [] },
22+
{ id: "c", parentId: "root", isDirectory: false, label: "a-first.txt" },
23+
]);
24+
const rows = buildVisibleRows(nodes, ["root"], new Set(["root"]));
25+
expect(rows.map((row) => row.id)).toEqual(["root", "b", "c", "a"]);
26+
});
27+
28+
it("renders loading and capped synthetic rows", () => {
29+
const nodes = makeNodes([
30+
{ id: "root", isDirectory: true, childrenStatus: "loading" },
31+
{
32+
id: "dir",
33+
isDirectory: true,
34+
childrenStatus: "capped",
35+
childrenIds: ["f1"],
36+
capInfo: { max: 1 },
37+
},
38+
{ id: "f1", parentId: "dir", isDirectory: false, label: "one.txt" },
39+
]);
40+
const loadingRows = buildVisibleRows(nodes, ["root"], new Set(["root"]));
41+
expect(loadingRows.map((row) => row.kind)).toEqual(["node", "loading"]);
42+
43+
const cappedRows = buildVisibleRows(nodes, ["dir"], new Set(["dir"]));
44+
expect(cappedRows.map((row) => row.kind)).toEqual(["node", "node", "capped"]);
45+
});
46+
});

0 commit comments

Comments
 (0)