diff --git a/.chronus/changes/playground-type-graph-reveal-source-2026-9-1-13-30-0.md b/.chronus/changes/playground-type-graph-reveal-source-2026-9-1-13-30-0.md new file mode 100644 index 00000000000..4261b63ddf6 --- /dev/null +++ b/.chronus/changes/playground-type-graph-reveal-source-2026-9-1-13-30-0.md @@ -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. diff --git a/.chronus/changes/type-graph-breadcrumb-scroll-2026-0-24-0-0-0.md b/.chronus/changes/type-graph-breadcrumb-scroll-2026-0-24-0-0-0.md new file mode 100644 index 00000000000..f202cd13ee2 --- /dev/null +++ b/.chronus/changes/type-graph-breadcrumb-scroll-2026-0-24-0-0-0.md @@ -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. diff --git a/.chronus/changes/type-graph-only-my-code-2026-9-1-11-42-0.md b/.chronus/changes/type-graph-only-my-code-2026-9-1-11-42-0.md new file mode 100644 index 00000000000..b82fc5d2abc --- /dev/null +++ b/.chronus/changes/type-graph-only-my-code-2026-9-1-11-42-0.md @@ -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. diff --git a/.chronus/changes/type-graph-type-origin-2026-9-1-12-0-2.md b/.chronus/changes/type-graph-type-origin-2026-9-1-12-0-2.md new file mode 100644 index 00000000000..a7d343cdde0 --- /dev/null +++ b/.chronus/changes/type-graph-type-origin-2026-9-1-12-0-2.md @@ -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. diff --git a/packages/html-program-viewer/src/react/current-path/current-path.module.css b/packages/html-program-viewer/src/react/current-path/current-path.module.css index 24260e6e5f0..62be174cd3e 100644 --- a/packages/html-program-viewer/src/react/current-path/current-path.module.css +++ b/packages/html-program-viewer/src/react/current-path/current-path.module.css @@ -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); } diff --git a/packages/html-program-viewer/src/react/current-path/current-path.tsx b/packages/html-program-viewer/src/react/current-path/current-path.tsx index 8e614e8ef8b..eae833ee4db 100644 --- a/packages/html-program-viewer/src/react/current-path/current-path.tsx +++ b/packages/html-program-viewer/src/react/current-path/current-path.tsx @@ -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"; @@ -24,6 +24,15 @@ export const CurrentPath = () => { const nav = useTreeNavigator(); const segments = nav.selectedPath.split("."); const [showInput, setShowInput] = useState(false); + const containerRef = useRef(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); @@ -31,6 +40,7 @@ export const CurrentPath = () => { return (
setShowInput(true)} > diff --git a/packages/html-program-viewer/src/react/list-type-view/list-type-view.module.css b/packages/html-program-viewer/src/react/list-type-view/list-type-view.module.css index 54efa89671a..955df81e5c4 100644 --- a/packages/html-program-viewer/src/react/list-type-view/list-type-view.module.css +++ b/packages/html-program-viewer/src/react/list-type-view/list-type-view.module.css @@ -6,3 +6,9 @@ .item:hover { background-color: var(--colorNeutralBackground1Hover); } + +.empty { + display: inline-flex; + align-items: center; + gap: 4px; +} diff --git a/packages/html-program-viewer/src/react/list-type-view/list-type-view.tsx b/packages/html-program-viewer/src/react/list-type-view/list-type-view.tsx index 70d80a9b68c..94353bfe167 100644 --- a/packages/html-program-viewer/src/react/list-type-view/list-type-view.tsx +++ b/packages/html-program-viewer/src/react/list-type-view/list-type-view.tsx @@ -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"; @@ -23,12 +23,30 @@ export const ListTypeView = ({ nav, node }: ListTypeViewProps) => { ))} - {node.children.length === 0 && No items} + {node.children.length === 0 && ( + + + + )} ); }; +const EmptyList = ({ nav, node }: ListTypeViewProps) => { + if (nav.onlyProjectCode && node === nav.tree) { + return ( + + No types declared in your code. + + + ); + } + return <>No items; +}; + const Item = ({ item, nav }: { nav: TreeNavigator; item: TypeGraphNode }) => { const select = useCallback(() => { nav.selectPath(item.id); diff --git a/packages/html-program-viewer/src/react/reveal-source-context.ts b/packages/html-program-viewer/src/react/reveal-source-context.ts new file mode 100644 index 00000000000..a8821a525ff --- /dev/null +++ b/packages/html-program-viewer/src/react/reveal-source-context.ts @@ -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(undefined); + +export const RevealSourceProvider = RevealSourceContext.Provider; + +export function useRevealSource(): RevealSourceCallback | undefined { + return useContext(RevealSourceContext); +} diff --git a/packages/html-program-viewer/src/react/tree-filter.test.tsx b/packages/html-program-viewer/src/react/tree-filter.test.tsx new file mode 100644 index 00000000000..56e57ec68ef --- /dev/null +++ b/packages/html-program-viewer/src/react/tree-filter.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + expect(screen.getByText("No types declared in your code.")).toBeDefined(); +}); diff --git a/packages/html-program-viewer/src/react/tree-navigation.module.css b/packages/html-program-viewer/src/react/tree-navigation.module.css index f366fd92d68..8c00d153956 100644 --- a/packages/html-program-viewer/src/react/tree-navigation.module.css +++ b/packages/html-program-viewer/src/react/tree-navigation.module.css @@ -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; diff --git a/packages/html-program-viewer/src/react/tree-navigation.tsx b/packages/html-program-viewer/src/react/tree-navigation.tsx index 2934d86b60a..6c29fe9c49e 100644 --- a/packages/html-program-viewer/src/react/tree-navigation.tsx +++ b/packages/html-program-viewer/src/react/tree-navigation.tsx @@ -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"; @@ -7,15 +8,40 @@ export interface TreeNavigationProps {} export const TreeNavigation = (_: TreeNavigationProps) => { const nav = useTreeNavigator(); + const showLibraries = !nav.onlyProjectCode; return ( - - selectionMode="single" - tree={nav.tree} - nodeIcon={NodeIcon} - selected={nav.selectedPath} - onSelect={nav.selectPath} - /> +
+
+
Types
+ + : } + onClick={() => nav.setOnlyProjectCode(showLibraries)} + /> + +
+
+ + selectionMode="single" + tree={nav.tree} + nodeIcon={NodeIcon} + selected={nav.selectedPath} + onSelect={nav.selectPath} + /> +
+
); }; diff --git a/packages/html-program-viewer/src/react/type-filter.ts b/packages/html-program-viewer/src/react/type-filter.ts new file mode 100644 index 00000000000..87d4a7a1f2d --- /dev/null +++ b/packages/html-program-viewer/src/react/type-filter.ts @@ -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(); + 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; + }; +} diff --git a/packages/html-program-viewer/src/react/type-graph.module.css b/packages/html-program-viewer/src/react/type-graph.module.css index ee0fc7d926e..240e1def73f 100644 --- a/packages/html-program-viewer/src/react/type-graph.module.css +++ b/packages/html-program-viewer/src/react/type-graph.module.css @@ -4,7 +4,7 @@ .tree-navigation-pane { height: 100%; - overflow-y: auto; + overflow: hidden; } .view-pane { @@ -15,3 +15,25 @@ .current-path { margin-bottom: 10px; } + +.hidden-by-filter { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 10px; + padding: 2px 4px 2px 8px; + font-size: 12px; + color: var(--colorNeutralForeground3); + background-color: var(--colorNeutralBackground3); + border: 1px solid var(--colorNeutralStroke2); + border-radius: var(--borderRadiusMedium); +} + +.hidden-by-filter-message { + flex: 1; + min-width: 0; +} + +.hidden-by-filter-action { + white-space: nowrap; +} diff --git a/packages/html-program-viewer/src/react/type-graph.tsx b/packages/html-program-viewer/src/react/type-graph.tsx index 356efc220c3..56f289d9c8c 100644 --- a/packages/html-program-viewer/src/react/type-graph.tsx +++ b/packages/html-program-viewer/src/react/type-graph.tsx @@ -1,4 +1,5 @@ -import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { Button, FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { EyeOffRegular } from "@fluentui/react-icons"; import type { Program } from "@typespec/compiler"; import { Pane, SplitPane } from "@typespec/react-components"; import { type FunctionComponent } from "react"; @@ -6,6 +7,7 @@ import ReactDOMServer from "react-dom/server"; import { CurrentPath } from "./current-path/current-path.js"; import { ListTypeView } from "./list-type-view/list-type-view.js"; import { ProgramProvider } from "./program-context.js"; +import { RevealSourceProvider, type RevealSourceCallback } from "./reveal-source-context.js"; import { TreeNavigation } from "./tree-navigation.js"; import style from "./type-graph.module.css"; import { TypeNodeView } from "./type-view/type-view.js"; @@ -24,31 +26,46 @@ export interface TypeGraphProps { readonly program: Program; readonly onNavigationChange?: (path: string) => void; readonly currentPath?: string; + /** + * If the graph should only show the types declared in the user project and hide the ones coming from the compiler or libraries. + * @default true + */ + readonly defaultOnlyProjectCode?: boolean; + /** + * Called when the user clicks the source location of a type declared in their code. + * Provide it when the host can reveal that location (e.g. an editor showing the project files). + */ + readonly onRevealSource?: RevealSourceCallback; } export const TypeGraph: FunctionComponent = ({ program, onNavigationChange, currentPath, + defaultOnlyProjectCode, + onRevealSource, }) => { return ( - - - - - -
- -
- -
-
+ + + + + + +
+ +
+ +
+
+
); @@ -56,6 +73,17 @@ export const TypeGraph: FunctionComponent = ({ const TypeGraphContent = () => { const nav = useTreeNavigator(); + + return ( + <> + {nav.selectionHiddenByFilter && } + + + ); +}; + +const TypeGraphNodeView = () => { + const nav = useTreeNavigator(); const node = nav.selectedNode; switch (node?.kind) { @@ -67,3 +95,24 @@ const TypeGraphContent = () => { return ; } }; + +/** Shown when navigating to a type that the navigation tree is currently hiding. */ +const HiddenByFilter = () => { + const nav = useTreeNavigator(); + return ( +
+ + + Not part of your code, so not listed in the tree. + + +
+ ); +}; diff --git a/packages/html-program-viewer/src/react/type-view/type-origin.module.css b/packages/html-program-viewer/src/react/type-view/type-origin.module.css new file mode 100644 index 00000000000..c7d41345f31 --- /dev/null +++ b/packages/html-program-viewer/src/react/type-view/type-origin.module.css @@ -0,0 +1,30 @@ +.origin { + font-family: var(--fontFamilyMonospace); + gap: 4px; +} + +.position { + color: var(--colorNeutralForeground3); + margin-left: 4px; +} + +.reveal { + padding: 0; + border: none; + background: none; + font: inherit; + cursor: pointer; + display: inline-flex; +} + +.reveal:hover .origin, +.reveal:focus-visible .origin { + border-color: var(--colorBrandStroke1); + color: var(--colorBrandForegroundLink); +} + +.reveal:hover .position, +.reveal:focus-visible .position { + color: var(--colorBrandForegroundLink); + text-decoration: underline; +} diff --git a/packages/html-program-viewer/src/react/type-view/type-origin.test.tsx b/packages/html-program-viewer/src/react/type-view/type-origin.test.tsx new file mode 100644 index 00000000000..c4d376ce60b --- /dev/null +++ b/packages/html-program-viewer/src/react/type-view/type-origin.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { navigateProgram, type Model, type Program } from "@typespec/compiler"; +import { describe, expect, it, vi } from "vitest"; +import { Tester } from "../../../test/test-host.js"; +import { RevealSourceProvider } from "../reveal-source-context.js"; +import { getDisplayPath, TypeOrigin } from "./type-origin.js"; + +async function findModel(program: Program, name: string): Promise { + let found: Model | undefined; + navigateProgram(program, { + model: (model) => { + if (model.name === name) { + found ??= model; + } + }, + }); + if (found === undefined) { + throw new Error(`Model ${name} not found`); + } + return found; +} + +it("shows types declared in the user project as `Your code`", async () => { + const { program } = await Tester.compile(`model Widget {}`); + const widget = await findModel(program, "Widget"); + + render(); + + expect(screen.getByText("Your code")).toBeDefined(); +}); + +it("shows types declared in the compiler as `Standard library`", async () => { + const { program } = await Tester.compile(`model Widget {}`); + const array = await findModel(program, "Array"); + + render(); + + expect(screen.getByText("Standard library")).toBeDefined(); +}); + +it("shows the file and line where the type is declared", async () => { + const { program } = await Tester.compile(` + model Widget {} + `); + const widget = await findModel(program, "Widget"); + + render(); + + expect(screen.getByText("main.tsp:2")).toBeDefined(); +}); + +it("reveals the source of a type declared in the user project", async () => { + const { program } = await Tester.compile(`model Widget {}`); + const widget = await findModel(program, "Widget"); + const revealSource = vi.fn(); + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /reveal in editor/i })); + + expect(revealSource).toHaveBeenCalledWith(widget); +}); + +it("does not offer to reveal the source of a type declared in a library", async () => { + const { program } = await Tester.compile(`model Widget {}`); + const array = await findModel(program, "Array"); + + render( + {}}> + + , + ); + + expect(screen.queryByRole("button")).toBeNull(); +}); + +describe("getDisplayPath", () => { + it("keeps only the file name for a file outside of a package", () => { + expect(getDisplayPath("/test/sub/main.tsp")).toEqual("main.tsp"); + }); + + it("shows the path relative to the package root for a scoped package", () => { + expect(getDisplayPath("/test/node_modules/@typespec/http/lib/main.tsp")).toEqual( + "lib/main.tsp", + ); + }); + + it("shows the path relative to the package root for an unscoped package", () => { + expect(getDisplayPath("/test/node_modules/mylib/lib/main.tsp")).toEqual("lib/main.tsp"); + }); + + it("uses the last node_modules for nested packages", () => { + expect( + getDisplayPath("/test/node_modules/@scope/a/node_modules/@scope/b/lib/main.tsp"), + ).toEqual("lib/main.tsp"); + }); +}); diff --git a/packages/html-program-viewer/src/react/type-view/type-origin.tsx b/packages/html-program-viewer/src/react/type-view/type-origin.tsx new file mode 100644 index 00000000000..7c209cd7e2c --- /dev/null +++ b/packages/html-program-viewer/src/react/type-view/type-origin.tsx @@ -0,0 +1,95 @@ +import { Badge, Tooltip } from "@fluentui/react-components"; +import { BookRegular, CodeRegular, PersonRegular } from "@fluentui/react-icons"; +import { + getBaseFileName, + getLocationContext, + getSourceLocation, + type Program, + type Type, +} from "@typespec/compiler"; +import type { ReactElement } from "react"; +import { useRevealSource } from "../reveal-source-context.js"; +import style from "./type-origin.module.css"; + +export interface TypeOriginProps { + readonly program: Program; + readonly type: Type; +} + +/** Show where a type was declared: the user project, the compiler standard library or a library. */ +export const TypeOrigin = ({ program, type }: TypeOriginProps) => { + const revealSource = useRevealSource(); + const context = getLocationContext(program, type); + const location = getSourceLocation(type); + const position = location.isSynthetic + ? undefined + : location.file.getLineAndCharacterOfPosition(location.pos); + + const { icon, label } = describeLocationContext(context); + const path = location.isSynthetic ? undefined : location.file.path; + const locationLabel = + path === undefined + ? "This type was not declared in a file" + : `${path}${position ? `:${position.line + 1}:${position.character + 1}` : ""}`; + + // Only the types declared in the user code can be revealed, the host is not expected to show the library files. + const canReveal = revealSource !== undefined && path !== undefined && context.type === "project"; + + const badge = ( + + {label} + {path && position && ( + + {getDisplayPath(path)}:{position.line + 1} + + )} + + ); + + if (canReveal) { + return ( + + + + ); + } + + return ( + + {badge} + + ); +}; + +/** + * Path to show next to the origin. Files coming from a package are shown relative to the package root, others are shown with their file name only. + */ +export function getDisplayPath(path: string): string { + const marker = "/node_modules/"; + const index = path.lastIndexOf(marker); + if (index === -1) { + return getBaseFileName(path); + } + const relative = path.slice(index + marker.length); + const segments = relative.split("/"); + // Drop the package name (`@scope/name` or `name`) to keep the path relative to the package root. + return segments.slice(segments[0].startsWith("@") ? 2 : 1).join("/"); +} + +function describeLocationContext(context: ReturnType): { + icon: ReactElement; + label: string; +} { + switch (context.type) { + case "project": + return { icon: , label: "Your code" }; + case "compiler": + return { icon: , label: "Standard library" }; + case "library": + return { icon: , label: context.metadata.name }; + case "synthetic": + return { icon: , label: "Synthetic" }; + } +} diff --git a/packages/html-program-viewer/src/react/type-view/type-view.module.css b/packages/html-program-viewer/src/react/type-view/type-view.module.css index 8a089eba108..f055882d749 100644 --- a/packages/html-program-viewer/src/react/type-view/type-view.module.css +++ b/packages/html-program-viewer/src/react/type-view/type-view.module.css @@ -20,12 +20,15 @@ display: flex; align-items: center; width: 100%; + gap: 10px; } .header-left { display: flex; align-items: center; gap: 10px; + flex-wrap: wrap; + min-width: 0; } .header-spacer { diff --git a/packages/html-program-viewer/src/react/type-view/type-view.tsx b/packages/html-program-viewer/src/react/type-view/type-view.tsx index 48fab1c7989..b5b50a93b67 100644 --- a/packages/html-program-viewer/src/react/type-view/type-view.tsx +++ b/packages/html-program-viewer/src/react/type-view/type-view.tsx @@ -16,6 +16,7 @@ import { InspectType } from "../inspect-type/inspect-type.js"; import { TypeDataTable } from "../inspect-type/type-data-table.js"; import { useProgram } from "../program-context.js"; import type { TreeNavigator, TypeGraphTypeNode } from "../use-tree-navigation.js"; +import { TypeOrigin } from "./type-origin.js"; import style from "./type-view.module.css"; declare global { @@ -59,6 +60,7 @@ export const TypeNodeView = ({ node }: TypeNodeViewProps) => { {node.name} +
diff --git a/packages/html-program-viewer/src/react/use-tree-navigation.tsx b/packages/html-program-viewer/src/react/use-tree-navigation.tsx index 139fb2c071a..a7dfa548e6e 100644 --- a/packages/html-program-viewer/src/react/use-tree-navigation.tsx +++ b/packages/html-program-viewer/src/react/use-tree-navigation.tsx @@ -10,6 +10,7 @@ import { } from "react"; import { isMapLike, type NamedType } from "../utils.js"; import { TypeConfig } from "./type-config.js"; +import { createIsProjectTypePredicate } from "./type-filter.js"; export interface TypeGraphNodeBase { readonly id: string; @@ -34,6 +35,11 @@ export interface TreeNavigator { readonly selectPath: (path: string) => void; readonly navToType: (type: Type) => void; readonly tree: TypeGraphListNode; + /** If the tree is currently only showing the types declared in the user project. */ + readonly onlyProjectCode: boolean; + readonly setOnlyProjectCode: (value: boolean) => void; + /** If the selected node is hidden from the tree by the {@link onlyProjectCode} filter. */ + readonly selectionHiddenByFilter: boolean; } function expandNamespaces(namespace: Namespace): Namespace[] { @@ -59,14 +65,22 @@ export interface TypeGraphNavigatorProvider { children: ReactNode; onNavigationChange?: (path: string) => void; currentPath?: string; + /** If the tree should only show the types declared in the user project by default. @default true */ + defaultOnlyProjectCode?: boolean; } export const TypeGraphNavigatorProvider = ({ program, children, onNavigationChange, currentPath, + defaultOnlyProjectCode, }: TypeGraphNavigatorProvider) => { - const treeNavigator = useTreeNavigatorInternal(program, onNavigationChange, currentPath); + const treeNavigator = useTreeNavigatorInternal( + program, + onNavigationChange, + currentPath, + defaultOnlyProjectCode, + ); return ( {children} ); @@ -76,8 +90,10 @@ function useTreeNavigatorInternal( program: Program, onNavigationChange?: (path: string) => void, currentPath?: string, + defaultOnlyProjectCode: boolean = true, ): TreeNavigator { const [selectedPath, setSelectedPath] = useState(currentPath || ""); + const [onlyProjectCode, setOnlyProjectCode] = useState(defaultOnlyProjectCode); // Update internal state when currentPath prop changes const selectPath = useCallback( @@ -95,19 +111,58 @@ function useTreeNavigatorInternal( } }, [currentPath, selectedPath]); - const tree = useMemo(() => computeTree(program), [program]); - const { pathToNode, typeToPath } = useMemo(() => computeReferences(tree), [tree]); - const selectedNode = useMemo(() => pathToNode.get(selectedPath), [pathToNode, selectedPath]); + const fullTree = useMemo(() => computeTree(program), [program]); + const projectTree = useMemo(() => filterProjectCode(program, fullTree), [program, fullTree]); + const fullReferences = useMemo(() => computeReferences(fullTree), [fullTree]); + const projectReferences = useMemo(() => computeReferences(projectTree), [projectTree]); + const tree = onlyProjectCode ? projectTree : fullTree; + const { pathToNode, typeToPath } = onlyProjectCode ? projectReferences : fullReferences; + // A node hidden from the tree by the filter is still shown when selected, so a link or a shared path to a library type keeps working. + const selectedNode = useMemo( + () => pathToNode.get(selectedPath) ?? fullReferences.pathToNode.get(selectedPath), + [pathToNode, fullReferences, selectedPath], + ); + const selectionHiddenByFilter = selectedNode !== undefined && !pathToNode.has(selectedPath); const navToType = useCallback( (type: Type) => { - const path = typeToPath.get(type); + const path = typeToPath.get(type) ?? fullReferences.typeToPath.get(type); if (path) { selectPath(path); } }, - [selectPath, typeToPath], + [selectPath, typeToPath, fullReferences], ); - return { tree, selectedPath, selectedNode, selectPath, navToType }; + return { + tree, + selectedPath, + selectedNode, + selectPath, + navToType, + onlyProjectCode, + setOnlyProjectCode, + selectionHiddenByFilter, + }; +} + +/** + * Keep only the nodes that were declared in the user project. A node is kept if it is itself declared in the project or if any of its descendants is. + */ +export function filterProjectCode(program: Program, tree: TypeGraphListNode): TypeGraphListNode { + const isProjectType = createIsProjectTypePredicate(program); + + function filterNode(node: TypeGraphNode): TypeGraphNode | undefined { + const children = node.children.map(filterNode).filter((x) => x !== undefined); + if (node.kind === "type") { + if (children.length === 0 && !isProjectType(node.type)) { + return undefined; + } + } else if (children.length === 0) { + return undefined; + } + return { ...node, children }; + } + + return { ...tree, children: tree.children.map(filterNode).filter((x) => x !== undefined) }; } function computeReferences(node: TypeGraphNode): { @@ -128,7 +183,7 @@ function computeReferences(node: TypeGraphNode): { return { pathToNode, typeToPath }; } -function computeTree(program: Program): TypeGraphListNode { +export function computeTree(program: Program): TypeGraphListNode { const root = program.getGlobalNamespaceType(); const namespaces = expandNamespaces(root); diff --git a/packages/playground/src/react/output-view/output-view.tsx b/packages/playground/src/react/output-view/output-view.tsx index 001538c9599..3fa565875cd 100644 --- a/packages/playground/src/react/output-view/output-view.tsx +++ b/packages/playground/src/react/output-view/output-view.tsx @@ -5,6 +5,7 @@ import { TabList, type SelectTabEventHandler, } from "@fluentui/react-components"; +import type { DiagnosticTarget } from "@typespec/compiler"; import { useCallback, useMemo, useState, type FunctionComponent } from "react"; import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; import type { PlaygroundEditorsOptions } from "../playground.js"; @@ -41,6 +42,10 @@ export interface OutputViewProps { * Callback when viewer state changes. */ onViewerStateChange?: (state: Record) => void; + /** + * Reveal in the editor where the given target is declared. + */ + onRevealSource?: (target: DiagnosticTarget) => void; } export const OutputView: FunctionComponent = ({ @@ -53,6 +58,7 @@ export const OutputView: FunctionComponent = ({ onViewerChange, viewerState, onViewerStateChange, + onRevealSource, }) => { const resolvedViewers = useMemo( () => resolveViewers(viewers, fileViewers), @@ -91,6 +97,7 @@ export const OutputView: FunctionComponent = ({ onViewerChange={onViewerChange} viewerState={viewerState} onViewerStateChange={onViewerStateChange} + onRevealSource={onRevealSource} /> ); @@ -126,6 +133,7 @@ const OutputViewInternal: FunctionComponent<{ onViewerChange?: (viewerKey: string) => void; viewerState?: Record; onViewerStateChange?: (state: Record) => void; + onRevealSource?: (target: DiagnosticTarget) => void; }> = ({ compilationResult, viewers, @@ -133,6 +141,7 @@ const OutputViewInternal: FunctionComponent<{ onViewerChange, viewerState, onViewerStateChange, + onRevealSource, }) => { const viewerList = Object.values(viewers.programViewers); const [internalSelected, setInternalSelected] = useState(viewerList[0].key); @@ -161,6 +170,7 @@ const OutputViewInternal: FunctionComponent<{ outputFiles={compilationResult.outputFiles} viewerState={viewerState} onViewerStateChange={onViewerStateChange} + onRevealSource={onRevealSource} /> diff --git a/packages/playground/src/react/output-view/type-graph-viewer.tsx b/packages/playground/src/react/output-view/type-graph-viewer.tsx index 54d538031d5..efdb6611ffa 100644 --- a/packages/playground/src/react/output-view/type-graph-viewer.tsx +++ b/packages/playground/src/react/output-view/type-graph-viewer.tsx @@ -9,6 +9,7 @@ const TypeGraphViewerComponent = ({ program, viewerState, onViewerStateChange, + onRevealSource, }: OutputViewerProps) => { const currentPath = viewerState?.["type-graph:path"] || ""; @@ -29,6 +30,7 @@ const TypeGraphViewerComponent = ({ program={program} currentPath={currentPath} onNavigationChange={handleNavigationChange} + onRevealSource={onRevealSource} /> ); diff --git a/packages/playground/src/react/playground.tsx b/packages/playground/src/react/playground.tsx index 33b5d199a49..b5983ab81a2 100644 --- a/packages/playground/src/react/playground.tsx +++ b/packages/playground/src/react/playground.tsx @@ -1,4 +1,4 @@ -import type { Diagnostic } from "@typespec/compiler"; +import type { Diagnostic, DiagnosticTarget } from "@typespec/compiler"; import { Pane, SplitPane } from "@typespec/react-components"; import "@typespec/react-components/style.css"; import { editor } from "monaco-editor"; @@ -258,6 +258,16 @@ export const Playground: FunctionComponent = (props) => { [host.compiler], ); + const handleRevealSource = useCallback( + (target: DiagnosticTarget) => { + const range = getMonacoRange(host.compiler, target); + editorRef.current?.setSelection(range); + editorRef.current?.revealRangeInCenterIfOutsideViewport(range); + editorRef.current?.focus(); + }, + [host.compiler], + ); + const playgroundContext = useMemo(() => { return { host, @@ -317,6 +327,7 @@ export const Playground: FunctionComponent = (props) => { compilationState={compilationState} isCompiling={isCompiling} isOutputStale={isOutputStale} + onRevealSource={handleRevealSource} editorOptions={props.editorOptions} viewers={props.viewers} fileViewers={selectedEmitter ? props.emitterViewers?.[selectedEmitter] : undefined} diff --git a/packages/playground/src/react/types.ts b/packages/playground/src/react/types.ts index b4200d3e2e8..37c4c897e71 100644 --- a/packages/playground/src/react/types.ts +++ b/packages/playground/src/react/types.ts @@ -1,4 +1,4 @@ -import type { Program } from "@typespec/compiler"; +import type { DiagnosticTarget, Program } from "@typespec/compiler"; import type { ReactNode } from "react"; export type CompilationCrashed = { @@ -21,6 +21,8 @@ export interface OutputViewerProps { readonly viewerState?: Record; /** Callback to update viewer state */ readonly onViewerStateChange?: (state: Record) => void; + /** Reveal in the editor where the given target is declared. */ + readonly onRevealSource?: (target: DiagnosticTarget) => void; } export interface ProgramViewer {