diff --git a/.chronus/changes/feature-playground-file-explorer-2026-2-13-23-47-41.md b/.chronus/changes/feature-playground-file-explorer-2026-2-13-23-47-41.md new file mode 100644 index 00000000000..c27f0d1a2ee --- /dev/null +++ b/.chronus/changes/feature-playground-file-explorer-2026-2-13-23-47-41.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/playground" +--- + +Add file tree view for output \ No newline at end of file diff --git a/packages/playground/src/react/breadcrumb/file-breadcrumb.module.css b/packages/playground/src/react/breadcrumb/file-breadcrumb.module.css new file mode 100644 index 00000000000..aeafcebf9f6 --- /dev/null +++ b/packages/playground/src/react/breadcrumb/file-breadcrumb.module.css @@ -0,0 +1,27 @@ +.breadcrumb { + display: flex; + align-items: center; + padding: 4px 12px; + font-size: 12px; + height: 26px; + border-bottom: 1px solid var(--colorNeutralStroke1); + background: var(--colorNeutralBackground1); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + flex-shrink: 0; +} + +.segment { + display: inline-flex; + align-items: center; +} + +.separator { + margin: 0 4px; + color: var(--colorNeutralForeground4); +} + +.current { + font-weight: 600; +} diff --git a/packages/playground/src/react/breadcrumb/file-breadcrumb.tsx b/packages/playground/src/react/breadcrumb/file-breadcrumb.tsx new file mode 100644 index 00000000000..2b915e80132 --- /dev/null +++ b/packages/playground/src/react/breadcrumb/file-breadcrumb.tsx @@ -0,0 +1,27 @@ +import type { FunctionComponent } from "react"; +import style from "./file-breadcrumb.module.css"; + +export interface FileBreadcrumbProps { + readonly path: string; +} + +export const FileBreadcrumb: FunctionComponent = ({ path }) => { + if (!path || !path.includes("/")) { + return null; + } + + const segments = path.split("/"); + + return ( +
+ {segments.map((segment, index) => ( + + {index > 0 && /} + + {segment} + + + ))} +
+ ); +}; diff --git a/packages/playground/src/react/breadcrumb/index.ts b/packages/playground/src/react/breadcrumb/index.ts new file mode 100644 index 00000000000..22e93bb4007 --- /dev/null +++ b/packages/playground/src/react/breadcrumb/index.ts @@ -0,0 +1 @@ +export { FileBreadcrumb, type FileBreadcrumbProps } from "./file-breadcrumb.js"; diff --git a/packages/playground/src/react/file-tree/file-tree.module.css b/packages/playground/src/react/file-tree/file-tree.module.css new file mode 100644 index 00000000000..885dcf573fb --- /dev/null +++ b/packages/playground/src/react/file-tree/file-tree.module.css @@ -0,0 +1,6 @@ +.file-tree { + height: 100%; + overflow: auto; + background: var(--colorNeutralBackground3); + padding-top: 4px; +} diff --git a/packages/playground/src/react/file-tree/file-tree.tsx b/packages/playground/src/react/file-tree/file-tree.tsx new file mode 100644 index 00000000000..22e799bbeed --- /dev/null +++ b/packages/playground/src/react/file-tree/file-tree.tsx @@ -0,0 +1,107 @@ +import { Tree, type TreeNode } from "@typespec/react-components"; +import { useMemo, type FC, type FunctionComponent } from "react"; +import style from "./file-tree.module.css"; + +import { DocumentRegular, FolderRegular } from "@fluentui/react-icons"; + +export interface FileTreeExplorerProps { + readonly files: string[]; + readonly selected: string; + readonly onSelect: (file: string) => void; +} + +interface FileTreeNode extends TreeNode { + readonly isDirectory: boolean; +} + +const FileNodeIcon: FC<{ node: FileTreeNode }> = ({ node }) => { + if (node.isDirectory) { + return ; + } + return ; +}; + +/** + * Builds a tree structure from a flat list of file paths. + */ +function buildTree(files: string[]): FileTreeNode { + const root: FileTreeNode = { id: "__root__", name: "root", isDirectory: true, children: [] }; + const dirMap = new Map(); + dirMap.set("", root); + + function ensureDir(dirPath: string): FileTreeNode { + if (dirMap.has(dirPath)) { + return dirMap.get(dirPath)!; + } + const parts = dirPath.split("/"); + const parentPath = parts.slice(0, -1).join("/"); + const parent = ensureDir(parentPath); + const node: FileTreeNode = { + id: dirPath, + name: parts[parts.length - 1], + isDirectory: true, + children: [], + }; + dirMap.set(dirPath, node); + (parent.children as FileTreeNode[]).push(node); + return node; + } + + for (const file of [...files].sort()) { + const lastSlash = file.lastIndexOf("/"); + if (lastSlash === -1) { + (root.children as FileTreeNode[]).push({ + id: file, + name: file, + isDirectory: false, + }); + } else { + const dirPath = file.substring(0, lastSlash); + const fileName = file.substring(lastSlash + 1); + const parent = ensureDir(dirPath); + (parent.children as FileTreeNode[]).push({ + id: file, + name: fileName, + isDirectory: false, + }); + } + } + + // Sort children: directories first, then files, alphabetically within each group + function sortChildren(node: FileTreeNode) { + if (node.children) { + (node.children as FileTreeNode[]).sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return String(a.name).localeCompare(String(b.name)); + }); + for (const child of node.children as FileTreeNode[]) { + sortChildren(child); + } + } + } + sortChildren(root); + + return root; +} + +export const FileTreeExplorer: FunctionComponent = ({ + files, + selected, + onSelect, +}) => { + const tree = useMemo(() => buildTree(files), [files]); + + return ( +
+ + tree={tree} + selectionMode="single" + selected={selected} + onSelect={onSelect} + nodeIcon={FileNodeIcon} + /> +
+ ); +}; diff --git a/packages/playground/src/react/file-tree/index.ts b/packages/playground/src/react/file-tree/index.ts new file mode 100644 index 00000000000..c8022943d20 --- /dev/null +++ b/packages/playground/src/react/file-tree/index.ts @@ -0,0 +1 @@ +export { FileTreeExplorer, type FileTreeExplorerProps } from "./file-tree.js"; diff --git a/packages/playground/src/react/output-view/file-viewer.tsx b/packages/playground/src/react/output-view/file-viewer.tsx index 185b06de290..e744129ff54 100644 --- a/packages/playground/src/react/output-view/file-viewer.tsx +++ b/packages/playground/src/react/output-view/file-viewer.tsx @@ -1,6 +1,9 @@ import { FolderListRegular } from "@fluentui/react-icons"; -import { useCallback, useEffect, useState } from "react"; +import { Pane, SplitPane } from "@typespec/react-components"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { FileBreadcrumb } from "../breadcrumb/index.js"; import { FileOutput } from "../file-output/file-output.js"; +import { FileTreeExplorer } from "../file-tree/index.js"; import { OutputTabs } from "../output-tabs/output-tabs.js"; import type { FileOutputViewer, OutputViewerProps, ProgramViewer } from "../types.js"; @@ -14,6 +17,11 @@ const FileViewerComponent = ({ const [filename, setFilename] = useState(""); const [content, setContent] = useState(""); + const showFileTree = useMemo( + () => outputFiles.some((f) => f.includes("/")) || outputFiles.length >= 3, + [outputFiles], + ); + const loadOutputFile = useCallback( async (path: string) => { const contents = await program.host.readFile("./tsp-output/" + path); @@ -33,21 +41,48 @@ const FileViewerComponent = ({ } }, [program, outputFiles, loadOutputFile, filename]); - const handleTabSelection = useCallback( + const handleFileSelection = useCallback( (newFilename: string) => { - setFilename(newFilename); - void loadOutputFile(newFilename); + // Only select files, not directories + if (outputFiles.includes(newFilename)) { + setFilename(newFilename); + void loadOutputFile(newFilename); + } }, - [loadOutputFile], + [loadOutputFile, outputFiles], ); if (outputFiles.length === 0) { return <>No files emitted.; } + if (showFileTree) { + return ( +
+ + + + + +
+ +
+ +
+
+
+
+
+ ); + } + return (
- +
diff --git a/packages/playground/src/react/output-view/output-view.module.css b/packages/playground/src/react/output-view/output-view.module.css index 7dd38706938..b6bd903e0ef 100644 --- a/packages/playground/src/react/output-view/output-view.module.css +++ b/packages/playground/src/react/output-view/output-view.module.css @@ -18,6 +18,17 @@ min-height: 0; } +.file-viewer-content-with-breadcrumb { + display: flex; + flex-direction: column; + height: 100%; +} + +.file-viewer-content-with-breadcrumb .file-viewer-content { + flex: 1; + min-height: 0; +} + .type-graph-viewer { height: 100%; overflow-y: auto; diff --git a/packages/react-components/src/tree/tree.test.tsx b/packages/react-components/src/tree/tree.test.tsx index c7592b95ee4..96e2ee2e697 100644 --- a/packages/react-components/src/tree/tree.test.tsx +++ b/packages/react-components/src/tree/tree.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { Tree } from "./tree.js"; import type { TreeNode } from "./types.js"; @@ -110,3 +110,86 @@ it("use up down arrow to navigate", async () => { fireEvent.keyDown(treeNode, { key: "ArrowDown", code: "ArrowDown" }); expect(treeNode).toHaveAttribute("aria-activedescendant", nodes[0].id); }); + +it("collapse expanded directory by clicking in selectionMode=single", async () => { + render(); + const child1 = await screen.findByText("Child 1"); + + // Click to expand + fireEvent.click(child1); + expect(await screen.findAllByRole("treeitem")).toHaveLength(5); + + // Click again to collapse + fireEvent.click(child1); + const nodes = await screen.findAllByRole("treeitem"); + expect(nodes).toHaveLength(2); + expect(nodes[0]).toHaveAttribute("aria-expanded", "false"); +}); + +it("collapse expanded directory by pressing space in selectionMode=single", async () => { + render(); + const treeNode = await screen.findByRole("tree"); + fireEvent.focus(treeNode); + + // Space to expand (focus defaults to first item: Child 1) + fireEvent.keyDown(treeNode, { key: "Space", code: "Space" }); + expect(await screen.findAllByRole("treeitem")).toHaveLength(5); + + // Space again to collapse (focus stays on Child 1) + fireEvent.keyDown(treeNode, { key: "Space", code: "Space" }); + expect(await screen.findAllByRole("treeitem")).toHaveLength(2); +}); + +it("collapse expanded directory by pressing enter in selectionMode=single", async () => { + render(); + const treeNode = await screen.findByRole("tree"); + fireEvent.focus(treeNode); + + // Enter to expand + fireEvent.keyDown(treeNode, { key: "Enter", code: "Enter" }); + expect(await screen.findAllByRole("treeitem")).toHaveLength(5); + + // Enter again to collapse + fireEvent.keyDown(treeNode, { key: "Enter", code: "Enter" }); + expect(await screen.findAllByRole("treeitem")).toHaveLength(2); +}); + +it("expand-collapse round trip by clicking in selectionMode=single", async () => { + render(); + const child1 = await screen.findByText("Child 1"); + + // Expand + fireEvent.click(child1); + expect(await screen.findAllByRole("treeitem")).toHaveLength(5); + + // Collapse + fireEvent.click(child1); + expect(await screen.findAllByRole("treeitem")).toHaveLength(2); + + // Re-expand + fireEvent.click(child1); + expect(await screen.findAllByRole("treeitem")).toHaveLength(5); +}); + +it("clicking a file in selectionMode=single still selects it", async () => { + const onSelect = vi.fn(); + render(); + + // Expand Child 1 first + const child1 = await screen.findByText("Child 1"); + fireEvent.click(child1); + + // Click a leaf node + const subChild = await screen.findByText("Sub child 1.2"); + fireEvent.click(subChild); + expect(onSelect).toHaveBeenCalledWith("$.child1.2"); +}); + +it("clicking a directory in selectionMode=single fires onSelect", async () => { + const onSelect = vi.fn(); + render(); + + const child1 = await screen.findByText("Child 1"); + fireEvent.click(child1); + expect(onSelect).toHaveBeenCalledWith("$.child1"); +}); diff --git a/packages/react-components/src/tree/tree.tsx b/packages/react-components/src/tree/tree.tsx index a796d807d38..9922f60b4ea 100644 --- a/packages/react-components/src/tree/tree.tsx +++ b/packages/react-components/src/tree/tree.tsx @@ -60,7 +60,15 @@ export function Tree({ const activateRow = useCallback( (row: TreeRow) => { setFocusedIndex(row.index); - if (selectionMode === "none" || selectedKey === row.id) { + if (row.hasChildren) { + // Always toggle expand/collapse for parent nodes regardless of selection state. + // Note: the useEffect that auto-expands selectedKey only re-runs when selectedKey + // changes, so it won't interfere once a directory is already selected. + toggleExpand(row.id); + if (selectionMode === "single") { + setSelectedKey(row.id); + } + } else if (selectionMode === "none" || selectedKey === row.id) { toggleExpand(row.id); } else { expand(row.id);