From 01b3d0d29d1794719f2146d8e6000a5b3e6671f0 Mon Sep 17 00:00:00 2001 From: Jose Manuel Heredia Hidalgo Date: Sun, 1 Mar 2026 21:54:49 +0000 Subject: [PATCH 1/7] feat(playground): add file explorer with breadcrumb and file tree components Add file-tree and breadcrumb components for the playground output view. Update bundler and file-viewer to support the new file explorer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/bundler/src/bundler.ts | 13 ++- .../breadcrumb/file-breadcrumb.module.css | 27 +++++ .../src/react/breadcrumb/file-breadcrumb.tsx | 27 +++++ .../playground/src/react/breadcrumb/index.ts | 1 + .../src/react/file-tree/file-tree.module.css | 6 + .../src/react/file-tree/file-tree.tsx | 107 ++++++++++++++++++ .../playground/src/react/file-tree/index.ts | 1 + .../src/react/output-view/file-viewer.tsx | 47 +++++++- .../react/output-view/output-view.module.css | 11 ++ 9 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 packages/playground/src/react/breadcrumb/file-breadcrumb.module.css create mode 100644 packages/playground/src/react/breadcrumb/file-breadcrumb.tsx create mode 100644 packages/playground/src/react/breadcrumb/index.ts create mode 100644 packages/playground/src/react/file-tree/file-tree.module.css create mode 100644 packages/playground/src/react/file-tree/file-tree.tsx create mode 100644 packages/playground/src/react/file-tree/index.ts diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts index 0fc8f5dfc31..c98463eb56b 100644 --- a/packages/bundler/src/bundler.ts +++ b/packages/bundler/src/bundler.ts @@ -211,7 +211,9 @@ async function createEsBuildContext( build.onResolve({ filter: /.*/ }, (args) => { if ( definition.packageJson.peerDependencies && - Object.keys(definition.packageJson.peerDependencies).some((x) => args.path.startsWith(x)) + Object.keys(definition.packageJson.peerDependencies).some( + (x) => args.path === x || args.path.startsWith(x + "/"), + ) ) { return { path: args.path, external: true }; } @@ -240,6 +242,15 @@ async function createEsBuildContext( target: "es2024", minify, plugins: [virtualPlugin, nodeModulesPolyfillPlugin({}), ...plugins], + define: { + "process.env": "{}", + "process.argv": "[]", + "process.platform": '"browser"', + "process.versions": "{}", + "process.stdout": "undefined", + "process.stderr": "undefined", + "process.cwd": "undefined", + }, }); } 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..9d4cd39b716 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 hasDirectories = useMemo( + () => outputFiles.some((f) => f.includes("/")), + [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 (hasDirectories) { + 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; From c132cc8af6c82f7c443eb95c3b719a27dcc1a2c8 Mon Sep 17 00:00:00 2001 From: Jose Manuel Heredia Hidalgo Date: Sun, 1 Mar 2026 21:59:29 +0000 Subject: [PATCH 2/7] format --- packages/playground/src/react/output-view/file-viewer.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/playground/src/react/output-view/file-viewer.tsx b/packages/playground/src/react/output-view/file-viewer.tsx index 9d4cd39b716..10d569af20c 100644 --- a/packages/playground/src/react/output-view/file-viewer.tsx +++ b/packages/playground/src/react/output-view/file-viewer.tsx @@ -17,10 +17,7 @@ const FileViewerComponent = ({ const [filename, setFilename] = useState(""); const [content, setContent] = useState(""); - const hasDirectories = useMemo( - () => outputFiles.some((f) => f.includes("/")), - [outputFiles], - ); + const hasDirectories = useMemo(() => outputFiles.some((f) => f.includes("/")), [outputFiles]); const loadOutputFile = useCallback( async (path: string) => { From 599338f967b9fb92c849b5169f6b9ffbbcd2c835 Mon Sep 17 00:00:00 2001 From: Jose Manuel Heredia Hidalgo Date: Mon, 2 Mar 2026 23:31:50 +0000 Subject: [PATCH 3/7] workaround alloy duplicates --- packages/bundler/src/bundler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts index c98463eb56b..a5a87671715 100644 --- a/packages/bundler/src/bundler.ts +++ b/packages/bundler/src/bundler.ts @@ -241,6 +241,9 @@ async function createEsBuildContext( format: "esm", target: "es2024", minify, + banner: { + js: "delete globalThis.__ALLOY__;", + }, plugins: [virtualPlugin, nodeModulesPolyfillPlugin({}), ...plugins], define: { "process.env": "{}", From 3ae315d52abf4ce3c0462689423de0b16d40f73f Mon Sep 17 00:00:00 2001 From: Jose Manuel Heredia Hidalgo Date: Fri, 13 Mar 2026 21:08:42 +0000 Subject: [PATCH 4/7] fix expand collapse --- .../react-components/src/tree/tree.test.tsx | 85 ++++++++++++++++++- packages/react-components/src/tree/tree.tsx | 10 ++- 2 files changed, 93 insertions(+), 2 deletions(-) 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); From 7f298713dce38572dbd82fdac456bb002d64deb9 Mon Sep 17 00:00:00 2001 From: Jose Manuel Heredia Hidalgo Date: Fri, 13 Mar 2026 21:17:02 +0000 Subject: [PATCH 5/7] Revert bundler changes --- packages/bundler/src/bundler.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts index a5a87671715..0fc8f5dfc31 100644 --- a/packages/bundler/src/bundler.ts +++ b/packages/bundler/src/bundler.ts @@ -211,9 +211,7 @@ async function createEsBuildContext( build.onResolve({ filter: /.*/ }, (args) => { if ( definition.packageJson.peerDependencies && - Object.keys(definition.packageJson.peerDependencies).some( - (x) => args.path === x || args.path.startsWith(x + "/"), - ) + Object.keys(definition.packageJson.peerDependencies).some((x) => args.path.startsWith(x)) ) { return { path: args.path, external: true }; } @@ -241,19 +239,7 @@ async function createEsBuildContext( format: "esm", target: "es2024", minify, - banner: { - js: "delete globalThis.__ALLOY__;", - }, plugins: [virtualPlugin, nodeModulesPolyfillPlugin({}), ...plugins], - define: { - "process.env": "{}", - "process.argv": "[]", - "process.platform": '"browser"', - "process.versions": "{}", - "process.stdout": "undefined", - "process.stderr": "undefined", - "process.cwd": "undefined", - }, }); } From 7549b08591793634208d6bc238bc540b7104ee95 Mon Sep 17 00:00:00 2001 From: Jose Manuel Heredia Hidalgo Date: Fri, 13 Mar 2026 23:24:17 +0000 Subject: [PATCH 6/7] feat(playground): show file tree for 3+ output files Show the FileTreeExplorer when there are 3 or more output files, in addition to when there are nested subdirectories. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/playground/src/react/output-view/file-viewer.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/playground/src/react/output-view/file-viewer.tsx b/packages/playground/src/react/output-view/file-viewer.tsx index 10d569af20c..e744129ff54 100644 --- a/packages/playground/src/react/output-view/file-viewer.tsx +++ b/packages/playground/src/react/output-view/file-viewer.tsx @@ -17,7 +17,10 @@ const FileViewerComponent = ({ const [filename, setFilename] = useState(""); const [content, setContent] = useState(""); - const hasDirectories = useMemo(() => outputFiles.some((f) => f.includes("/")), [outputFiles]); + const showFileTree = useMemo( + () => outputFiles.some((f) => f.includes("/")) || outputFiles.length >= 3, + [outputFiles], + ); const loadOutputFile = useCallback( async (path: string) => { @@ -53,7 +56,7 @@ const FileViewerComponent = ({ return <>No files emitted.; } - if (hasDirectories) { + if (showFileTree) { return (
From 5ef392def36329b88a91ae66c42c272943abb21a Mon Sep 17 00:00:00 2001 From: Jose Manuel Heredia Hidalgo Date: Fri, 13 Mar 2026 23:47:56 +0000 Subject: [PATCH 7/7] Add change info --- .../feature-playground-file-explorer-2026-2-13-23-47-41.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/feature-playground-file-explorer-2026-2-13-23-47-41.md 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