waveterm/frontend/app/treeview/treeview.test.ts
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

46 lines
1.9 KiB
TypeScript

// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { buildVisibleRows, TreeNodeData } from "@/app/treeview/treeview";
import { describe, expect, it } from "vitest";
function makeNodes(entries: TreeNodeData[]): Map<string, TreeNodeData> {
return new Map(entries.map((entry) => [entry.id, entry]));
}
describe("treeview visible rows", () => {
it("sorts directories before files and alphabetically", () => {
const nodes = makeNodes([
{
id: "root",
isDirectory: true,
childrenStatus: "loaded",
childrenIds: ["c", "a", "b"],
},
{ id: "a", parentId: "root", isDirectory: false, label: "z-last.txt" },
{ id: "b", parentId: "root", isDirectory: true, label: "docs", childrenStatus: "loaded", childrenIds: [] },
{ id: "c", parentId: "root", isDirectory: false, label: "a-first.txt" },
]);
const rows = buildVisibleRows(nodes, ["root"], new Set(["root"]));
expect(rows.map((row) => row.id)).toEqual(["root", "b", "c", "a"]);
});
it("renders loading and capped synthetic rows", () => {
const nodes = makeNodes([
{ id: "root", isDirectory: true, childrenStatus: "loading" },
{
id: "dir",
isDirectory: true,
childrenStatus: "capped",
childrenIds: ["f1"],
capInfo: { max: 1 },
},
{ id: "f1", parentId: "dir", isDirectory: false, label: "one.txt" },
]);
const loadingRows = buildVisibleRows(nodes, ["root"], new Set(["root"]));
expect(loadingRows.map((row) => row.kind)).toEqual(["node", "loading"]);
const cappedRows = buildVisibleRows(nodes, ["dir"], new Set(["dir"]));
expect(cappedRows.map((row) => row.kind)).toEqual(["node", "node", "capped"]);
});
});