Skip to content
Open
Show file tree
Hide file tree
Changes from all 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,7 @@
---
changeKind: feature
packages:
- "@typespec/playground"
---

Clicking the source location of a type in the type graph now selects its declaration in the editor.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/html-program-viewer"
---

Fix scrollbars showing in the type graph breadcrumb bar. The path now scrolls horizontally without a scrollbar and keeps the selected node in view.
7 changes: 7 additions & 0 deletions .chronus/changes/type-graph-only-my-code-2026-9-1-11-42-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/html-program-viewer"
---

Hide the types coming from the compiler standard library and the loaded libraries from the type graph navigation tree. They can be shown again with the new toolbar button of the navigation pane, or by default with the new `defaultOnlyProjectCode` prop. Navigating to one of those types (from a link or a saved path) still shows it, with a notice that the tree does not list it.
7 changes: 7 additions & 0 deletions .chronus/changes/type-graph-type-origin-2026-9-1-12-0-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/html-program-viewer"
---

Show where a type was declared (your code, the standard library or a library) in the type view, with its file and line. When the host provides the new `onRevealSource` callback, clicking the badge of a type declared in your code reveals its declaration.
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
.current-path {
overflow-y: auto;
display: flex;
align-items: center;
/* Only the path can overflow, and its scrollbar would cover most of this one line tall bar. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
border: 1px solid var(--colorNeutralStroke1);
}

.current-path::-webkit-scrollbar {
display: none;
}

.current-path.focus {
border-bottom: 1px solid var(--colorBrandForeground1);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "@fluentui/react-components";
import { DatabaseRegular } from "@fluentui/react-icons";
import { getDoc } from "@typespec/compiler";
import { useCallback, useState, type MouseEvent } from "react";
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { Fragment } from "react/jsx-runtime";
import { useProgram } from "../program-context.js";
Expand All @@ -24,13 +24,23 @@ export const CurrentPath = () => {
const nav = useTreeNavigator();
const segments = nav.selectedPath.split(".");
const [showInput, setShowInput] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

// Keep the end of the path, where the selected node is, in view.
useEffect(() => {
const container = containerRef.current;
if (container) {
container.scrollLeft = container.scrollWidth;
}
}, [nav.selectedPath, showInput]);

useHotkeys("ctrl+shift+f, meta+shift+f", () => {
setShowInput(true);
});

return (
<div
ref={containerRef}
className={mergeClasses(style["current-path"], showInput && style["focus"])}
onClick={() => setShowInput(true)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@
.item:hover {
background-color: var(--colorNeutralBackground1Hover);
}

.empty {
display: inline-flex;
align-items: center;
gap: 4px;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Card, CardHeader, Text } from "@fluentui/react-components";
import { Button, Card, CardHeader, Text } from "@fluentui/react-components";
import { List, ListItem } from "@fluentui/react-list";
import { useCallback } from "react";
import type { TreeNavigator, TypeGraphListNode, TypeGraphNode } from "../use-tree-navigation.js";
Expand All @@ -23,12 +23,30 @@ export const ListTypeView = ({ nav, node }: ListTypeViewProps) => {
<Item key={item.id} item={item} nav={nav} />
))}

{node.children.length === 0 && <ListItem>No items</ListItem>}
{node.children.length === 0 && (
<ListItem>
<EmptyList nav={nav} node={node} />
</ListItem>
)}
</List>
</Card>
);
};

const EmptyList = ({ nav, node }: ListTypeViewProps) => {
if (nav.onlyProjectCode && node === nav.tree) {
return (
<span className={style["empty"]}>
No types declared in your code.
<Button appearance="transparent" size="small" onClick={() => nav.setOnlyProjectCode(false)}>
Show everything
</Button>
</span>
);
}
return <>No items</>;
};

const Item = ({ item, nav }: { nav: TreeNavigator; item: TypeGraphNode }) => {
const select = useCallback(() => {
nav.selectPath(item.id);
Expand Down
13 changes: 13 additions & 0 deletions packages/html-program-viewer/src/react/reveal-source-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Type } from "@typespec/compiler";
import { createContext, useContext } from "react";

/** Reveal where the given type is declared in the host editor. */
export type RevealSourceCallback = (type: Type) => void;

const RevealSourceContext = createContext<RevealSourceCallback | undefined>(undefined);

export const RevealSourceProvider = RevealSourceContext.Provider;

export function useRevealSource(): RevealSourceCallback | undefined {
return useContext(RevealSourceContext);
}
115 changes: 115 additions & 0 deletions packages/html-program-viewer/src/react/tree-filter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { expect, it } from "vitest";
import { Tester } from "../../test/test-host.js";
import { TypeGraph } from "./type-graph.js";
import { computeTree, filterProjectCode, type TypeGraphNode } from "./use-tree-navigation.js";

async function computeTreesFor(code: string) {
const { program } = await Tester.compile(code);
const full = computeTree(program);
return { full, filtered: filterProjectCode(program, full) };
}

function names(node: TypeGraphNode): string[] {
return node.children.map((x) => x.name.toString());
}

function find(node: TypeGraphNode, name: string): TypeGraphNode | undefined {
return node.children.find((x) => x.name.toString() === name);
}

it("keeps the namespaces declared in the user project", async () => {
const { filtered } = await computeTreesFor(`namespace MyService; model Foo {}`);

expect(names(filtered)).toContain("MyService");
});

it("removes the namespaces coming from the compiler or a library", async () => {
const { full, filtered } = await computeTreesFor(`namespace MyService; model Foo {}`);

expect(names(full)).toContain("TypeSpec");
expect(names(filtered)).not.toContain("TypeSpec");
});

it("only keeps the members declared in the user project", async () => {
const { filtered } = await computeTreesFor(`
namespace MyService;
model Foo {}
`);

const ns = find(filtered, "MyService")!;
expect(ns).toBeDefined();
const models = find(ns, "models")!;
expect(names(models)).toEqual(["Foo"]);
});

it("keeps a library namespace that the user project is augmenting", async () => {
const { filtered } = await computeTreesFor(`
namespace TypeSpec;
model MyExtension {}
`);

const ns = find(filtered, "TypeSpec");
expect(ns).toBeDefined();
expect(names(find(ns!, "models")!)).toEqual(["MyExtension"]);
});

it("keeps the types declared in the global namespace", async () => {
const { filtered } = await computeTreesFor(`model Foo {}`);

const global = find(filtered, "(global)")!;
expect(global).toBeDefined();
expect(names(find(global, "models")!)).toEqual(["Foo"]);
});

it("hides the library types in the navigation tree by default", async () => {
const { program } = await Tester.compile(`namespace MyService; model Foo {}`);
render(<TypeGraph program={program} />);

const tree = within(screen.getByRole("tree"));
expect(tree.getByTitle("MyService")).toBeDefined();
expect(tree.queryByTitle("TypeSpec")).toBeNull();
});

it("shows the library types when defaultOnlyProjectCode is false", async () => {
const { program } = await Tester.compile(`namespace MyService; model Foo {}`);
render(<TypeGraph program={program} defaultOnlyProjectCode={false} />);

const tree = within(screen.getByRole("tree"));
expect(tree.getByTitle("MyService")).toBeDefined();
expect(tree.getByTitle("TypeSpec")).toBeDefined();
});

it("toggling the library types button on reveals the library types", async () => {
const { program } = await Tester.compile(`namespace MyService; model Foo {}`);
render(<TypeGraph program={program} />);

expect(within(screen.getByRole("tree")).queryByTitle("TypeSpec")).toBeNull();

fireEvent.click(screen.getByRole("button", { name: /show the types coming from/i }));

expect(within(screen.getByRole("tree")).getByTitle("TypeSpec")).toBeDefined();
});

it("still shows a type hidden from the tree, explaining it is not listed", async () => {
const { program } = await Tester.compile(`namespace MyService; model Foo {}`);
render(<TypeGraph program={program} currentPath="$.TypeSpec.models.Array" />);

// The type itself is shown ...
expect(screen.getByText("Standard library")).toBeDefined();
// ... with a notice that the tree does not list it.
expect(screen.getByText(/not listed in the tree/)).toBeDefined();
expect(within(screen.getByRole("tree")).queryByTitle("TypeSpec")).toBeNull();

fireEvent.click(screen.getByRole("button", { name: /show library types/i }));

expect(screen.queryByText(/not listed in the tree/)).toBeNull();
expect(within(screen.getByRole("tree")).getByTitle("TypeSpec")).toBeDefined();
});

it("offers to show everything when the project declares no type", async () => {
const { program } = await Tester.compile(``);
render(<TypeGraph program={program} />);

expect(screen.getByText("No types declared in your code.")).toBeDefined();
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,45 @@
.tree-navigation {
display: flex;
flex-direction: column;
height: 100%;
}

.toolbar {
display: flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 2px 0 8px;
border-bottom: 1px solid var(--colorNeutralStroke2);
flex-shrink: 0;
}

.toolbar-title {
flex: 1;
min-width: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--colorNeutralForeground3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.toolbar-action {
min-width: 22px;
height: 22px;
padding: 0;
border: none;
}

.tree-container {
flex: 1;
overflow: auto;
min-height: 0;
}

.type-kind-icon {
color: var(--colorPaletteBerryForeground2);
font-weight: bold;
Expand Down
42 changes: 34 additions & 8 deletions packages/html-program-viewer/src/react/tree-navigation.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppsListRegular } from "@fluentui/react-icons";
import { ToggleButton, Tooltip } from "@fluentui/react-components";
import { AppsListRegular, LibraryFilled, LibraryRegular } from "@fluentui/react-icons";
import { Tree } from "@typespec/react-components";
import style from "./tree-navigation.module.css";
import { useTreeNavigator, type TypeGraphNode } from "./use-tree-navigation.js";
Expand All @@ -7,15 +8,40 @@ export interface TreeNavigationProps {}

export const TreeNavigation = (_: TreeNavigationProps) => {
const nav = useTreeNavigator();
const showLibraries = !nav.onlyProjectCode;

return (
<Tree<TypeGraphNode>
selectionMode="single"
tree={nav.tree}
nodeIcon={NodeIcon}
selected={nav.selectedPath}
onSelect={nav.selectPath}
/>
<div className={style["tree-navigation"]}>
<div className={style["toolbar"]}>
<div className={style["toolbar-title"]}>Types</div>
<Tooltip
content={
showLibraries
? "Hide the types coming from the compiler standard library and the loaded libraries"
: "Show the types coming from the compiler standard library and the loaded libraries"
}
relationship="label"
>
<ToggleButton
className={style["toolbar-action"]}
appearance="subtle"
size="small"
checked={showLibraries}
icon={showLibraries ? <LibraryFilled /> : <LibraryRegular />}
onClick={() => nav.setOnlyProjectCode(showLibraries)}
/>
</Tooltip>
</div>
<div className={style["tree-container"]}>
<Tree<TypeGraphNode>
selectionMode="single"
tree={nav.tree}
nodeIcon={NodeIcon}
selected={nav.selectedPath}
onSelect={nav.selectPath}
/>
</div>
</div>
);
};

Expand Down
18 changes: 18 additions & 0 deletions packages/html-program-viewer/src/react/type-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getLocationContext, type Program, type Type } from "@typespec/compiler";

/**
* Create a predicate telling whether a type was declared in the user project (as opposed to the compiler standard library or a library).
* Results are cached as the same type is likely to be queried multiple times when building the type graph.
*/
export function createIsProjectTypePredicate(program: Program): (type: Type) => boolean {
const cache = new Map<Type, boolean>();
return (type: Type) => {
const cached = cache.get(type);
if (cached !== undefined) {
return cached;
}
const result = getLocationContext(program, type).type === "project";
cache.set(type, result);
return result;
};
}
Loading
Loading