Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 27 additions & 0 deletions packages/playground/src/react/breadcrumb/file-breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -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<FileBreadcrumbProps> = ({ path }) => {
if (!path || !path.includes("/")) {
return null;
}

const segments = path.split("/");

return (
<div className={style["breadcrumb"]}>
{segments.map((segment, index) => (
<span key={index} className={style["segment"]}>
{index > 0 && <span className={style["separator"]}>/</span>}
<span className={index === segments.length - 1 ? style["current"] : undefined}>
{segment}
</span>
</span>
))}
</div>
);
};
1 change: 1 addition & 0 deletions packages/playground/src/react/breadcrumb/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FileBreadcrumb, type FileBreadcrumbProps } from "./file-breadcrumb.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.file-tree {
height: 100%;
overflow: auto;
background: var(--colorNeutralBackground3);
padding-top: 4px;
}
107 changes: 107 additions & 0 deletions packages/playground/src/react/file-tree/file-tree.tsx
Original file line number Diff line number Diff line change
@@ -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 <FolderRegular />;
}
return <DocumentRegular />;
};

/**
* 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<string, FileTreeNode>();
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<FileTreeExplorerProps> = ({
files,
selected,
onSelect,
}) => {
const tree = useMemo(() => buildTree(files), [files]);

return (
<div className={style["file-tree"]}>
<Tree<FileTreeNode>
tree={tree}
selectionMode="single"
selected={selected}
onSelect={onSelect}
nodeIcon={FileNodeIcon}
/>
</div>
);
};
1 change: 1 addition & 0 deletions packages/playground/src/react/file-tree/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FileTreeExplorer, type FileTreeExplorerProps } from "./file-tree.js";
44 changes: 38 additions & 6 deletions packages/playground/src/react/output-view/file-viewer.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -14,6 +17,8 @@ const FileViewerComponent = ({
const [filename, setFilename] = useState<string>("");
const [content, setContent] = useState<string>("");

const hasDirectories = useMemo(() => outputFiles.some((f) => f.includes("/")), [outputFiles]);

const loadOutputFile = useCallback(
async (path: string) => {
const contents = await program.host.readFile("./tsp-output/" + path);
Expand All @@ -33,21 +38,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 (
<div className={style["file-viewer"]}>
<SplitPane initialSizes={["220px", undefined]}>
<Pane minSize={120} maxSize={400}>
<FileTreeExplorer
files={outputFiles}
selected={filename}
onSelect={handleFileSelection}
/>
</Pane>
<Pane>
<div className={style["file-viewer-content-with-breadcrumb"]}>
<FileBreadcrumb path={filename} />
<div className={style["file-viewer-content"]}>
<FileOutput filename={filename} content={content} viewers={fileViewers} />
</div>
</div>
</Pane>
</SplitPane>
</div>
);
}

return (
<div className={style["file-viewer"]}>
<OutputTabs filenames={outputFiles} selected={filename} onSelect={handleTabSelection} />
<OutputTabs filenames={outputFiles} selected={filename} onSelect={handleFileSelection} />
<div className={style["file-viewer-content"]}>
<FileOutput filename={filename} content={content} viewers={fileViewers} />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
85 changes: 84 additions & 1 deletion packages/react-components/src/tree/tree.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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(<Tree tree={simpleTree} selectionMode="single" />);
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(<Tree tree={simpleTree} selectionMode="single" />);
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(<Tree tree={simpleTree} selectionMode="single" />);
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(<Tree tree={simpleTree} selectionMode="single" />);
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(<Tree tree={simpleTree} selectionMode="single" onSelect={onSelect} />);

// 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(<Tree tree={simpleTree} selectionMode="single" onSelect={onSelect} />);

const child1 = await screen.findByText("Child 1");
fireEvent.click(child1);
expect(onSelect).toHaveBeenCalledWith("$.child1");
});
10 changes: 9 additions & 1 deletion packages/react-components/src/tree/tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,15 @@ export function Tree<T extends TreeNode>({
const activateRow = useCallback(
(row: TreeRow<TreeNode>) => {
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);
Expand Down
Loading