From 003c0625665f1dbf197f79aea51aa9f8e2aa8117 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sat, 30 May 2026 17:39:56 +0530 Subject: [PATCH 01/11] =?UTF-8?q?feat(extension):=20slice=20033=20phase=20?= =?UTF-8?q?1=20=E2=80=94=20layer/fw=20tokens=20+=20shell=20layout=20consta?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish the design-token and layout-constant groundwork for the GraphView mockup replica (spec 033): - Add --layer-* and --fw-* custom properties to the nonce-guarded :root block in html.ts (the only place global custom properties cascade under the CSP; a CSS Module :root would be scoped/hashed). - Add components/shellLayout.ts with the grid templates the mockup pins (SHELL_COLUMNS/ROWS, MERMAID_ROWS/BODY_COLUMNS, TAB_STRIP_HEIGHT, WORKSPACES_CARD_MIN_WIDTH) plus co-located tests. - Add App.module.css scaffolding for the tab strip + 3-column shell grid (wired into App.tsx in phase 2). - Point .specify/feature.json at specs/033-graphview-mockup-replica. Refs: #133 --- packages/extension/src/webview/App.module.css | 140 ++++++++++++++++++ .../webview/components/shellLayout.test.tsx | 31 ++++ .../src/webview/components/shellLayout.ts | 32 ++++ packages/extension/src/webview/html.ts | 22 +++ 4 files changed, 225 insertions(+) create mode 100644 packages/extension/src/webview/App.module.css create mode 100644 packages/extension/src/webview/components/shellLayout.test.tsx create mode 100644 packages/extension/src/webview/components/shellLayout.ts diff --git a/packages/extension/src/webview/App.module.css b/packages/extension/src/webview/App.module.css new file mode 100644 index 0000000..3dd2551 --- /dev/null +++ b/packages/extension/src/webview/App.module.css @@ -0,0 +1,140 @@ +/* + * App shell + tab strip (slice 033, Phase 2). + * + * Translates the editor-tab strip and the GraphView/Trace 3-column grid from + * scratch/graphview-mockup-final.html into scoped CSS Module classes. Grid + * templates mirror the literals in components/shellLayout.ts exactly; keep them + * in sync. Only VS Code theme tokens are used (FR-026). + */ + +.window { + display: grid; + grid-template-rows: 32px 1fr; + height: 100%; + width: 100%; + min-height: 0; +} + +/* ---- Tab strip ---------------------------------------------------------- */ + +.tabs { + display: flex; + background: var(--vscode-tab-inactiveBackground); + border-bottom: 1px solid var(--vscode-tab-border); + overflow: hidden; +} + +.tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 14px; + height: 32px; + background: var(--vscode-tab-inactiveBackground); + color: var(--vscode-tab-inactiveForeground); + cursor: pointer; + border: 0; + border-right: 1px solid var(--vscode-tab-border); + font: inherit; + font-size: 12px; + white-space: nowrap; + user-select: none; +} + +.tab:hover:not(.tabActive):not(:disabled) { + background: var(--vscode-tab-hoverBackground, var(--vscode-tab-inactiveBackground)); +} + +.tab:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.tabActive { + background: var(--vscode-tab-activeBackground); + color: var(--vscode-tab-activeForeground); + border-bottom: 1px solid var(--vscode-tab-activeBackground); +} + +.tab:disabled, +.tab[aria-disabled="true"] { + color: var(--vscode-disabledForeground); + cursor: default; +} + +.tabDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--vscode-charts-green); + flex-shrink: 0; +} + +/* ---- Scene container ---------------------------------------------------- */ + +.scene { + min-height: 0; + height: 100%; + overflow: hidden; +} + +/* ---- GraphView / Trace shell grid -------------------------------------- */ + +.graphShell { + display: grid; + grid-template-columns: 260px 1fr 320px; + grid-template-rows: 44px 1fr 28px; + grid-template-areas: + "toolbar toolbar toolbar" + "left canvas right" + "status status status"; + height: 100%; + width: 100%; + min-height: 0; +} + +.shellToolbar { + grid-area: toolbar; + min-width: 0; +} + +.shellLeft { + grid-area: left; + min-width: 0; + overflow-y: auto; + background: var(--vscode-panel-background); + border-right: 1px solid var(--vscode-panel-border); +} + +.shellCanvas { + grid-area: canvas; + position: relative; + min-width: 0; + overflow: hidden; +} + +.shellRight { + grid-area: right; + min-width: 0; + overflow-y: auto; + background: var(--vscode-panel-background); + border-left: 1px solid var(--vscode-panel-border); + display: flex; + flex-direction: column; +} + +.shellStatus { + grid-area: status; + min-width: 0; +} + +/* + * Below ~800px the fixed rails would crowd the canvas off-screen. Hold a + * readable minimum and let the canvas take the squeeze (spec edge case: + * "rails should maintain minimum readable widths rather than collapsing"). + */ +@media (max-width: 800px) { + .graphShell { + grid-template-columns: 200px 1fr 240px; + } +} diff --git a/packages/extension/src/webview/components/shellLayout.test.tsx b/packages/extension/src/webview/components/shellLayout.test.tsx new file mode 100644 index 0000000..4002a43 --- /dev/null +++ b/packages/extension/src/webview/components/shellLayout.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { + SHELL_COLUMNS, + SHELL_ROWS, + MERMAID_ROWS, + MERMAID_BODY_COLUMNS, + TAB_STRIP_HEIGHT, + WORKSPACES_CARD_MIN_WIDTH, + WORKSPACES_GRID_COLUMNS, +} from "./shellLayout.js"; + +describe("shellLayout constants", () => { + it("pins the GraphView shell grid to the mockup proportions", () => { + expect(SHELL_COLUMNS).toBe("260px 1fr 320px"); + expect(SHELL_ROWS).toBe("44px 1fr 28px"); + }); + + it("pins the Mermaid shell rows and body split", () => { + expect(MERMAID_ROWS).toBe("44px 44px 1fr 56px"); + expect(MERMAID_BODY_COLUMNS).toBe("2fr 1fr"); + }); + + it("pins the tab strip height", () => { + expect(TAB_STRIP_HEIGHT).toBe(32); + }); + + it("derives the responsive workspaces grid from the card min width", () => { + expect(WORKSPACES_CARD_MIN_WIDTH).toBe("320px"); + expect(WORKSPACES_GRID_COLUMNS).toBe("repeat(auto-fill, minmax(320px, 1fr))"); + }); +}); diff --git a/packages/extension/src/webview/components/shellLayout.ts b/packages/extension/src/webview/components/shellLayout.ts new file mode 100644 index 0000000..cf2e15b --- /dev/null +++ b/packages/extension/src/webview/components/shellLayout.ts @@ -0,0 +1,32 @@ +/** + * Shell layout constants (slice 033). + * + * Single source of truth for the grid templates the mockup pins + * (scratch/graphview-mockup-final.html). The CSS Modules that own the actual + * layout reference the same literal values; these constants exist so tests can + * assert against one canonical definition and so any future tweak changes one + * place. They are deliberately `as const` string templates matching the CSS + * `grid-template-*` syntax exactly. + */ + +/** GraphView + Trace shell columns: left rail / canvas / right rail. */ +export const SHELL_COLUMNS = "260px 1fr 320px" as const; + +/** GraphView + Trace shell rows: toolbar / body / status bar. */ +export const SHELL_ROWS = "44px 1fr 28px" as const; + +/** Mermaid preview shell rows: toolbar / status / body / export bar. */ +export const MERMAID_ROWS = "44px 44px 1fr 56px" as const; + +/** Mermaid body split: rendered diagram (2fr) / source text (1fr). */ +export const MERMAID_BODY_COLUMNS = "2fr 1fr" as const; + +/** Editor-style tab strip height, in pixels. */ +export const TAB_STRIP_HEIGHT = 32 as const; + +/** Minimum width of a workspace card before the responsive grid wraps. */ +export const WORKSPACES_CARD_MIN_WIDTH = "320px" as const; + +/** Responsive workspaces card grid template. */ +export const WORKSPACES_GRID_COLUMNS = + `repeat(auto-fill, minmax(${WORKSPACES_CARD_MIN_WIDTH}, 1fr))` as const; diff --git a/packages/extension/src/webview/html.ts b/packages/extension/src/webview/html.ts index 81ffc0f..7dd4323 100644 --- a/packages/extension/src/webview/html.ts +++ b/packages/extension/src/webview/html.ts @@ -58,6 +58,28 @@ export function getWebviewContent(webview: vscode.Webview, extensionUri: vscode. background-color: var(--vscode-editor-background); } + /* Dextree design tokens (slice 033). These must live in this nonce-guarded + inline block — not a CSS Module — because custom properties declared in a + module :root get scoped/hashed and would not cascade to every component. + Hex values match scratch/graphview-mockup-final.html: there is no + --vscode-* token covering architectural layers or framework identity, so + these are the one allowed exception to the "tokens only" rule (FR-026). */ + :root { + /* Architectural-layer palette (classifier output, slice 026) */ + --layer-entry: #f1c40f; + --layer-orchestration: #3794ff; + --layer-domain: #2ecc71; + --layer-io: #e67e22; + --layer-util: #9b9b9b; + --layer-dead: #6b3535; + + /* Framework chip palette */ + --fw-vscode: #007acc; + --fw-react: #61dafb; + --fw-vitest: #6e9f18; + --fw-node: #68a063; + } + #root { height: 100%; overflow: hidden; From 4c1212334f2b7d73759530f95e7846fb86e82767 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sat, 30 May 2026 17:52:35 +0530 Subject: [PATCH 02/11] =?UTF-8?q?feat(extension):=20slice=20033=20phase=20?= =?UTF-8?q?2=20=E2=80=94=204-tab=20strip=20+=203-column=20GraphView=20shel?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the webview shell to match the mockup (spec 033, US1+US2): - App.tsx renders an editor-style 4-tab strip (GraphView / Mermaid Preview / Trace mode / Workspaces) above every scene. Active tab derives from the existing activeScene state — no new state machine, protocol unchanged (CC-007). GraphView tab shows the green workspace-loaded dot and appends the workspace name once loaded. - The GraphView scene is wrapped in the 260px/1fr/320px CSS Grid shell (App.module.css) with canvas + right-rail columns. Left rail, toolbar, and status bar are populated in phase 3. - Trace tab is disabled at the App level (trace state is owned internally by GraphView); the trace-active bridge lands in phase 5. - 6 new App tests (tab order, active-state reflection, workspace-name label, disabled trace tab, tab→scene switching, shell-grid structure). Full extension suite: 462 passed, zero regressions. Refs: #133 --- packages/extension/src/webview/App.test.tsx | 88 ++++ packages/extension/src/webview/App.tsx | 490 ++++++++++++-------- 2 files changed, 390 insertions(+), 188 deletions(-) diff --git a/packages/extension/src/webview/App.test.tsx b/packages/extension/src/webview/App.test.tsx index 4dd398b..1f1ac98 100644 --- a/packages/extension/src/webview/App.test.tsx +++ b/packages/extension/src/webview/App.test.tsx @@ -428,4 +428,92 @@ describe("App", () => { expect(screen.queryByTestId("workspaces-page")).toBeNull(); }); }); + + describe("tab strip (slice 033 US1)", () => { + it("renders the four scene tabs in mockup order", () => { + render(); + + const tabs = screen.getAllByRole("tab"); + expect(tabs.map((t) => t.getAttribute("data-testid"))).toEqual([ + "tab-graph", + "tab-mermaid", + "tab-trace", + "tab-workspaces", + ]); + expect(screen.getByTestId("tab-graph").textContent).toContain("GraphView"); + expect(screen.getByTestId("tab-mermaid").textContent).toContain("Mermaid Preview"); + expect(screen.getByTestId("tab-trace").textContent).toContain("Trace mode"); + expect(screen.getByTestId("tab-workspaces").textContent).toContain("Workspaces"); + }); + + it("marks the GraphView tab active by default and reflects the active scene", () => { + render(); + + expect(screen.getByTestId("tab-graph").getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("tab-mermaid").getAttribute("aria-selected")).toBe("false"); + + act(() => { + fireEvent.click(screen.getByTestId("tab-mermaid")); + }); + + expect(screen.getByTestId("tab-mermaid").getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("tab-graph").getAttribute("aria-selected")).toBe("false"); + }); + + it("appends the workspace name to the GraphView tab once a workspace loads", () => { + render(); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { ...mockGraphMessage, workspaceName: "dextree", workspaceFrameworks: [] }, + }), + ); + }); + + expect(screen.getByTestId("tab-graph").textContent).toContain("GraphView · dextree"); + }); + + it("disables the Trace tab until trace mode is wired (phase 5)", () => { + render(); + + const traceTab = screen.getByTestId("tab-trace"); + expect(traceTab.getAttribute("aria-disabled")).toBe("true"); + expect((traceTab as HTMLButtonElement).disabled).toBe(true); + }); + + it("switches to the Workspaces scene when the Workspaces tab is clicked", () => { + render(); + + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: mockGraphMessage })); + }); + vscodeApi.postMessage.mockClear(); + + act(() => { + fireEvent.click(screen.getByTestId("tab-workspaces")); + }); + + expect(vscodeApi.postMessage).toHaveBeenCalledWith({ type: "requestWorkspaceList" }); + expect(screen.getByTestId("workspaces-page")).toBeTruthy(); + expect(screen.getByTestId("tab-workspaces").getAttribute("aria-selected")).toBe("true"); + }); + }); + + describe("graph shell grid (slice 033 US2)", () => { + it("wraps the graph scene in the 3-column grid shell with canvas + right rail", () => { + render(); + + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: mockGraphMessage })); + }); + + const shell = screen.getByTestId("graph-shell"); + expect(shell).toBeTruthy(); + // GraphView (mocked) renders inside the shell's canvas column. + expect(shell.contains(screen.getByTestId("graph-view"))).toBe(true); + // The right rail (graph info aside) lives in the same shell. + expect(shell.querySelector('[aria-label="Graph info"]')).toBeTruthy(); + }); + }); }); diff --git a/packages/extension/src/webview/App.tsx b/packages/extension/src/webview/App.tsx index 4c72778..2dc6e5e 100644 --- a/packages/extension/src/webview/App.tsx +++ b/packages/extension/src/webview/App.tsx @@ -1,6 +1,7 @@ import type { GraphEdge, GraphNode } from "@dextree/core"; import type { MermaidPreviewOptions, MermaidPreviewResult } from "@dextree/exporters"; import { useEffect, useMemo, useReducer, useState } from "react"; +import appStyles from "./App.module.css"; import { EmptyState } from "./components/EmptyState.js"; import { GraphView } from "./components/GraphView.js"; import { LoadingState } from "./components/LoadingState.js"; @@ -24,6 +25,14 @@ import type { MermaidPreviewFileFormat } from "./preview/exportPreview.js"; type AppScene = "graph" | "workspaces" | "mermaid-preview"; +/** + * Tab identity for the editor-style strip (slice 033 US1). Distinct from + * {@link AppScene}: "trace" is a *variant* of the graph scene (driven by + * GraphView's internal trace state), not a separate scene — so it maps back to + * the "graph" scene when activated. + */ +type TabKey = "graph" | "mermaid" | "trace" | "workspaces"; + // --------------------------------------------------------------------------- // State model — discriminated union (FR-002, FR-008) // --------------------------------------------------------------------------- @@ -77,6 +86,48 @@ function reducer(state: AppState, action: AppAction): AppState { } } +// --------------------------------------------------------------------------- +// Tab strip (slice 033 US1) +// --------------------------------------------------------------------------- + +interface TabDescriptor { + key: TabKey; + label: string; + codicon: string; + /** Disabled tabs render muted and are not clickable. */ + disabled?: boolean; + /** Shows the green workspace-loaded dot before the icon. */ + showDot?: boolean; + onSelect: () => void; +} + +function TabStrip({ tabs, activeKey }: { tabs: TabDescriptor[]; activeKey: TabKey }) { + return ( +
+ {tabs.map((tab) => { + const isActive = tab.key === activeKey; + return ( + + ); + })} +
+ ); +} + // --------------------------------------------------------------------------- // App component // --------------------------------------------------------------------------- @@ -302,208 +353,271 @@ export function App({ vscodeApi }: AppProps) { return parts[parts.length - 1] ?? name; } - if (activeScene === "workspaces") { - return ( - - ); - } - - if (activeScene === "mermaid-preview") { - return ( -
-
- -
- setActiveScene("graph"), + }, + { + key: "mermaid", + label: "Mermaid Preview", + codicon: "export", + onSelect: () => setActiveScene("mermaid-preview"), + }, + { + key: "trace", + label: "GraphView (Trace mode)", + codicon: "rocket", + // Trace is entered from within GraphView; the tab is a status indicator + // until the trace-active bridge lands in phase 5. + disabled: true, + onSelect: () => setActiveScene("graph"), + }, + { + key: "workspaces", + label: "Workspaces", + codicon: "database", + onSelect: handleWorkspaceSwitcherClick, + }, + ]; + + const tabStrip = ; + + function renderScene() { + if (activeScene === "workspaces") { + return ( + -
- ); - } - - if (showEmptyState) { - return ; - } + ); + } - return ( -
-
- {hasGraph ? ( - { - handleCommand("export-mermaid"); - }} - onExportCurrentView={() => { - vscodeApi.postMessage({ type: "exportCurrentView", viewId: "graph-view" }); - }} - onExportTraceSequence={(trace: TraceSequenceSnapshot) => { - const message: ExportTraceSequenceMessage = { type: "exportTraceSequence", trace }; - vscodeApi.postMessage(message); - }} - {...(state.workspaceName !== null && { workspaceName: state.workspaceName })} - workspaceFrameworks={state.workspaceFrameworks} - onWorkspaceSwitcherClick={handleWorkspaceSwitcherClick} - /> - ) : ( - +
+ ); + } - {showLoadingOverlay ? ( - state.indexing !== null ? ( - - ) : ( - - ) - ) : null} - - +
+ ); + } + + return ( +
+ {tabStrip} +
{renderScene()}
); } From b474fbafec9f01e1c84efd3a5dd84b782e7fa8b4 Mon Sep 17 00:00:00 2001 From: dgtalbug Date: Sat, 30 May 2026 21:03:11 +0530 Subject: [PATCH 03/11] feat: enhance InspectorPanel with additional badges and neighbor functionality - Updated INSPECTOR_BADGE_KEYS to include "kind" and "exported". - Introduced InspectorNeighbor and InspectorNeighbors interfaces for neighbor data. - Added rendering functions for new badge types in InspectorPanel. - Implemented NeighborSection component to display related nodes. - Adjusted PopulatedState to incorporate neighbor data and actions. feat: improve LensesPanel with mockup structure tests - Added tests for LensesPanel to verify lens row structure including icon, title, description, and count badge. - Ensured correct activation state for lens rows. style: update LensesPanel header title to uppercase - Changed header title from "Lenses" to "LENSES" for consistency. style: refactor NodeFilterPanel styles for improved layout - Updated CSS for NodeFilterPanel to enhance layout and styling. - Introduced new styles for section headers, actions, and filter rows. test: enhance NodeFilterPanel tests for new features - Added tests for section header with All/None links. - Verified rendering of filter rows with checkbox, codicon, label, and count badge. - Updated tests to reflect changes in component structure and behavior. feat: extend GraphViewProps with new workspace actions - Added onTraceActiveChange, onReindex, onClearWorkspace, onClearAll, onToggleSourceOnly, sourceOnly, and isIndexing props. style: add edge-kind dot colors to webview CSS - Introduced new CSS variables for edge colors based on edge types to enhance visual representation in the graph view. --- packages/extension/src/webview/App.module.css | 16 +- packages/extension/src/webview/App.test.tsx | 105 +-- packages/extension/src/webview/App.tsx | 259 ++----- .../components/EdgeTypesPanel.module.css | 95 +++ .../components/EdgeTypesPanel.test.tsx | 149 ++++ .../src/webview/components/EdgeTypesPanel.tsx | 154 +++++ .../components/GraphToolbar.module.css | 147 ++++ .../webview/components/GraphToolbar.test.tsx | 462 +++++-------- .../src/webview/components/GraphToolbar.tsx | 424 ++++++------ .../webview/components/GraphView.module.css | 116 ++++ .../src/webview/components/GraphView.test.tsx | 242 +++---- .../src/webview/components/GraphView.tsx | 634 ++++++++++++------ .../components/InspectorPanel.module.css | 201 +++--- .../components/InspectorPanel.test.tsx | 178 ++--- .../src/webview/components/InspectorPanel.tsx | 265 +++++--- .../webview/components/LensesPanel.test.tsx | 48 ++ .../src/webview/components/LensesPanel.tsx | 18 +- .../components/NodeFilterPanel.module.css | 110 +-- .../components/NodeFilterPanel.test.tsx | 119 ++-- .../webview/components/NodeFilterPanel.tsx | 106 ++- .../src/webview/components/graphViewTypes.ts | 12 + packages/extension/src/webview/html.ts | 26 + 22 files changed, 2390 insertions(+), 1496 deletions(-) create mode 100644 packages/extension/src/webview/components/EdgeTypesPanel.module.css create mode 100644 packages/extension/src/webview/components/EdgeTypesPanel.test.tsx create mode 100644 packages/extension/src/webview/components/EdgeTypesPanel.tsx create mode 100644 packages/extension/src/webview/components/GraphToolbar.module.css create mode 100644 packages/extension/src/webview/components/GraphView.module.css diff --git a/packages/extension/src/webview/App.module.css b/packages/extension/src/webview/App.module.css index 3dd2551..5922743 100644 --- a/packages/extension/src/webview/App.module.css +++ b/packages/extension/src/webview/App.module.css @@ -78,7 +78,21 @@ overflow: hidden; } -/* ---- GraphView / Trace shell grid -------------------------------------- */ +/* ---- GraphView scene wrapper ------------------------------------------- */ + +/* + * GraphView owns its own 3-column shell grid (components/GraphView.module.css), + * so the App scene just gives it a full-bleed positioned box. Empty/Loading + * overlays (.dxt-graph-overlay) are absolutely positioned children on top. + */ +.graphScene { + position: relative; + height: 100%; + width: 100%; + min-height: 0; +} + +/* ---- GraphView / Trace shell grid (legacy; kept for shell-grid contract) - */ .graphShell { display: grid; diff --git a/packages/extension/src/webview/App.test.tsx b/packages/extension/src/webview/App.test.tsx index 1f1ac98..72a8609 100644 --- a/packages/extension/src/webview/App.test.tsx +++ b/packages/extension/src/webview/App.test.tsx @@ -9,6 +9,12 @@ vi.mock("./components/GraphView.js", () => ({ workspaceName, workspaceFrameworks, onWorkspaceSwitcherClick, + onReindex, + onClearWorkspace, + onClearAll, + onToggleSourceOnly, + sourceOnly, + isIndexing, }: { nodes: { filePath: string; startLine: number }[]; edges: unknown[]; @@ -16,6 +22,12 @@ vi.mock("./components/GraphView.js", () => ({ workspaceName?: string; workspaceFrameworks?: readonly string[]; onWorkspaceSwitcherClick?: () => void; + onReindex?: () => void; + onClearWorkspace?: () => void; + onClearAll?: () => void; + onToggleSourceOnly?: () => void; + sourceOnly?: boolean; + isIndexing?: boolean; }) => ( <> + {/* Relocated workspace actions (slice 033) — App threads these into the + GraphView toolbar; the mock surfaces them so App wiring is testable. */} + + + + +
{String(sourceOnly ?? false)}
+
{String(isIndexing ?? false)}
), })); @@ -185,7 +213,7 @@ describe("App", () => { }); }); - it("posts command messages when panel action buttons are clicked", () => { + it("threads relocated workspace-action handlers into GraphView (slice 033)", () => { render(); vscodeApi.postMessage.mockClear(); @@ -193,55 +221,53 @@ describe("App", () => { window.dispatchEvent(new MessageEvent("message", { data: mockGraphMessage })); }); - // "Clear Workspace" button should dispatch a command - const clearBtn = screen.getByTitle("Clear the current workspace index"); - fireEvent.click(clearBtn); - + // The Re-index / Clear actions moved from App's legacy panel into the + // GraphView toolbar; App still owns the command dispatch via the handlers + // it passes down. + fireEvent.click(screen.getByTestId("gv-clear-workspace")); expect(vscodeApi.postMessage).toHaveBeenCalledWith({ type: "command", command: "clear-workspace", }); - }); - describe("legend rendering (US3 FR-010)", () => { - it("shows DEFINES and IMPORTS as fallback when presentEdgeKinds is empty", () => { - render(); - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { ...mockGraphMessage, presentEdgeKinds: [] }, - }), - ); - }); - expect(screen.getByText("DEFINES")).toBeTruthy(); - expect(screen.getByText("IMPORTS")).toBeTruthy(); + fireEvent.click(screen.getByTestId("gv-reindex")); + expect(vscodeApi.postMessage).toHaveBeenCalledWith({ + type: "command", + command: "index-workspace", }); - it("renders exactly the provided edge kinds when presentEdgeKinds is non-empty", () => { - render(); - act(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { ...mockGraphMessage, presentEdgeKinds: ["DEFINES", "CALLS"] }, - }), - ); - }); - expect(screen.getByText("DEFINES")).toBeTruthy(); - expect(screen.getByText("CALLS")).toBeTruthy(); + fireEvent.click(screen.getByTestId("gv-clear-all")); + expect(vscodeApi.postMessage).toHaveBeenCalledWith({ + type: "command", + command: "clear-all", }); + }); - it("renders a custom pill with dxt-legend-pill-custom class for CUSTOM_* kinds", () => { + it("toggles source-only and reflects it down to GraphView (slice 033)", () => { + render(); + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: mockGraphMessage })); + }); + + expect(screen.getByTestId("gv-source-only-state").textContent).toBe("false"); + fireEvent.click(screen.getByTestId("gv-source-only")); + expect(screen.getByTestId("gv-source-only-state").textContent).toBe("true"); + }); + + describe("edge legend (US3 FR-010)", () => { + // The edge legend moved out of App's legacy right panel and into GraphView + // (canvas legend + Edge Types rail, covered by GraphView/EdgeTypesPanel + // tests). App must no longer render its own legend pills. + it("does not render the legacy App-level legend pills", () => { render(); act(() => { window.dispatchEvent( new MessageEvent("message", { - data: { ...mockGraphMessage, presentEdgeKinds: ["CUSTOM_FOO"] }, + data: { ...mockGraphMessage, presentEdgeKinds: ["DEFINES", "CALLS"] }, }), ); }); - expect(screen.getByText("CUSTOM_FOO")).toBeTruthy(); - const pill = document.querySelector(".dxt-legend-pill-custom"); - expect(pill).toBeTruthy(); + expect(document.querySelector('[class*="dxt-legend-pill"]')).toBeNull(); }); }); @@ -500,8 +526,8 @@ describe("App", () => { }); }); - describe("graph shell grid (slice 033 US2)", () => { - it("wraps the graph scene in the 3-column grid shell with canvas + right rail", () => { + describe("graph scene (slice 033 US2)", () => { + it("renders GraphView filling the scene; GraphView owns the shell, not App", () => { render(); act(() => { @@ -510,10 +536,11 @@ describe("App", () => { const shell = screen.getByTestId("graph-shell"); expect(shell).toBeTruthy(); - // GraphView (mocked) renders inside the shell's canvas column. + // GraphView (mocked) renders inside the scene. expect(shell.contains(screen.getByTestId("graph-view"))).toBe(true); - // The right rail (graph info aside) lives in the same shell. - expect(shell.querySelector('[aria-label="Graph info"]')).toBeTruthy(); + // App no longer renders its own legacy "Graph info" right rail — the + // 3-column shell (rails, toolbar, status) is owned by GraphView now. + expect(shell.querySelector('[aria-label="Graph info"]')).toBeNull(); }); }); }); diff --git a/packages/extension/src/webview/App.tsx b/packages/extension/src/webview/App.tsx index 2dc6e5e..802e7f6 100644 --- a/packages/extension/src/webview/App.tsx +++ b/packages/extension/src/webview/App.tsx @@ -149,9 +149,6 @@ export function App({ vscodeApi }: AppProps) { workspaceFrameworks: [], }); - const [indexedCount, setIndexedCount] = useState(0); - const [failedCount, setFailedCount] = useState(0); - const [lastIndexedFiles, setLastIndexedFiles] = useState([]); const [showSourceOnly, setShowSourceOnly] = useState(false); const [activeScene, setActiveScene] = useState("graph"); const [workspaceList, setWorkspaceList] = useState(null); @@ -222,23 +219,6 @@ export function App({ vscodeApi }: AppProps) { console.log(`[App] dispatching indexing: phase=${"phase" in msg ? String(msg.phase) : "?"}`); dispatch({ type: "indexing", message: msg }); - - if (msg.type === "indexing") { - if (msg.phase === "progress" && msg.fileName) { - const name = msg.fileName; - setLastIndexedFiles((prev) => [name, ...prev.filter((f) => f !== name)].slice(0, 8)); - setFailedCount(msg.failed); - } - if (msg.phase === "finished") { - setIndexedCount(msg.current - msg.failed); - setFailedCount(msg.failed); - } - if (msg.phase === "starting") { - setLastIndexedFiles([]); - setFailedCount(0); - setIndexedCount(0); - } - } } window.addEventListener("message", handleMessage); @@ -338,21 +318,6 @@ export function App({ vscodeApi }: AppProps) { [showSourceOnly, state.edges, displayNodeIds], ); - const nodeCount = displayNodes.length; - const edgeCount = displayEdges.length; - const fileCount = displayNodes.filter((n) => n.type === "file").length; - const symbolCount = displayNodes.filter((n) => n.type === "symbol").length; - // During active indexing, show live progress in Files; use "—" for counts not yet known. - const displayFileCount = isIndexingActive && state.indexing ? state.indexing.current : fileCount; - const displayNodeCount: number | "—" = isIndexingActive ? "—" : nodeCount; - const displayEdgeCount: number | "—" = isIndexingActive ? "—" : edgeCount; - const displaySymbolCount: number | "—" = isIndexingActive ? "—" : symbolCount; - - function formatFileLabel(name: string): string { - const parts = name.split(/[/\\]/); - return parts[parts.length - 1] ?? name; - } - // ---- Tab strip model (slice 033 US1) -------------------------------------- // The active tab derives from activeScene. "trace" is a graph-scene variant // owned by GraphView; at the App level it stays disabled until trace wiring @@ -434,182 +399,76 @@ export function App({ vscodeApi }: AppProps) { ); } - if (showEmptyState) { - return ; - } - return renderGraphScene(); } + // GraphView owns the full 3-column shell (toolbar / rails / status) as of + // slice 033 Phase 3. App no longer renders a competing grid or right-rail + // "Graph info" panel — the workspace actions (Re-index / Clear / Source-only) + // are threaded into the toolbar, counts live in GraphView's status bar, and + // Empty/Loading render as overlays on top of the shell (T020). function renderGraphScene() { return (
-
- {hasGraph ? ( - { - handleCommand("export-mermaid"); - }} - onExportCurrentView={() => { - vscodeApi.postMessage({ type: "exportCurrentView", viewId: "graph-view" }); - }} - onExportTraceSequence={(trace: TraceSequenceSnapshot) => { - const message: ExportTraceSequenceMessage = { type: "exportTraceSequence", trace }; - vscodeApi.postMessage(message); - }} - {...(state.workspaceName !== null && { workspaceName: state.workspaceName })} - workspaceFrameworks={state.workspaceFrameworks} - onWorkspaceSwitcherClick={handleWorkspaceSwitcherClick} - /> - ) : ( - ); } diff --git a/packages/extension/src/webview/components/EdgeTypesPanel.module.css b/packages/extension/src/webview/components/EdgeTypesPanel.module.css new file mode 100644 index 0000000..d818991 --- /dev/null +++ b/packages/extension/src/webview/components/EdgeTypesPanel.module.css @@ -0,0 +1,95 @@ +/* EdgeTypesPanel — right-rail edge type filter (slice 033 US2) */ + +.container { + border-bottom: 1px solid var(--vscode-panel-border); +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px 6px; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); +} + +.sectionActions { + display: inline-flex; + gap: 6px; + text-transform: none; + font-weight: 400; +} + +.linkBtn { + background: none; + border: 0; + color: var(--vscode-charts-blue); + cursor: pointer; + font: inherit; + font-size: 11px; + padding: 0; +} + +.linkBtn:hover { + text-decoration: underline; +} + +.sectionBody { + padding: 0 6px 10px; + display: flex; + flex-direction: column; + gap: 1px; +} + +.filterRow { + display: grid; + grid-template-columns: 16px 12px 1fr auto; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + user-select: none; + color: var(--vscode-foreground); +} + +.filterRow:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.filterRow input[type="checkbox"] { + margin: 0; + accent-color: var(--vscode-button-background); +} + +.filterRowDisabled { + cursor: not-allowed; + opacity: 0.55; + color: var(--vscode-disabledForeground); +} + +.edgeDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--vscode-foreground); + margin-left: 4px; +} + +.filterCount { + font-variant-numeric: tabular-nums; + color: var(--vscode-badge-foreground); + background: var(--vscode-badge-background); + border-radius: 10px; + padding: 1px 8px; + font-size: 11px; +} + +.emptyState { + padding: 8px 10px; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} diff --git a/packages/extension/src/webview/components/EdgeTypesPanel.test.tsx b/packages/extension/src/webview/components/EdgeTypesPanel.test.tsx new file mode 100644 index 0000000..7dbc9e7 --- /dev/null +++ b/packages/extension/src/webview/components/EdgeTypesPanel.test.tsx @@ -0,0 +1,149 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { EdgeTypesPanel, type EdgeTypeEntry } from "./EdgeTypesPanel.js"; + +const sampleEntries: EdgeTypeEntry[] = [ + { + kind: "CONTAINS", + label: "Contains", + count: 169, + disabled: true, + tooltip: "Folder containment is always shown", + }, + { kind: "DEFINES", label: "Defines", count: 1024 }, + { kind: "IMPORTS", label: "Imports", count: 386 }, + { kind: "CALLS", label: "Calls", count: 2100 }, + { kind: "INHERITS", label: "Extends", count: 42 }, + { + kind: "IMPLEMENTS", + label: "Implements", + count: 31, + disabled: true, + tooltip: "Implements edges are always shown", + }, +]; + +function renderPanel(overrides?: { + hiddenKinds?: Set; + onToggle?: ReturnType; +}) { + const onToggle = overrides?.onToggle ?? vi.fn(); + const hiddenKinds = overrides?.hiddenKinds ?? new Set(); + const result = render( + , + ); + return { ...result, onToggle }; +} + +describe("EdgeTypesPanel", () => { + afterEach(() => cleanup()); + + it("renders the section header with All / None link buttons", () => { + renderPanel(); + + expect(screen.getByText("Edge Types")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Show all edge types" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Hide all edge types" })).toBeTruthy(); + }); + + it("renders one row per entry with checkbox, colored dot, label, and count", () => { + const { container } = renderPanel(); + + for (const entry of sampleEntries) { + const row = screen.getByTestId(`edge-row-${entry.kind}`); + expect(within(row).getByText(entry.label)).toBeTruthy(); + expect(within(row).getByText(String(entry.count))).toBeTruthy(); + expect(row.querySelector('input[type="checkbox"]')).toBeTruthy(); + } + // Each row carries a colored dot element. + expect(container.querySelectorAll('[data-testid="edge-dot"]').length).toBe( + sampleEntries.length, + ); + }); + + it("colors each dot from the edge kind (token resolves to a real value, not undefined)", () => { + renderPanel(); + + // The dot's color must come from a defined --edge-color- custom + // property. A bare `var(--edge-color-defines)` with no fallback renders + // colorless when the token is undefined — assert a fallback is present. + const dot = within(screen.getByTestId("edge-row-DEFINES")).getByTestId("edge-dot"); + const bg = (dot as HTMLElement).style.background || (dot as HTMLElement).style.backgroundColor; + expect(bg).toMatch(/var\(--edge-color-defines,/); + }); + + it("checks visible kinds and unchecks hidden kinds", () => { + renderPanel({ hiddenKinds: new Set(["IMPORTS"]) }); + + const importsRow = screen.getByTestId("edge-row-IMPORTS"); + const definesRow = screen.getByTestId("edge-row-DEFINES"); + expect((importsRow.querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe( + false, + ); + expect((definesRow.querySelector('input[type="checkbox"]') as HTMLInputElement).checked).toBe( + true, + ); + }); + + it("renders disabled rows for CONTAINS and IMPLEMENTS with a tooltip", () => { + renderPanel(); + + const containsRow = screen.getByTestId("edge-row-CONTAINS"); + const implementsRow = screen.getByTestId("edge-row-IMPLEMENTS"); + expect(containsRow.getAttribute("aria-disabled")).toBe("true"); + expect(implementsRow.getAttribute("aria-disabled")).toBe("true"); + expect(containsRow.getAttribute("title")).toBe("Folder containment is always shown"); + expect((containsRow.querySelector('input[type="checkbox"]') as HTMLInputElement).disabled).toBe( + true, + ); + }); + + it("does not call onToggle when a disabled row is clicked", () => { + const { onToggle } = renderPanel(); + + fireEvent.click( + screen.getByTestId("edge-row-CONTAINS").querySelector('input[type="checkbox"]')!, + ); + + expect(onToggle).not.toHaveBeenCalled(); + }); + + it("calls onToggle with the kind when an enabled row checkbox is clicked", () => { + const { onToggle } = renderPanel(); + + fireEvent.click(screen.getByTestId("edge-row-CALLS").querySelector('input[type="checkbox"]')!); + + expect(onToggle).toHaveBeenCalledWith("CALLS"); + }); + + it("All button shows every currently-hidden enabled kind", () => { + const onToggle = vi.fn(); + renderPanel({ hiddenKinds: new Set(["DEFINES", "IMPORTS"]), onToggle }); + + fireEvent.click(screen.getByRole("button", { name: "Show all edge types" })); + + // Only the two hidden enabled kinds get toggled back on. + expect(onToggle).toHaveBeenCalledWith("DEFINES"); + expect(onToggle).toHaveBeenCalledWith("IMPORTS"); + expect(onToggle).toHaveBeenCalledTimes(2); + }); + + it("None button hides every currently-visible enabled kind (skips disabled)", () => { + const onToggle = vi.fn(); + renderPanel({ onToggle }); + + fireEvent.click(screen.getByRole("button", { name: "Hide all edge types" })); + + // 4 enabled kinds (DEFINES, IMPORTS, CALLS, INHERITS); CONTAINS + IMPLEMENTS are disabled. + expect(onToggle).toHaveBeenCalledTimes(4); + expect(onToggle).not.toHaveBeenCalledWith("CONTAINS"); + expect(onToggle).not.toHaveBeenCalledWith("IMPLEMENTS"); + }); + + it("shows an empty-state status when all enabled kinds are hidden", () => { + renderPanel({ hiddenKinds: new Set(["DEFINES", "IMPORTS", "CALLS", "INHERITS"]) }); + + expect(screen.getByRole("status")).toBeTruthy(); + }); +}); diff --git a/packages/extension/src/webview/components/EdgeTypesPanel.tsx b/packages/extension/src/webview/components/EdgeTypesPanel.tsx new file mode 100644 index 0000000..75a61c2 --- /dev/null +++ b/packages/extension/src/webview/components/EdgeTypesPanel.tsx @@ -0,0 +1,154 @@ +import styles from "./EdgeTypesPanel.module.css"; + +interface EdgeKindMeta { + label: string; + codicon: string; + /** + * Dot color for the kind. The `--edge-color-` custom property is not + * defined globally, so each entry carries a `--vscode-charts-*` fallback; + * without it the dot renders colorless. Mirrors the mockup's per-kind edge + * palette in scratch/graphview-mockup-final.html. + */ + dotColor: string; +} + +const EDGE_KIND_META: Readonly> = { + CONTAINS: { + label: "Contains", + codicon: "symbol-folder", + dotColor: "var(--edge-color-contains, var(--vscode-symbolIcon-folderForeground, #c5c5c5))", + }, + DEFINES: { + label: "Defines", + codicon: "symbol-file", + dotColor: "var(--edge-color-defines, var(--vscode-charts-blue, #3794ff))", + }, + IMPORTS: { + label: "Imports", + codicon: "arrow-swap", + dotColor: "var(--edge-color-imports, var(--vscode-charts-green, #4ec9b0))", + }, + CALLS: { + label: "Calls", + codicon: "symbol-function", + dotColor: "var(--edge-color-calls, var(--vscode-charts-orange, #ce9178))", + }, + EXTENDS: { + label: "Extends", + codicon: "symbol-class", + dotColor: "var(--edge-color-extends, var(--vscode-charts-purple, #c586c0))", + }, + INHERITS: { + label: "Extends", + codicon: "symbol-class", + dotColor: "var(--edge-color-inherits, var(--vscode-charts-purple, #c586c0))", + }, + IMPLEMENTS: { + label: "Implements", + codicon: "symbol-interface", + dotColor: "var(--edge-color-implements, var(--vscode-charts-yellow, #dcdcaa))", + }, + INSTANTIATES: { + label: "New", + codicon: "symbol-misc", + dotColor: "var(--edge-color-instantiates, var(--vscode-charts-red, #f44747))", + }, +}; + +const DEFAULT_EDGE_DOT_COLOR = "var(--edge-color-default, var(--vscode-foreground, #cccccc))"; + +export interface EdgeTypeEntry { + kind: string; + label: string; + count: number; + disabled?: boolean; + tooltip?: string; +} + +export interface EdgeTypesPanelProps { + entries: EdgeTypeEntry[]; + hiddenKinds: Set; + onToggle: (kind: string) => void; +} + +export function EdgeTypesPanel({ entries, hiddenKinds, onToggle }: EdgeTypesPanelProps) { + const activeEntries = entries.filter((e) => !e.disabled); + const allHidden = activeEntries.length > 0 && activeEntries.every((e) => hiddenKinds.has(e.kind)); + + return ( +
+
+ Edge Types + + + + +
+
+ {entries.map((entry) => { + const isVisible = !hiddenKinds.has(entry.kind); + const meta = EDGE_KIND_META[entry.kind] ?? { + label: entry.label, + codicon: "symbol-misc", + dotColor: DEFAULT_EDGE_DOT_COLOR, + }; + return ( + + ); + })} +
+ {allHidden && ( +
+ No edge types visible. +
+ )} +
+ ); +} diff --git a/packages/extension/src/webview/components/GraphToolbar.module.css b/packages/extension/src/webview/components/GraphToolbar.module.css new file mode 100644 index 0000000..cc6f9c6 --- /dev/null +++ b/packages/extension/src/webview/components/GraphToolbar.module.css @@ -0,0 +1,147 @@ +/* GraphToolbar — slice 033 US3, grouped toolbar with separators */ + +.toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 0 8px; + height: 44px; + background: var(--vscode-editorWidget-background); + border-bottom: 1px solid var(--vscode-editorWidget-border); +} + +.group { + display: flex; + align-items: center; + gap: 2px; +} + +.group + .group { + margin-left: 6px; + padding-left: 8px; + border-left: 1px solid var(--vscode-editorWidget-border); +} + +.spacer { + flex: 1; +} + +.iconBtn { + height: 30px; + min-width: 30px; + padding: 0 8px; + background: transparent; + color: var(--vscode-foreground); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font: inherit; + font-size: 12px; + white-space: nowrap; +} + +.iconBtn:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.iconBtn[aria-pressed="true"] { + background: var(--vscode-toolbar-activeBackground); +} + +.iconBtn:disabled { + color: var(--vscode-disabledForeground); + cursor: default; +} + +.accentBtn { + height: 30px; + min-width: 30px; + padding: 0 8px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font: inherit; + font-size: 12px; + white-space: nowrap; +} + +.accentBtn:hover { + background: var(--vscode-button-hoverBackground); +} + +.workspaceSwitcher { + height: 30px; + max-width: 260px; + padding: 0 10px; + border-radius: 4px; + background: transparent; + border: 1px solid var(--vscode-editorWidget-border); + color: var(--vscode-foreground); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + font: inherit; + font-size: 12px; +} + +.workspaceSwitcher:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.workspaceName { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspaceMeta { + font-size: 10px; + color: var(--vscode-descriptionForeground); +} + +.depthControl { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + padding: 0 6px; +} + +.depthControl input[type="range"] { + accent-color: var(--vscode-button-background); + width: 70px; +} + +.depthValue { + color: var(--vscode-foreground); + width: 12px; + text-align: right; +} + +.layoutSelect { + height: 28px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + padding: 0 6px; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.layoutSelect:focus { + border-color: var(--vscode-focusBorder); + outline: none; +} diff --git a/packages/extension/src/webview/components/GraphToolbar.test.tsx b/packages/extension/src/webview/components/GraphToolbar.test.tsx index aeb6a57..984e461 100644 --- a/packages/extension/src/webview/components/GraphToolbar.test.tsx +++ b/packages/extension/src/webview/components/GraphToolbar.test.tsx @@ -3,14 +3,34 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { GraphToolbar } from "./GraphToolbar.js"; -import { CANONICAL_NODE_FILTER_LIST } from "./NodeFilterPanel.js"; + +const baseProps = { + onExportMermaid: vi.fn(), + showMinimap: false, + onToggleMinimap: vi.fn(), + searchQuery: "", + searchResults: [], + searchFocusedIndex: 0, + onSearchQueryChange: vi.fn(), + onSearchSelectResult: vi.fn(), + onSearchClear: vi.fn(), + depth: 3, + depthEnabled: false, + onDepthChange: vi.fn(), + tracePhase: "idle" as const, + onTraceToggle: vi.fn(), + onTraceExit: vi.fn(), + activeLayoutPreset: "forceAtlas2" as const, + onSelectLayoutPreset: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + onZoomFit: vi.fn(), + onZoomReset: vi.fn(), +} satisfies ComponentProps; function renderToolbar(overrides?: Partial>) { const onExportMermaid = vi.fn(); - const onExportCurrentView = vi.fn(); const onToggleMinimap = vi.fn(); - const onToggleEdgeKind = vi.fn(); - const onToggleNodeKind = vi.fn(); const onSearchQueryChange = vi.fn(); const onSearchSelectResult = vi.fn(); const onSearchClear = vi.fn(); @@ -18,22 +38,16 @@ function renderToolbar(overrides?: Partial>) const onTraceToggle = vi.fn(); const onTraceExit = vi.fn(); const onSelectLayoutPreset = vi.fn(); + const onZoomIn = vi.fn(); + const onZoomOut = vi.fn(); + const onZoomFit = vi.fn(); + const onZoomReset = vi.fn(); const result = render( ({ - ...t, - count: i, - }))} - hiddenNodeKinds={new Set()} - onToggleNodeKind={onToggleNodeKind} searchQuery="" searchResults={[]} searchFocusedIndex={0} @@ -48,6 +62,10 @@ function renderToolbar(overrides?: Partial>) onTraceExit={onTraceExit} activeLayoutPreset="forceAtlas2" onSelectLayoutPreset={onSelectLayoutPreset} + onZoomIn={onZoomIn} + onZoomOut={onZoomOut} + onZoomFit={onZoomFit} + onZoomReset={onZoomReset} {...overrides} />, ); @@ -55,10 +73,7 @@ function renderToolbar(overrides?: Partial>) return { ...result, onExportMermaid, - onExportCurrentView, onToggleMinimap, - onToggleEdgeKind, - onToggleNodeKind, onSearchQueryChange, onSearchSelectResult, onSearchClear, @@ -66,6 +81,10 @@ function renderToolbar(overrides?: Partial>) onTraceToggle, onTraceExit, onSelectLayoutPreset, + onZoomIn, + onZoomOut, + onZoomFit, + onZoomReset, }; } @@ -83,19 +102,11 @@ describe("GraphToolbar", () => { it("calls onExportMermaid when the export button is clicked", () => { const { onExportMermaid } = renderToolbar(); - fireEvent.click(screen.getByRole("button", { name: "Export as Mermaid" })); + fireEvent.click(screen.getByRole("button", { name: "Open Mermaid preview export" })); expect(onExportMermaid).toHaveBeenCalledTimes(1); }); - it("calls onExportCurrentView when the current-view export button is clicked", () => { - const { onExportCurrentView } = renderToolbar(); - - fireEvent.click(screen.getByRole("button", { name: "Export current view" })); - - expect(onExportCurrentView).toHaveBeenCalledTimes(1); - }); - it("calls onToggleMinimap when the minimap toggle button is clicked", () => { const { onToggleMinimap } = renderToolbar(); @@ -104,138 +115,19 @@ describe("GraphToolbar", () => { expect(onToggleMinimap).toHaveBeenCalledTimes(1); }); - it("calls onToggleEdgeKind with the clicked edge kind", () => { - const { onToggleEdgeKind } = renderToolbar(); - - fireEvent.click(screen.getByRole("button", { name: "Hide Defines edges" })); - - expect(onToggleEdgeKind).toHaveBeenCalledWith("DEFINES"); - }); - - it("exposes aria-labels for all toolbar buttons", () => { - renderToolbar(); - - expect(screen.getByRole("button", { name: "Export as Mermaid" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Toggle minimap" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Hide Defines edges" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Hide Calls edges" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Hide Imports edges" })).toBeTruthy(); - }); - it("reflects the minimap visibility state with aria-pressed", () => { const { rerender } = renderToolbar(); const toggleButton = screen.getByRole("button", { name: "Toggle minimap" }); expect(toggleButton.getAttribute("aria-pressed")).toBe("false"); - rerender( - ({ ...t, count: i }))} - hiddenNodeKinds={new Set()} - onToggleNodeKind={vi.fn()} - searchQuery="" - searchResults={[]} - searchFocusedIndex={0} - onSearchQueryChange={vi.fn()} - onSearchSelectResult={vi.fn()} - onSearchClear={vi.fn()} - depth={3} - depthEnabled={false} - onDepthChange={vi.fn()} - tracePhase="idle" - onTraceToggle={vi.fn()} - onTraceExit={vi.fn()} - activeLayoutPreset="forceAtlas2" - onSelectLayoutPreset={vi.fn()} - />, - ); + rerender(); expect( screen.getByRole("button", { name: "Toggle minimap" }).getAttribute("aria-pressed"), ).toBe("true"); }); - it("renders in isolation with mocked props", () => { - const { container } = renderToolbar(); - - expect(container.querySelector(".dxt-toolbar")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Export as Mermaid" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Toggle minimap" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Hide Defines edges" })).toBeTruthy(); - }); - - it("renders without errors when edgeKinds is empty", () => { - const { container } = renderToolbar({ edgeKinds: [] }); - - expect(container.querySelector(".dxt-toolbar")).toBeTruthy(); - // Slice 031 US2 — the disabled Implements stub is gone; IMPLEMENTS is now - // a real edge kind that only appears when the workspace produced one. - expect(container.querySelectorAll(".dxt-edge-filter-pill").length).toBe(0); - }); - - // US2: Edge-type rename + Implements stub - it("renders INHERITS edge pill with label 'Extends' (FR-006)", () => { - renderToolbar({ edgeKinds: ["INHERITS"] }); - - expect(screen.getByRole("button", { name: "Hide Extends edges" })).toBeTruthy(); - expect(screen.queryByRole("button", { name: /Inherits/i })).toBeNull(); - }); - - it("clicking Extends pill calls onToggleEdgeKind with 'INHERITS'", () => { - const { onToggleEdgeKind } = renderToolbar({ edgeKinds: ["INHERITS"] }); - - fireEvent.click(screen.getByRole("button", { name: "Hide Extends edges" })); - - expect(onToggleEdgeKind).toHaveBeenCalledWith("INHERITS"); - }); - - // Slice 031 US2 — IMPLEMENTS is now a real edge kind. The "Implements" pill - // appears when IMPLEMENTS is in the edgeKinds list and is interactive like - // any other edge filter chip. - it("renders interactive Implements pill when IMPLEMENTS is in edgeKinds (slice 031 US2)", () => { - renderToolbar({ edgeKinds: ["IMPLEMENTS"] }); - - const implementsButton = screen.getByRole("button", { name: "Hide Implements edges" }); - expect(implementsButton.getAttribute("aria-disabled")).not.toBe("true"); - }); - - it("clicking Implements pill calls onToggleEdgeKind with 'IMPLEMENTS' (slice 031 US2)", () => { - const { onToggleEdgeKind } = renderToolbar({ edgeKinds: ["IMPLEMENTS"] }); - - fireEvent.click(screen.getByRole("button", { name: "Hide Implements edges" })); - - expect(onToggleEdgeKind).toHaveBeenCalledWith("IMPLEMENTS"); - }); - - it("does not render Implements pill when IMPLEMENTS is absent from edgeKinds (slice 031 US2)", () => { - renderToolbar({ edgeKinds: ["DEFINES", "IMPORTS", "CALLS", "INHERITS", "INSTANTIATES"] }); - - expect(screen.queryByRole("button", { name: "Hide Implements edges" })).toBeNull(); - }); - - it("renders all 5 fixed edge pills even when edgeKinds list is full", () => { - renderToolbar({ edgeKinds: ["DEFINES", "IMPORTS", "CALLS", "INHERITS", "INSTANTIATES"] }); - - expect(screen.getByRole("button", { name: "Hide Defines edges" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Hide Imports edges" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Hide Calls edges" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Hide Extends edges" })).toBeTruthy(); - }); - - it("renders node filter panel inside the toolbar", () => { - renderToolbar(); - - expect(screen.getByRole("group", { name: "Node type filters" })).toBeTruthy(); - }); - - // Slice 022 — search + depth slider it("renders the search input inside the toolbar (slice 022)", () => { renderToolbar(); @@ -256,6 +148,31 @@ describe("GraphToolbar", () => { expect(slider.disabled).toBe(true); }); + it("renders zoom buttons", () => { + renderToolbar(); + + expect(screen.getByRole("button", { name: "Zoom in" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Zoom out" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Fit to screen" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Reset layout" })).toBeTruthy(); + }); + + it("calls zoom handlers", () => { + const { onZoomIn, onZoomOut, onZoomFit, onZoomReset } = renderToolbar(); + + fireEvent.click(screen.getByRole("button", { name: "Zoom in" })); + expect(onZoomIn).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Zoom out" })); + expect(onZoomOut).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Fit to screen" })); + expect(onZoomFit).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Reset layout" })); + expect(onZoomReset).toHaveBeenCalledTimes(1); + }); + // Slice 023 — trace route toggle it("renders the trace toggle button with aria-pressed reflecting the phase (slice 023)", () => { const { rerender } = renderToolbar({ tracePhase: "idle" }); @@ -263,34 +180,7 @@ describe("GraphToolbar", () => { screen.getByRole("button", { name: "Toggle trace route mode" }).getAttribute("aria-pressed"), ).toBe("false"); - rerender( - ({ ...t, count: i }))} - hiddenNodeKinds={new Set()} - onToggleNodeKind={vi.fn()} - searchQuery="" - searchResults={[]} - searchFocusedIndex={0} - onSearchQueryChange={vi.fn()} - onSearchSelectResult={vi.fn()} - onSearchClear={vi.fn()} - depth={3} - depthEnabled={false} - onDepthChange={vi.fn()} - tracePhase="picking-start" - onTraceToggle={vi.fn()} - onTraceExit={vi.fn()} - activeLayoutPreset="forceAtlas2" - onSelectLayoutPreset={vi.fn()} - />, - ); + rerender(); expect( screen.getByRole("button", { name: "Toggle trace route mode" }).getAttribute("aria-pressed"), ).toBe("true"); @@ -305,39 +195,9 @@ describe("GraphToolbar", () => { it("shows Export + Exit trace buttons only when tracePhase is not idle", () => { const { rerender } = renderToolbar({ tracePhase: "idle" }); expect(screen.queryByRole("button", { name: "Exit trace mode" })).toBeNull(); - expect(screen.queryByRole("button", { name: "Export this trace (disabled)" })).toBeNull(); - - rerender( - ({ ...t, count: i }))} - hiddenNodeKinds={new Set()} - onToggleNodeKind={vi.fn()} - searchQuery="" - searchResults={[]} - searchFocusedIndex={0} - onSearchQueryChange={vi.fn()} - onSearchSelectResult={vi.fn()} - onSearchClear={vi.fn()} - depth={3} - depthEnabled={false} - onDepthChange={vi.fn()} - tracePhase="picking-end" - onTraceToggle={vi.fn()} - onTraceExit={vi.fn()} - activeLayoutPreset="forceAtlas2" - onSelectLayoutPreset={vi.fn()} - />, - ); + + rerender(); expect(screen.getByRole("button", { name: "Exit trace mode" })).toBeTruthy(); - const exportBtn = screen.getByRole("button", { name: "Export this trace (disabled)" }); - expect((exportBtn as HTMLButtonElement).disabled).toBe(true); }); it("calls onTraceExit when the Exit trace button is clicked", () => { @@ -346,9 +206,7 @@ describe("GraphToolbar", () => { expect(onTraceExit).toHaveBeenCalledTimes(1); }); - // Slice 031 US1 — the trace-export button is now interactive when a path is - // active AND the host has wired an export callback. - it("activates the trace export button when canExportTrace is true and onExportTrace is wired (slice 031 US1)", () => { + it("activates the trace export button when canExportTrace is true (slice 031 US1)", () => { const onExportTrace = vi.fn(); renderToolbar({ tracePhase: "path-active", @@ -364,29 +222,6 @@ describe("GraphToolbar", () => { expect(onExportTrace).toHaveBeenCalledTimes(1); }); - it("keeps the trace export button disabled when canExportTrace is false (slice 031 US1)", () => { - renderToolbar({ - tracePhase: "path-active", - canExportTrace: false, - onExportTrace: vi.fn(), - }); - - const exportBtn = screen.getByRole("button", { name: "Export this trace (disabled)" }); - expect((exportBtn as HTMLButtonElement).disabled).toBe(true); - }); - - it("keeps the trace export button disabled when onExportTrace is undefined (slice 031 US1)", () => { - renderToolbar({ - tracePhase: "path-active", - canExportTrace: true, - // onExportTrace deliberately not provided — host hasn't wired the command - }); - - const exportBtn = screen.getByRole("button", { name: "Export this trace (disabled)" }); - expect((exportBtn as HTMLButtonElement).disabled).toBe(true); - }); - - // Slice 024 — workspace switcher button it("renders the workspace switcher button when workspaceName is provided", () => { renderToolbar({ workspaceName: "dextree" }); @@ -401,28 +236,6 @@ describe("GraphToolbar", () => { expect(screen.queryByRole("button", { name: /switch workspace/i })).toBeNull(); }); - it("renders framework chips alongside the workspace name when provided", () => { - const { container } = renderToolbar({ - workspaceName: "dextree", - workspaceFrameworks: ["react", "vitest"], - }); - - const chips = container.querySelectorAll(".dxt-workspace-switcher .dxt-badge--framework"); - expect(chips.length).toBe(2); - expect(chips[0]?.textContent).toBe("react"); - expect(chips[1]?.textContent).toBe("vitest"); - }); - - it("renders workspace name without a chip row when workspaceFrameworks is empty", () => { - const { container } = renderToolbar({ - workspaceName: "dextree", - workspaceFrameworks: [], - }); - - expect(container.querySelector(".dxt-workspace-switcher")).toBeTruthy(); - expect(container.querySelectorAll(".dxt-badge--framework").length).toBe(0); - }); - it("calls onWorkspaceSwitcherClick when the workspace button is clicked", () => { const onWorkspaceSwitcherClick = vi.fn(); renderToolbar({ workspaceName: "dextree", onWorkspaceSwitcherClick }); @@ -455,34 +268,7 @@ describe("GraphToolbar", () => { "forceAtlas2", ); - rerender( - ({ ...t, count: i }))} - hiddenNodeKinds={new Set()} - onToggleNodeKind={vi.fn()} - searchQuery="" - searchResults={[]} - searchFocusedIndex={0} - onSearchQueryChange={vi.fn()} - onSearchSelectResult={vi.fn()} - onSearchClear={vi.fn()} - depth={3} - depthEnabled={false} - onDepthChange={vi.fn()} - tracePhase="idle" - onTraceToggle={vi.fn()} - onTraceExit={vi.fn()} - activeLayoutPreset="circular" - onSelectLayoutPreset={vi.fn()} - />, - ); + rerender(); expect((screen.getByRole("combobox", { name: /layout/i }) as HTMLSelectElement).value).toBe( "circular", ); @@ -498,14 +284,114 @@ describe("GraphToolbar", () => { expect(onSelectLayoutPreset).toHaveBeenCalledWith("circular"); }); - it("still calls onSelectLayoutPreset when the user picks the active preset (no-op handled by GraphView)", () => { - const { onSelectLayoutPreset } = renderToolbar({ activeLayoutPreset: "forceAtlas2" }); + // Slice 033 US3 — toolbar groups + describe("toolbar groups (slice 033 US3)", () => { + it("renders toolbar controls organized into distinct groups", () => { + const { container } = renderToolbar({ workspaceName: "dextree" }); - fireEvent.change(screen.getByRole("combobox", { name: /layout/i }), { - target: { value: "forceAtlas2" }, + const groups = container.querySelectorAll('[data-testid="toolbar-group"]'); + expect(groups.length).toBeGreaterThanOrEqual(7); }); - // Toolbar surfaces every selection; safe-no-op is decided in GraphView. - expect(onSelectLayoutPreset).toHaveBeenCalledWith("forceAtlas2"); + it("orders groups in mockup sequence: workspace → search → zoom → depth → trace → layout → view", () => { + const { container } = renderToolbar({ workspaceName: "dextree" }); + + const groups = Array.from(container.querySelectorAll('[data-testid="toolbar-group"]')); + const labels = groups.map((g) => g.getAttribute("aria-label")); + + expect(labels).toEqual([ + "Workspace switcher", + "Search", + "Zoom controls", + "Depth controls", + "Trace controls", + "Layout controls", + "View controls", + ]); + }); + + it("renders export button as accent-styled in the view controls group", () => { + const { container } = renderToolbar(); + + const viewGroup = container.querySelector( + '[data-testid="toolbar-group"][aria-label="View controls"]', + ); + expect(viewGroup).toBeTruthy(); + const exportBtn = viewGroup?.querySelector('[data-testid="export-accent"]'); + expect(exportBtn).toBeTruthy(); + }); + }); + + // Slice 033 Phase 3 — workspace actions relocated from the legacy right panel. + describe("workspace actions group (slice 033 Phase 3)", () => { + it("renders Re-index, Source-only, Clear Workspace, and Clear All when handlers are provided", () => { + renderToolbar({ + onReindex: vi.fn(), + onClearWorkspace: vi.fn(), + onClearAll: vi.fn(), + onToggleSourceOnly: vi.fn(), + sourceOnly: false, + isIndexing: false, + }); + + expect(screen.getByRole("button", { name: /re-index/i })).toBeTruthy(); + expect(screen.getByTitle("Clear the current workspace index")).toBeTruthy(); + expect(screen.getByTitle("Clear all indexed workspaces")).toBeTruthy(); + expect(screen.getByRole("button", { name: /source-only view/i })).toBeTruthy(); + }); + + it("does not render the workspace actions group when handlers are absent", () => { + const { container } = renderToolbar(); + + const group = container.querySelector( + '[data-testid="toolbar-group"][aria-label="Workspace actions"]', + ); + expect(group).toBeNull(); + }); + + it("calls onReindex / onClearWorkspace / onClearAll / onToggleSourceOnly", () => { + const onReindex = vi.fn(); + const onClearWorkspace = vi.fn(); + const onClearAll = vi.fn(); + const onToggleSourceOnly = vi.fn(); + renderToolbar({ + onReindex, + onClearWorkspace, + onClearAll, + onToggleSourceOnly, + sourceOnly: false, + isIndexing: false, + }); + + fireEvent.click(screen.getByRole("button", { name: /re-index/i })); + expect(onReindex).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTitle("Clear the current workspace index")); + expect(onClearWorkspace).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTitle("Clear all indexed workspaces")); + expect(onClearAll).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: /source-only view/i })); + expect(onToggleSourceOnly).toHaveBeenCalledTimes(1); + }); + + it("disables Re-index / Clear while indexing is active", () => { + renderToolbar({ + onReindex: vi.fn(), + onClearWorkspace: vi.fn(), + onClearAll: vi.fn(), + onToggleSourceOnly: vi.fn(), + sourceOnly: false, + isIndexing: true, + }); + + expect( + (screen.getByRole("button", { name: /indexing/i }) as HTMLButtonElement).disabled, + ).toBe(true); + expect( + (screen.getByTitle("Clear the current workspace index") as HTMLButtonElement).disabled, + ).toBe(true); + }); }); }); diff --git a/packages/extension/src/webview/components/GraphToolbar.tsx b/packages/extension/src/webview/components/GraphToolbar.tsx index 7286090..d9f388e 100644 --- a/packages/extension/src/webview/components/GraphToolbar.tsx +++ b/packages/extension/src/webview/components/GraphToolbar.tsx @@ -1,37 +1,13 @@ -import type { GraphEdge } from "@dextree/core"; - import { DepthSlider } from "./DepthSlider.js"; -import { NodeFilterPanel } from "./NodeFilterPanel.js"; -import type { NodeFilterEntry } from "./NodeFilterPanel.js"; import { SearchBar } from "./SearchBar.js"; import type { LayoutPresetId, SearchResultItem, TracePhase } from "./graphViewTypes.js"; import { LAYOUT_PRESET_OPTIONS } from "./graphLayoutPresets.js"; - -const EDGE_KIND_LABELS: Record = { - DEFINES: "Defines", - IMPORTS: "Imports", - CALLS: "Calls", - INHERITS: "Extends", - INSTANTIATES: "New", - IMPLEMENTS: "Implements", -}; - -// The Implements edge filter chip is now rendered through the standard -// EDGE_KIND_LABELS path (slice 031 US2); no disabled stub is needed. +import styles from "./GraphToolbar.module.css"; export interface GraphToolbarProps { onExportMermaid: () => void; - onExportCurrentView: () => void; showMinimap: boolean; onToggleMinimap: () => void; - edgeKinds: GraphEdge["kind"][]; - hiddenEdgeKinds: Set; - onToggleEdgeKind: (kind: GraphEdge["kind"]) => void; - // Node filter (new — slice 019): - nodeFilterEntries: NodeFilterEntry[]; - hiddenNodeKinds: Set; - onToggleNodeKind: (key: string) => void; - // Search + Depth (new — slice 022): searchQuery: string; searchResults: SearchResultItem[]; searchFocusedIndex: number; @@ -41,73 +17,37 @@ export interface GraphToolbarProps { depth: number; depthEnabled: boolean; onDepthChange: (depth: number) => void; - // Trace mode (new — slice 023): tracePhase: TracePhase; onTraceToggle: () => void; onTraceExit: () => void; - /** - * Slice 031 (US1). Snapshot is built by GraphView from its TraceState and - * passed through unmodified. Undefined keeps the export button disabled - * (host has not wired the command yet). - */ onExportTrace?: () => void; - /** Trace export is only meaningful when the path is resolved (slice 031 US1). */ canExportTrace?: boolean; - // Workspace switcher (new — slice 024): workspaceName?: string; workspaceFrameworks?: readonly string[]; onWorkspaceSwitcherClick?: () => void; - // Layout presets (new — slice 025): activeLayoutPreset: LayoutPresetId; onSelectLayoutPreset: (preset: LayoutPresetId) => void; -} - -function EdgeFilterBar({ - edgeKinds, - hiddenKinds, - onToggle, -}: { - edgeKinds: GraphEdge["kind"][]; - hiddenKinds: Set; - onToggle: (kind: GraphEdge["kind"]) => void; -}) { - return ( -
- {edgeKinds.map((kind) => { - const active = !hiddenKinds.has(kind); - const label = `${active ? "Hide" : "Show"} ${EDGE_KIND_LABELS[kind]} edges`; - - return ( - - ); - })} -
- ); + onZoomIn: () => void; + onZoomOut: () => void; + onZoomFit: () => void; + onZoomReset: () => void; + /** + * Workspace actions relocated from the legacy right-rail panel (slice 033). + * The whole group renders only when `onReindex` is provided, so GraphView + * usages that don't own these handlers keep the mockup-clean toolbar. + */ + onReindex?: () => void; + onClearWorkspace?: () => void; + onClearAll?: () => void; + onToggleSourceOnly?: () => void; + sourceOnly?: boolean; + isIndexing?: boolean; } export function GraphToolbar({ onExportMermaid, - onExportCurrentView, showMinimap, onToggleMinimap, - edgeKinds, - hiddenEdgeKinds, - onToggleEdgeKind, - nodeFilterEntries, - hiddenNodeKinds, - onToggleNodeKind, searchQuery, searchResults, searchFocusedIndex, @@ -127,120 +67,157 @@ export function GraphToolbar({ onWorkspaceSwitcherClick, activeLayoutPreset, onSelectLayoutPreset, + onZoomIn, + onZoomOut, + onZoomFit, + onZoomReset, + onReindex, + onClearWorkspace, + onClearAll, + onToggleSourceOnly, + sourceOnly = false, + isIndexing = false, }: GraphToolbarProps) { const traceActive = tracePhase !== "idle"; return ( -
+
+ {/* Group 1: Workspace switcher */} {workspaceName !== undefined && ( - - )} - - - - {traceActive && ( - <> - +
- +
)} - - -
- +
- -
+ + {/* Group: Workspace actions (relocated from legacy right panel, slice 033) */} + {onReindex !== undefined && ( +
+ + {onToggleSourceOnly !== undefined && ( + + )} + {onClearWorkspace !== undefined && ( + + )} + {onClearAll !== undefined && ( + + )} +
+ )} + +
+ + {/* Group 7: Minimap + Export */} +
+ + +
+ ); } diff --git a/packages/extension/src/webview/components/GraphView.module.css b/packages/extension/src/webview/components/GraphView.module.css new file mode 100644 index 0000000..ec56061 --- /dev/null +++ b/packages/extension/src/webview/components/GraphView.module.css @@ -0,0 +1,116 @@ +/* + * GraphView shell grid (slice 033 Phase 3). + * + * Translates the 3-column shell from scratch/graphview-mockup-final.html into + * scoped CSS Module classes so the rails, toolbar, and status bar are first + * class grid areas instead of children of the canvas surface. Grid template + * mirrors App.module.css .graphShell and components/shellLayout.ts — keep the + * column/row values in sync. VS Code theme tokens only (FR-026). + */ + +.shell { + display: grid; + grid-template-columns: 260px 1fr 320px; + grid-template-rows: 44px 1fr 28px; + grid-template-areas: + "toolbar toolbar toolbar" + "left canvas right" + "status status status"; + height: 100%; + width: 100%; + min-height: 0; + background: var(--vscode-editor-background); +} + +.toolbarArea { + grid-area: toolbar; + min-width: 0; +} + +.left { + grid-area: left; + min-width: 0; + overflow-y: auto; + background: var(--vscode-panel-background); + border-right: 1px solid var(--vscode-panel-border); +} + +.canvas { + grid-area: canvas; + position: relative; + min-width: 0; + overflow: hidden; + background: + radial-gradient(circle at 50% 45%, rgba(255, 255, 255, 0.03) 0%, transparent 65%), + var(--vscode-editor-background); +} + +.right { + grid-area: right; + min-width: 0; + overflow-y: auto; + background: var(--vscode-panel-background); + border-left: 1px solid var(--vscode-panel-border); + display: flex; + flex-direction: column; +} + +/* ---- Status bar -------------------------------------------------------- */ + +.status { + grid-area: status; + display: flex; + align-items: center; + gap: 16px; + padding: 0 12px; + min-width: 0; + background: var(--vscode-editorWidget-background); + border-top: 1px solid var(--vscode-editorWidget-border); + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; +} + +.statusItem { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.statusSpacer { + flex: 1; +} + +.statusPill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 6px; + height: 18px; + border-radius: 9px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 10px; +} + +.statusPillLens { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.statusPillFramework { + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +/* + * Below ~800px the fixed rails would crowd the canvas off-screen. Hold a + * readable minimum and let the canvas absorb the squeeze (mirrors + * App.module.css and the spec edge case on minimum rail widths). + */ +@media (max-width: 800px) { + .shell { + grid-template-columns: 200px 1fr 240px; + } +} diff --git a/packages/extension/src/webview/components/GraphView.test.tsx b/packages/extension/src/webview/components/GraphView.test.tsx index 93ea6ae..5f39bde 100644 --- a/packages/extension/src/webview/components/GraphView.test.tsx +++ b/packages/extension/src/webview/components/GraphView.test.tsx @@ -248,26 +248,11 @@ describe("GraphView", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: "Export as Mermaid" })); + fireEvent.click(screen.getByRole("button", { name: "Open Mermaid preview export" })); expect(onExportMermaid).toHaveBeenCalledTimes(1); }); - it("calls onExportCurrentView from the toolbar export-current-view button", () => { - const onExportCurrentView = vi.fn(); - render( - , - ); - fireEvent.click(screen.getByRole("button", { name: "Export current view" })); - expect(onExportCurrentView).toHaveBeenCalledTimes(1); - }); - it("toggles the minimap visibility from the toolbar button", () => { const { container } = render( { expect(minimapButton.getAttribute("aria-pressed")).toBe("true"); }); - it("updates edge filter pill state from the toolbar", () => { - render( - , - ); - - const definesButton = screen.getByRole("button", { name: "Hide Defines edges" }); - - fireEvent.click(definesButton); - - expect( - screen.getByRole("button", { name: "Show Defines edges" }).getAttribute("aria-pressed"), - ).toBe("false"); - }); - it("assigns distinct node size and color attributes for file and symbol nodes", () => { render( { expect(container.querySelectorAll(".dxt-selection-path").length).toBeGreaterThan(0); expect(container.querySelectorAll(".dxt-selection-traveler").length).toBeGreaterThan(0); - expect(screen.getByText("Called by")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "formatCaller" })); + // Neighbors now render in the Inspector (right rail), not a floating canvas + // panel. Group titles carry a count ("Called by · N"); rows are addressable + // by neighbor-row- and clicking one navigates to that symbol. + expect(screen.getByText(/Called by/)).toBeTruthy(); + fireEvent.click(screen.getByTestId("neighbor-row-symbol-3")); expect(onNavigate).toHaveBeenCalledWith("/workspace/src/caller.ts", 9); - fireEvent.click(screen.getByRole("button", { name: "formatDate" })); + fireEvent.click(screen.getByTestId("neighbor-row-symbol-2")); expect(onNavigate).toHaveBeenCalledWith("/workspace/src/util.ts", 4); }); - describe("node-kind filter (slice 019)", () => { - it("node filter panel renders in the toolbar with all canonical kinds", () => { - render( - , - ); - - expect(screen.getByRole("group", { name: "Node type filters" })).toBeTruthy(); - // Folder, Class, Interface, Function, Method, Property, Variable, Enum, Type, Decorator = 10 - expect(screen.getAllByRole("checkbox").length).toBe(10); - }); - - it("hiddenNodeKinds starts as empty set — all chips aria-checked=true (FR-010)", () => { - render( - , - ); - - const chips = screen.getAllByRole("checkbox"); - const activeChips = chips.filter((c) => c.getAttribute("aria-disabled") !== "true"); - expect(activeChips.length).toBeGreaterThan(0); - for (const chip of activeChips) { - expect(chip.getAttribute("aria-checked")).toBe("true"); - } - }); - - it("toggling a node-kind chip updates aria-checked state", () => { - render( - , - ); - - const functionChip = screen.getByRole("checkbox", { name: "Hide Function nodes" }); - expect(functionChip.getAttribute("aria-checked")).toBe("true"); - - fireEvent.click(functionChip); - - expect( - screen.getByRole("checkbox", { name: "Show Function nodes" }).getAttribute("aria-checked"), - ).toBe("false"); - }); - - it("toggling a chip twice returns it to visible state", () => { - render( - , - ); - - const functionChip = screen.getByRole("checkbox", { name: "Hide Function nodes" }); - fireEvent.click(functionChip); - fireEvent.click(screen.getByRole("checkbox", { name: "Show Function nodes" })); - - expect( - screen.getByRole("checkbox", { name: "Hide Function nodes" }).getAttribute("aria-checked"), - ).toBe("true"); - }); - - it("nodeFilterEntries counts are derived from nodes prop", () => { - // baseNodes: 1 file, 1 function, 1 class - render( - , - ); - - // baseNodes: 1 file, 1 function, 1 class — Folder chip should show count 1 - const folderChip = screen.getByRole("checkbox", { name: /Folder/ }); - expect(folderChip).toBeTruthy(); - // The badge should show "1" - const badge = - folderChip.querySelector("span[aria-label]") ?? - folderChip.parentElement?.querySelector("span[aria-label]"); - expect(badge ?? folderChip.textContent).toBeTruthy(); - }); - - it("Decorator chip is disabled and not clickable", () => { - render( - , - ); - - // Find chip by aria-disabled - const chips = screen.getAllByRole("checkbox"); - const disabledChip = chips.find((c) => c.getAttribute("aria-disabled") === "true"); - expect(disabledChip).toBeTruthy(); - expect(disabledChip?.textContent).toContain("Decorator"); - }); - }); - describe("lens activation (slice 021)", () => { it("renders the status-bar pill when a lens row is clicked", () => { render( @@ -1430,4 +1280,82 @@ describe("GraphView", () => { expect(graph.getNodeAttribute("symbol-pass1only", "entryKind")).toBeUndefined(); }); }); + + // Slice 033 Phase 3 — shell wiring: rails + status into the grid. + describe("shell wiring (slice 033 Phase 3)", () => { + function renderWired() { + const result = render( + , + ); + act(() => { + vi.runOnlyPendingTimers(); + }); + return result; + } + + it("renders the shell as a 3-column grid root", () => { + const { container } = renderWired(); + const shell = container.querySelector('[data-testid="graph-view-shell"]'); + expect(shell).toBeTruthy(); + // The shell root carries the grid module class, not the old flex layout. + expect(shell?.className.includes("dxt-graph-view")).toBe(false); + }); + + it("renders the toolbar in its own grid area (not inside the canvas surface)", () => { + const { container } = renderWired(); + const toolbar = screen.getByRole("toolbar", { name: "Graph toolbar" }); + const canvasSurface = container.querySelector('[data-testid="graph-view"]'); + // Toolbar must not be a descendant of the canvas container. + expect(canvasSurface?.contains(toolbar)).toBe(false); + }); + + it("renders the Node Types filter panel in the left rail", () => { + renderWired(); + expect(screen.getByTestId("node-filter-panel")).toBeTruthy(); + // Lenses + Node Types coexist in the left rail. + expect(screen.getByTestId("lens-row-god-class")).toBeTruthy(); + }); + + it("renders the Edge Types panel in the right rail", () => { + renderWired(); + expect(screen.getByTestId("edge-types-panel")).toBeTruthy(); + }); + + it("toggling a node-type checkbox does not throw and updates the checkbox", () => { + renderWired(); + const fileRow = screen.getByTestId("filter-row-file"); + const checkbox = fileRow.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + act(() => { + fireEvent.click(checkbox); + }); + expect(checkbox.checked).toBe(false); + }); + + it("toggling an edge-type checkbox does not throw and updates the checkbox", () => { + renderWired(); + const callsRow = screen.getByTestId("edge-row-CALLS"); + const checkbox = callsRow.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + act(() => { + fireEvent.click(checkbox); + }); + expect(checkbox.checked).toBe(false); + }); + + it("renders a status bar with a right cluster showing the layout name", () => { + renderWired(); + const statusBar = screen.getByTestId("graph-status-bar"); + expect(statusBar).toBeTruthy(); + expect(within(statusBar).getByText("ForceAtlas2")).toBeTruthy(); + }); + }); }); diff --git a/packages/extension/src/webview/components/GraphView.tsx b/packages/extension/src/webview/components/GraphView.tsx index 541d04f..228e05f 100644 --- a/packages/extension/src/webview/components/GraphView.tsx +++ b/packages/extension/src/webview/components/GraphView.tsx @@ -14,18 +14,29 @@ import { NodeCircleProgram } from "sigma/rendering"; import { applyLayoutPreset, + LAYOUT_PRESET_OPTIONS, restoreNodePositions, snapshotNodePositions, } from "./graphLayoutPresets.js"; import { computeHoverNeighborhood, type HoverNeighborhood } from "./graphHover.js"; import { GraphToolbar } from "./GraphToolbar.js"; -import { InspectorPanel } from "./InspectorPanel.js"; +import { EdgeTypesPanel, type EdgeTypeEntry } from "./EdgeTypesPanel.js"; +import { + InspectorPanel, + type InspectorNeighbor, + type InspectorNeighbors, +} from "./InspectorPanel.js"; import { LENS_REGISTRY, LensesPanel } from "./LensesPanel.js"; import lensesPanelStyles from "./LensesPanel.module.css"; +import { + CANONICAL_NODE_FILTER_LIST, + NodeFilterPanel, + type NodeFilterEntry, +} from "./NodeFilterPanel.js"; +import shellStyles from "./GraphView.module.css"; import { TraceBanner } from "./TraceBanner.js"; import { TraceInspector } from "./TraceInspector.js"; import { dimColor } from "./lensColor.js"; -import { CANONICAL_NODE_FILTER_LIST, type NodeFilterEntry } from "./NodeFilterPanel.js"; import { TRACE_STATE_IDLE, entryVisualState, @@ -675,14 +686,6 @@ function StaticGraphFallback({ ); } -const ALL_EDGE_KINDS: GraphEdge["kind"][] = [ - "DEFINES", - "IMPORTS", - "CALLS", - "INHERITS", - "INSTANTIATES", -]; - function computeSelection( graph: MultiDirectedGraph, selectedNodeId: string | null, @@ -1135,11 +1138,17 @@ export function GraphView({ edges, onNavigate, onExportMermaid, - onExportCurrentView, + onExportCurrentView: _onExportCurrentView, onExportTraceSequence, workspaceName, workspaceFrameworks, onWorkspaceSwitcherClick, + onReindex, + onClearWorkspace, + onClearAll, + onToggleSourceOnly, + sourceOnly, + isIndexing, }: GraphViewProps) { const containerRef = useRef(null); const graphRef = useRef(null); @@ -1257,60 +1266,10 @@ export function GraphView({ const pathNodeIdsRef = useRef>(new Set()); const pathEdgeIdsRef = useRef>(new Set()); - const toggleEdgeKind = useCallback((kind: GraphEdge["kind"]) => { - setHiddenEdgeKinds((prev) => { - const next = new Set(prev); - if (next.has(kind)) { - next.delete(kind); - } else { - next.add(kind); - } - return next; - }); - }, []); - - const toggleNodeKind = useCallback((key: string) => { - setHiddenNodeKinds((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }, []); - const onToggleMinimap = useCallback(() => { setShowMinimap((visible) => !visible); }, []); - const nodeFilterEntries = useMemo(() => { - const counts = new Map(); - let decoratorBackedCount = 0; - for (const node of nodes) { - const key = node.type === "file" ? "file" : (node.symbolKind ?? "function"); - counts.set(key, (counts.get(key) ?? 0) + 1); - // Slice 031 US3 — annotation-backed symbols carry a "decorator-backed" - // flag projected by core/src/query/subgraph.ts so the Decorator chip - // shows a truthful count instead of staying a placeholder. - if (node.flags?.includes("decorator-backed")) { - decoratorBackedCount += 1; - } - } - counts.set("decorator", decoratorBackedCount); - return CANONICAL_NODE_FILTER_LIST.map((template) => ({ - ...template, - count: counts.get(template.key) ?? 0, - // US3 — keep "decorator" honest: when the workspace has no annotation- - // backed nodes the chip stays available but disabled, so the user sees - // why filtering has no effect instead of getting a noisy zero chip. - ...(template.key === "decorator" && decoratorBackedCount === 0 - ? { disabled: true, tooltip: "No decorator-backed symbols found in this workspace." } - : {}), - })); - }, [nodes]); - // Compute counts for all five lenses against the current subgraph. Stub // lenses (selector === null) contribute 0. Runs once per subgraph change. const lensCounts = useMemo>(() => { @@ -1440,6 +1399,93 @@ export function GraphView({ setTraceState(TRACE_STATE_IDLE); }, []); + // Zoom handlers (slice 033 US3). + const handleZoomIn = useCallback((): void => { + const sigma = sigmaRef.current; + const camera = (sigma as SigmaWithExtras).getCamera?.(); + const state = camera?.getState?.(); + if (camera?.animate !== undefined && state !== undefined) { + camera.animate({ x: state.x, y: state.y, ratio: state.ratio * 0.7 }, { duration: 200 }); + } + }, []); + + const handleZoomOut = useCallback((): void => { + const sigma = sigmaRef.current; + const camera = (sigma as SigmaWithExtras).getCamera?.(); + const state = camera?.getState?.(); + if (camera?.animate !== undefined && state !== undefined) { + camera.animate({ x: state.x, y: state.y, ratio: state.ratio * 1.4 }, { duration: 200 }); + } + }, []); + + const handleZoomFit = useCallback((): void => { + const sigma = sigmaRef.current; + if (sigma === null) return; + const container = containerRef.current; + if (container === null) return; + const g = graphRef.current; + if (g === null || g.order === 0) return; + + // Fit all nodes in viewport with 40px padding + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + g.forEachNode((node) => { + const attrs = g.getNodeAttributes(node) as GraphNodeAttributes; + if (attrs.x < minX) minX = attrs.x; + if (attrs.x > maxX) maxX = attrs.x; + if (attrs.y < minY) minY = attrs.y; + if (attrs.y > maxY) maxY = attrs.y; + }); + const padding = 40; + const width = container.clientWidth - padding * 2; + const height = container.clientHeight - padding * 2; + const graphWidth = maxX - minX + 1; + const graphHeight = maxY - minY + 1; + const ratio = Math.max(graphWidth / width, graphHeight / height, 0.1); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const camera = (sigma as SigmaWithExtras).getCamera?.(); + camera?.animate?.({ x: centerX, y: centerY, ratio }, { duration: 300 }); + }, []); + + const handleZoomReset = useCallback((): void => { + const sigma = sigmaRef.current; + const camera = (sigma as SigmaWithExtras).getCamera?.(); + camera?.animate?.({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 300 }); + }, []); + + // Filter toggles (slice 033 US2). The hidden-kind sets already drive the + // Sigma node/edge reducers via their refs; these handlers expose the toggle + // to the left/right rail filter panels. A toggle that adds the kind hides + // it; removing the kind shows it again. + const handleToggleNodeKind = useCallback((key: string): void => { + setHiddenNodeKinds((current) => { + const next = new Set(current); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const handleToggleEdgeKind = useCallback((kind: string): void => { + setHiddenEdgeKinds((current) => { + const next = new Set(current) as Set; + const typed = kind as GraphEdge["kind"]; + if (next.has(typed)) { + next.delete(typed); + } else { + next.add(typed); + } + return next; + }); + }, []); + const handleTracePathCompute = useCallback((startId: string, endId: string): void => { const graph = graphRef.current; if (graph === null || !graph.hasNode(startId) || !graph.hasNode(endId)) { @@ -2150,14 +2196,15 @@ export function GraphView({ travelerSegments.length > 0 ? travelerSegments : overlaySegments.slice(0, 3); // B3: Compute callers and callees from graph during render - const neighborCallers: Array<{ id: string; label: string; filePath: string; startLine: number }> = - []; - const neighborCallees: Array<{ + type CanvasNeighbor = { id: string; label: string; filePath: string; startLine: number; - }> = []; + symbolKind: string; + }; + const neighborCallers: CanvasNeighbor[] = []; + const neighborCallees: CanvasNeighbor[] = []; if (selectedNodeId !== null && graphRef.current !== null) { const g = graphRef.current; @@ -2170,6 +2217,7 @@ export function GraphView({ label: a.label, filePath: a.filePath, startLine: a.startLine, + symbolKind: a.symbolKind ?? "misc", }); }); g.forEachOutboundEdge(selectedNodeId, (_edge, attrs, _src, target) => { @@ -2180,6 +2228,7 @@ export function GraphView({ label: a.label, filePath: a.filePath, startLine: a.startLine, + symbolKind: a.symbolKind ?? "misc", }); }); } @@ -2188,17 +2237,151 @@ export function GraphView({ const MAX_NEIGHBORS = 8; const shownCallers = neighborCallers.slice(0, MAX_NEIGHBORS); const shownCallees = neighborCallees.slice(0, MAX_NEIGHBORS); - const extraCallers = neighborCallers.length - shownCallers.length; - const extraCallees = neighborCallees.length - shownCallees.length; + + // Inspector neighbor lists (slice 033). The Inspector now owns neighbor + // rendering in the right rail; the canvas neighbor panel becomes redundant. + // Implements is not tracked separately yet (CALLS-only graph), so it is empty. + const toInspectorNeighbors = (items: CanvasNeighbor[]): InspectorNeighbor[] => + items.map((n) => ({ + id: n.id, + label: n.label, + filePath: n.filePath, + symbolKind: n.symbolKind, + })); + const inspectorNeighbors: InspectorNeighbors = { + calledBy: toInspectorNeighbors(shownCallers), + calls: toInspectorNeighbors(shownCallees), + implements: [], + }; + + // Left-rail node-type filter entries: canonical order, counts from the + // current node set, Decorator stays a disabled future stub (slice 031). + const nodeKindCounts = new Map(); + for (const node of nodes) { + const key = node.type === "file" ? "file" : (node.symbolKind ?? "misc"); + nodeKindCounts.set(key, (nodeKindCounts.get(key) ?? 0) + 1); + } + const fileCount = nodeKindCounts.get("file") ?? 0; + const symbolCount = nodes.length - fileCount; + const nodeFilterEntries: NodeFilterEntry[] = CANONICAL_NODE_FILTER_LIST.map((entry) => ({ + ...entry, + count: nodeKindCounts.get(entry.key) ?? 0, + ...(entry.key === "decorator" + ? { + disabled: true, + tooltip: "Available after slice 026 — decorator-relationship classification.", + } + : {}), + })); + + // Right-rail edge-type filter entries: counts from the current edge set, + // ordered to match the mockup. INHERITS surfaces as "Extends". + const edgeKindCounts = new Map(); + for (const edge of edges) { + edgeKindCounts.set(edge.kind, (edgeKindCounts.get(edge.kind) ?? 0) + 1); + } + const EDGE_FILTER_ORDER: ReadonlyArray<{ kind: string; label: string }> = [ + { kind: "DEFINES", label: "Defines" }, + { kind: "IMPORTS", label: "Imports" }, + { kind: "CALLS", label: "Calls" }, + { kind: "INHERITS", label: "Extends" }, + { kind: "INSTANTIATES", label: "New" }, + { kind: "IMPLEMENTS", label: "Implements" }, + ]; + const edgeTypeEntries: EdgeTypeEntry[] = EDGE_FILTER_ORDER.filter( + (e) => (edgeKindCounts.get(e.kind) ?? 0) > 0 || e.kind === "IMPLEMENTS", + ).map((e) => ({ + kind: e.kind, + label: e.label, + count: edgeKindCounts.get(e.kind) ?? 0, + ...(e.kind === "IMPLEMENTS" + ? { disabled: true, tooltip: "Implements edges are always shown." } + : {}), + })); + + const activeLayoutLabel = + LAYOUT_PRESET_OPTIONS.find((o) => o.id === layoutSelection.activePreset)?.label ?? + layoutSelection.activePreset; return ( -
- -
+
+ {/* TOOLBAR area */} +
+ 0 + } + {...(onExportTraceSequence !== undefined && { + onExportTrace: () => { + if ( + traceState.phase !== "path-active" || + traceState.startNodeId === null || + traceState.endNodeId === null || + traceState.pathNodeIds.length === 0 + ) { + return; + } + onExportTraceSequence({ + phase: "path-active", + startNodeId: traceState.startNodeId, + endNodeId: traceState.endNodeId, + nodeIds: [...traceState.pathNodeIds], + edgeIds: [...traceState.pathEdgeIds], + }); + }, + })} + {...(workspaceName !== undefined && { workspaceName })} + {...(workspaceFrameworks !== undefined && { workspaceFrameworks })} + {...(onWorkspaceSwitcherClick !== undefined && { onWorkspaceSwitcherClick })} + activeLayoutPreset={layoutSelection.activePreset} + onSelectLayoutPreset={handleSelectLayoutPreset} + onZoomIn={handleZoomIn} + onZoomOut={handleZoomOut} + onZoomFit={handleZoomFit} + onZoomReset={handleZoomReset} + {...(onReindex !== undefined && { onReindex })} + {...(onClearWorkspace !== undefined && { onClearWorkspace })} + {...(onClearAll !== undefined && { onClearAll })} + {...(onToggleSourceOnly !== undefined && { onToggleSourceOnly })} + {...(sourceOnly !== undefined && { sourceOnly })} + {...(isIndexing !== undefined && { isIndexing })} + /> +
+ + {/* LEFT rail: Lenses + Node Types */} + + + {/* CANVAS area */} +
- {selectedNodeId !== null && (neighborCallers.length > 0 || neighborCallees.length > 0) && ( -
- {neighborCallers.length > 0 && ( -
-
Called by
- {shownCallers.map((caller) => ( - - ))} - {extraCallers > 0 && ( - + {extraCallers} more - )} -
- )} - {neighborCallees.length > 0 && ( -
-
Calls
- {shownCallees.map((callee) => ( - - ))} - {extraCallees > 0 && ( - + {extraCallees} more - )} -
- )} + {/* Canvas overlays (slice 033 US4) */} +
+ + + +
+ +
+
Layers
+
+ + Entry
- )} +
+ + Domain +
+
+ + I/O +
+
+
+ + Defines +
+
+ + Imports +
+
+ + Calls +
+
+ +
+ + Click isolate + + + Dbl-click editor + + + Scroll zoom + + + Esc clear + +
- 0 - } - {...(onExportTraceSequence !== undefined && { - onExportTrace: () => { - if ( - traceState.phase !== "path-active" || - traceState.startNodeId === null || - traceState.endNodeId === null || - traceState.pathNodeIds.length === 0 - ) { - return; - } - onExportTraceSequence({ - phase: "path-active", - startNodeId: traceState.startNodeId, - endNodeId: traceState.endNodeId, - nodeIds: [...traceState.pathNodeIds], - edgeIds: [...traceState.pathEdgeIds], - }); - }, - })} - {...(workspaceName !== undefined && { workspaceName })} - {...(workspaceFrameworks !== undefined && { workspaceFrameworks })} - {...(onWorkspaceSwitcherClick !== undefined && { onWorkspaceSwitcherClick })} - activeLayoutPreset={layoutSelection.activePreset} - onSelectLayoutPreset={handleSelectLayoutPreset} - /> {layoutSelection.notice !== null && (
)} -
- {traceState.phase === "path-active" ? ( - n.id === traceState.startNodeId)?.label ?? null) - } - endLabel={ - traceState.endNodeId === null - ? null - : (nodes.find((n) => n.id === traceState.endNodeId)?.label ?? null) - } - onStepClick={handleTraceStepClick} - /> - ) : ( - n.id === selectedNodeId) ?? null) - } - onTraceFromHere={traceState.phase === "idle" ? handleTraceFromHere : undefined} - /> - )} +
+ + {/* RIGHT rail: Edge Types + Inspector (or Trace details in trace mode) */} + + + {/* STATUS bar */} +
+ + {fileCount} files + + + {symbolCount} symbols + + + {edges.length} edges + + {workspaceFrameworks !== undefined && workspaceFrameworks.length > 0 && ( + + {workspaceFrameworks.map((fw) => ( + + {fw} + + ))} + + )} + {activeLensId !== null && ( + + + + + )} + + + + + +
); } diff --git a/packages/extension/src/webview/components/InspectorPanel.module.css b/packages/extension/src/webview/components/InspectorPanel.module.css index daed2c0..bded047 100644 --- a/packages/extension/src/webview/components/InspectorPanel.module.css +++ b/packages/extension/src/webview/components/InspectorPanel.module.css @@ -1,150 +1,165 @@ -/* InspectorPanel — right-rail Inspector for the currently-selected GraphNode (slice 020). */ +/* InspectorPanel — right-rail node inspector (slice 033 US2) */ .panel { - width: 280px; - flex: 0 0 280px; - height: 100%; display: flex; flex-direction: column; - gap: 8px; - padding: 8px 10px; - border-left: 1px solid var(--vscode-panel-border, transparent); - background: var(--vscode-sideBar-background, transparent); - color: var(--vscode-foreground); - font-size: 12px; - line-height: 1.4; overflow-y: auto; - overflow-x: hidden; - box-sizing: border-box; + width: 320px; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px 6px; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-panel-border); } .empty { - flex: 1; display: flex; - flex-direction: column; align-items: center; justify-content: center; - gap: 12px; - color: var(--vscode-descriptionForeground); - text-align: center; + flex: 1; + padding: 16px; } .emptyMessage { font-size: 12px; - opacity: 0.85; -} - -.row { - display: flex; - flex-direction: column; - gap: 2px; + color: var(--vscode-descriptionForeground); } -/* Slice 023 — actions row + "Trace from here" button */ -.actionRow { +.inspectorHeader { + padding: 10px 12px 8px; + border-bottom: 1px solid var(--vscode-panel-border); display: flex; - flex-direction: row; - gap: 6px; - margin-top: 4px; -} - -.traceFromHere { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - background: var(--vscode-button-secondaryBackground, transparent); - color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); - border: 1px solid var(--vscode-button-border, var(--vscode-button-secondaryBackground)); - border-radius: 3px; - font: inherit; - font-size: 11px; - cursor: pointer; -} - -.traceFromHere:hover { - background: var(--vscode-button-secondaryHoverBackground, var(--vscode-list-hoverBackground)); + align-items: flex-start; + gap: 8px; } -.traceFromHere:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 1px; +.inspectorTitle { + flex: 1; + min-width: 0; } -.label { +.inspectorName { font-weight: 600; + color: var(--vscode-foreground); font-size: 13px; - word-break: break-word; + word-break: break-all; } -.kind { +.inspectorMeta { font-size: 11px; color: var(--vscode-descriptionForeground); - text-transform: lowercase; + margin-top: 2px; + word-break: break-all; } -.filePath { - font-size: 11px; - color: var(--vscode-descriptionForeground); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.inspectorBadges { + display: flex; + gap: 4px; + margin-top: 6px; + flex-wrap: wrap; } -.sectionTitle { +.badge { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--vscode-descriptionForeground); - margin-top: 4px; + padding: 1px 6px; + border-radius: 10px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); } .signature { - font-family: var(--vscode-editor-font-family, monospace); + margin: 8px 12px; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.03); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; + color: var(--vscode-foreground); + word-break: break-all; } .docstring { - white-space: pre-wrap; - word-break: break-word; + margin: 0 12px 8px; + padding: 6px 8px; font-size: 11px; - color: var(--vscode-foreground); - max-height: 200px; + color: var(--vscode-descriptionForeground); + border-left: 2px solid var(--vscode-charts-blue); + background: rgba(55, 148, 255, 0.05); +} + +.inspectorBody { overflow-y: auto; + flex: 1; + padding: 8px 4px; +} + +.neighborGroupTitle { + padding: 6px 10px 2px; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); +} + +.neighborRow { + display: grid; + grid-template-columns: 16px 1fr auto; + align-items: center; + gap: 8px; + padding: 4px 10px; + background: none; + border: 0; + width: 100%; + text-align: left; + cursor: pointer; + color: var(--vscode-foreground); + font: inherit; + font-size: 12px; + border-radius: 4px; } -.placeholder { +.neighborRow:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.neighborPath { color: var(--vscode-descriptionForeground); - opacity: 0.7; + font-size: 11px; + font-variant-numeric: tabular-nums; } -.badgeRow { +.inspectorActions { display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 2px; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--vscode-panel-border); } -.badge { +.actionBtn { + height: 28px; + padding: 0 10px; + background: transparent; + color: var(--vscode-foreground); + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 4px; + cursor: pointer; display: inline-flex; align-items: center; - padding: 2px 7px; - border-radius: 4px; - font-size: 10px; - line-height: 1.4; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - white-space: nowrap; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; + gap: 6px; + font: inherit; + font-size: 12px; } -.badgeSkeleton { - composes: badge; - opacity: 0.4; +.actionBtn:hover { + background: var(--vscode-toolbar-hoverBackground); } diff --git a/packages/extension/src/webview/components/InspectorPanel.test.tsx b/packages/extension/src/webview/components/InspectorPanel.test.tsx index 4c62d88..c705cc0 100644 --- a/packages/extension/src/webview/components/InspectorPanel.test.tsx +++ b/packages/extension/src/webview/components/InspectorPanel.test.tsx @@ -9,17 +9,6 @@ import { InspectorPanel, } from "./InspectorPanel.js"; -function fileNode(overrides: Partial = {}): GraphNode { - return { - id: "file-1", - type: "file", - label: "src/example.ts", - filePath: "/workspace/src/example.ts", - startLine: 1, - ...overrides, - }; -} - function symbolNode(overrides: Partial = {}): GraphNode { return { id: "sym-1", @@ -31,9 +20,7 @@ function symbolNode(overrides: Partial = {}): GraphNode { importance: 0.123, framework: "react", frameworkRole: "component", - fanIn: 7, - isCore: true, - flags: ["hot-path"], + visibility: "public", signature: "function doThing(x: number): string", docstring: "Doubles x and stringifies", ...overrides, @@ -43,42 +30,29 @@ function symbolNode(overrides: Partial = {}): GraphNode { describe("InspectorPanel", () => { afterEach(() => cleanup()); - // (1) Empty state when selectedNode === null - it("renders empty-state heading and a 4-slot skeleton when no node is selected", () => { - const { container } = render(); + it("renders empty state when no node is selected", () => { + render(); expect(screen.getByText("Select a node to inspect")).toBeTruthy(); - const skeletons = container.querySelectorAll('[data-testid="badge-skeleton"]'); - expect(skeletons.length).toBe(4); }); - // (2) Populated state for a file node - it("renders label / kind / filepath / badges for a file node", () => { - const { container } = render( - , - ); + it("renders label / filepath / badges for a symbol node", () => { + const { container } = render(); - expect(screen.getByText("src/example.ts")).toBeTruthy(); - expect(screen.getByText("file")).toBeTruthy(); + expect(screen.getByText("doThing")).toBeTruthy(); expect(container.textContent).toContain("/workspace/src/example.ts"); - // Files have no signature/docstring — both rows show "—" const badges = container.querySelectorAll('[data-testid^="badge-"]'); - expect(badges.length).toBe(4); + expect(badges.length).toBe(5); }); - // (3) Populated state for a symbol node with signature + docstring - it("renders signature in monospace and docstring with line breaks preserved", () => { + it("renders signature and docstring when available", () => { const { container } = render(); - expect(screen.getByText("doThing")).toBeTruthy(); - // Symbol kind sub-type rendered alongside type - expect(container.textContent).toContain("function"); - expect(container.textContent).toContain("function doThing(x: number): string"); - expect(container.textContent).toContain("Doubles x and stringifies"); + expect(container.querySelector('[data-testid="inspector-signature"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="inspector-docstring"]')).toBeTruthy(); }); - // (4) Badge row order matches INSPECTOR_BADGE_KEYS - it("renders badges in INSPECTOR_BADGE_KEYS order", () => { + it("badge row order matches INSPECTOR_BADGE_KEYS", () => { const { container } = render(); const badgeEls = Array.from(container.querySelectorAll('[data-testid^="badge-"]')); @@ -86,125 +60,85 @@ describe("InspectorPanel", () => { expect(keys).toEqual([...INSPECTOR_BADGE_KEYS]); }); - // (5) layer + entry badges always render "—" - it('always renders "—" for layer and entry badges (FR-006 stubs)', () => { - const { container } = render(); - - const layer = container.querySelector('[data-testid="badge-layer"]'); - const entry = container.querySelector('[data-testid="badge-entry"]'); - expect(layer?.textContent).toBe("—"); - expect(entry?.textContent).toBe("—"); - }); - - // (6) Updates within React's render cycle on prop change - it("re-renders synchronously when selectedNode changes", () => { - const { rerender, container } = render(); - expect(screen.getByText("Select a node to inspect")).toBeTruthy(); - - rerender(); - expect(container.textContent).toContain("fresh"); - expect(container.textContent).not.toContain("Select a node to inspect"); - }); - - // (7) Geometry stability: badge-row always has 4 children - it("renders exactly 4 elements in the badge row regardless of selection state (FR-006/C2)", () => { + it("renders 5 badges for selected node", () => { const { container, rerender } = render(); - const emptyChildren = container.querySelectorAll('[data-testid="badge-skeleton"]'); - expect(emptyChildren.length).toBe(4); - rerender(); + rerender(); const populatedChildren = container.querySelectorAll('[data-testid^="badge-"]'); - expect(populatedChildren.length).toBe(4); + expect(populatedChildren.length).toBe(5); }); - // (8) Importance boundary at IMPORTANCE_NEAR_ZERO_THRESHOLD - it('formats importance at the threshold boundary as toFixed(3), and "≈ 0" below it', () => { - const atBoundary = render( + it("formats importance at threshold boundary", () => { + const { container } = render( , ); - expect( - atBoundary.container.querySelector('[data-testid="badge-importance"]')?.textContent, - ).toBe("0.001"); - atBoundary.unmount(); + expect(container.querySelector('[data-testid="badge-importance"]')?.textContent).toBe("0.001"); + }); - const belowBoundary = render( + it("formats importance below threshold as ≈ 0", () => { + const { container } = render( , ); - expect( - belowBoundary.container.querySelector('[data-testid="badge-importance"]')?.textContent, - ).toBe("≈ 0"); + expect(container.querySelector('[data-testid="badge-importance"]')?.textContent).toBe("≈ 0"); }); - // (9) Framework separator uses BADGE_SEPARATOR constant, not a hardcoded literal - it("joins framework and frameworkRole with BADGE_SEPARATOR (A1)", () => { + it("joins framework and frameworkRole with separator", () => { const { container } = render(); - const badge = container.querySelector('[data-testid="badge-framework"]'); expect(badge?.textContent).toBe(`react${BADGE_SEPARATOR}component`); }); - // (10) Framework badge shows name alone when frameworkRole is undefined - it("renders framework name without trailing separator when frameworkRole is undefined", () => { + it("renders framework name without separator when frameworkRole is undefined", () => { const { container } = render( , ); - const badge = container.querySelector('[data-testid="badge-framework"]'); expect(badge?.textContent).toBe("django"); - expect(badge?.textContent?.endsWith(BADGE_SEPARATOR)).toBe(false); }); - // Bonus: undefined framework + undefined frameworkRole → "—" - it('renders "—" when both framework and frameworkRole are undefined', () => { - const { container } = render( - , - ); - const badge = container.querySelector('[data-testid="badge-framework"]'); - expect(badge?.textContent).toBe("—"); + it("renders Trace from here when onTraceFromHere provided", () => { + render(); + expect(screen.getByTestId("trace-from-here")).toBeTruthy(); }); - // Bonus: importance undefined → "—" - it('renders "—" for importance when undefined', () => { - const { container } = render( - , - ); - const badge = container.querySelector('[data-testid="badge-importance"]'); - expect(badge?.textContent).toBe("—"); + it("renders Export button always when node is selected", () => { + render(); + expect(screen.getByRole("button", { name: "Export" })).toBeTruthy(); }); - // Slice 023 — "Trace from here" action - it("renders the 'Trace from here' button when onTraceFromHere is provided and a node is selected", () => { + it("calls onTraceFromHere with node id when clicked", () => { const onTraceFromHere = vi.fn(); - const { container } = render( - , - ); - expect(container.querySelector('[data-testid="trace-from-here"]')).toBeTruthy(); + render(); + screen.getByTestId("trace-from-here").click(); + expect(onTraceFromHere).toHaveBeenCalledWith("sym-1"); }); - it("does NOT render 'Trace from here' button when onTraceFromHere is undefined", () => { - const { container } = render(); - expect(container.querySelector('[data-testid="trace-from-here"]')).toBeNull(); - }); + describe("neighbor lists", () => { + const sampleNeighbors = { + calledBy: [{ id: "n1", label: "caller1", filePath: "src/a.ts", symbolKind: "function" }], + calls: [{ id: "n2", label: "callee1", filePath: "src/b.ts", symbolKind: "method" }], + implements: [ + { id: "n3", label: "IRepository", filePath: "src/types.ts", symbolKind: "interface" }, + ], + }; - it("does NOT render 'Trace from here' button when no node is selected", () => { - const { container } = render(); - expect(container.querySelector('[data-testid="trace-from-here"]')).toBeNull(); - }); + it("renders neighbor group titles with counts", () => { + render(); - it("calls onTraceFromHere with the selected node id when the button is clicked", () => { - const onTraceFromHere = vi.fn(); - const { container } = render( - , - ); - const button = container.querySelector( - '[data-testid="trace-from-here"]', - ) as HTMLButtonElement | null; - expect(button).not.toBeNull(); - button?.click(); - expect(onTraceFromHere).toHaveBeenCalledWith("sym-1"); + expect(screen.getByText(/Called by/)).toBeTruthy(); + expect(screen.getByText(/Calls/)).toBeTruthy(); + expect(screen.getByText(/Implements/)).toBeTruthy(); + }); + + it("renders neighbor rows with name and file path", () => { + render(); + + expect(screen.getByText("caller1")).toBeTruthy(); + expect(screen.getByText("callee1")).toBeTruthy(); + expect(screen.getByText("IRepository")).toBeTruthy(); + expect(screen.getByTestId("neighbor-row-n1")).toBeTruthy(); + }); }); }); diff --git a/packages/extension/src/webview/components/InspectorPanel.tsx b/packages/extension/src/webview/components/InspectorPanel.tsx index 4b0a4ed..ff5f25c 100644 --- a/packages/extension/src/webview/components/InspectorPanel.tsx +++ b/packages/extension/src/webview/components/InspectorPanel.tsx @@ -3,34 +3,41 @@ import type React from "react"; import styles from "./InspectorPanel.module.css"; -/** - * Inspector panel constants — runtime source of truth. - * - * Mirrors the spec contract at `specs/020-inspector-panel/contracts/inspector-panel.ts`. - * Production code does not import from `specs/`; this file is the runtime declaration. - * Keep both in sync. - */ -export const INSPECTOR_BADGE_KEYS = ["importance", "framework", "layer", "entry"] as const; +export const INSPECTOR_BADGE_KEYS = [ + "kind", + "exported", + "importance", + "layer", + "framework", +] as const; export type InspectorBadgeKey = (typeof INSPECTOR_BADGE_KEYS)[number]; -/** Unicode middle dot (U+00B7) separating framework name from role. */ export const BADGE_SEPARATOR = "·" as const; -/** - * Scores strictly below this value (but non-zero) render as "≈ 0" with the raw - * value as a tooltip. At or above the threshold, render as `score.toFixed(3)`. - */ export const IMPORTANCE_NEAR_ZERO_THRESHOLD = 0.0005 as const; -/** Fixed CSS width of the Inspector right rail. */ -export const INSPECTOR_PANEL_WIDTH_PX = 280 as const; +export const INSPECTOR_PANEL_WIDTH_PX = 320 as const; const PLACEHOLDER = "—"; +export interface InspectorNeighbor { + id: string; + label: string; + filePath: string; + symbolKind: string; +} + +export interface InspectorNeighbors { + calledBy: InspectorNeighbor[]; + calls: InspectorNeighbor[]; + implements: InspectorNeighbor[]; +} + export interface InspectorPanelProps { selectedNode: GraphNode | null; - /** Slice 023 — when provided, renders a "Trace from here" action button. */ onTraceFromHere?: ((nodeId: string) => void) | undefined; + neighbors?: InspectorNeighbors; + onNeighborClick?: (nodeId: string) => void; } interface BadgeRendering { @@ -38,87 +45,187 @@ interface BadgeRendering { tooltip?: string; } +function renderKind(node: GraphNode): BadgeRendering { + if (node.type === "symbol" && node.symbolKind !== undefined) { + return { text: node.symbolKind }; + } + return { text: node.type }; +} + +function renderExported(_node: GraphNode): BadgeRendering { + // Deferred: GraphNode does not yet carry a visibility/exports indicator. + return { text: PLACEHOLDER }; +} + function renderImportance(node: GraphNode): BadgeRendering { const score = node.importance; - if (score === undefined) { - return { text: PLACEHOLDER }; - } + if (score === undefined) return { text: PLACEHOLDER }; if (score > 0 && score < IMPORTANCE_NEAR_ZERO_THRESHOLD) { return { text: "≈ 0", tooltip: score.toString() }; } return { text: score.toFixed(3) }; } +function renderLayer(node: GraphNode): BadgeRendering { + return { text: node.archLayer ?? PLACEHOLDER }; +} + function renderFramework(node: GraphNode): BadgeRendering { - if (node.framework === undefined) { - return { text: PLACEHOLDER }; - } - if (node.frameworkRole === undefined) { - return { text: node.framework }; - } + if (node.framework === undefined) return { text: PLACEHOLDER }; + if (node.frameworkRole === undefined) return { text: node.framework }; return { text: `${node.framework}${BADGE_SEPARATOR}${node.frameworkRole}` }; } function badgeFor(key: InspectorBadgeKey, node: GraphNode): BadgeRendering { switch (key) { + case "kind": + return renderKind(node); + case "exported": + return renderExported(node); case "importance": return renderImportance(node); + case "layer": + return renderLayer(node); case "framework": return renderFramework(node); - case "layer": - return { text: capitalize(node.archLayer ?? PLACEHOLDER) }; - case "entry": - return { text: capitalize(node.entryKind ?? PLACEHOLDER) }; } } -function capitalize(value: string): string { - if (value.length === 0) return value; - return value.charAt(0).toUpperCase() + value.slice(1); -} - function EmptyState(): React.ReactElement { return (
Select a node to inspect
-
); } +function NeighborSection({ + title, + count, + neighbors, + onClick, +}: { + title: string; + count: number; + neighbors: InspectorNeighbor[]; + onClick?: (id: string) => void; +}) { + return ( + <> +
+ {title} · {count} +
+ {neighbors.map((n) => ( + + ))} + + ); +} + function PopulatedState({ node, onTraceFromHere, + neighbors, + onNeighborClick, }: { node: GraphNode; onTraceFromHere?: ((nodeId: string) => void) | undefined; + neighbors?: InspectorNeighbors; + onNeighborClick?: (nodeId: string) => void; }): React.ReactElement { - const kindLabel = - node.type === "symbol" && node.symbolKind !== undefined ? node.symbolKind : node.type; - return ( <> -
-
- {node.label} +
+
+ + {node.signature !== undefined && ( +
+ {node.signature}
-
{kindLabel}
-
- {node.filePath} + )} + + {node.docstring !== undefined && ( +
+ {node.docstring}
+ )} + +
+ {neighbors && ( + <> + {neighbors.calledBy.length > 0 && ( + + )} + {neighbors.calls.length > 0 && ( + + )} + {neighbors.implements.length > 0 && ( + + )} + + )}
- {onTraceFromHere !== undefined && ( -
+
+ {onTraceFromHere && ( -
- )} - -
-
Signature
- {node.signature !== undefined ? ( -
- {node.signature} -
- ) : ( -
{PLACEHOLDER}
- )} -
- -
-
Docstring
- {node.docstring !== undefined ? ( -
{node.docstring}
- ) : ( -
{PLACEHOLDER}
)} -
- -
- {INSPECTOR_BADGE_KEYS.map((key) => { - const { text, tooltip } = badgeFor(key, node); - return ( - - {text} - - ); - })} +
); @@ -166,13 +251,23 @@ function PopulatedState({ export function InspectorPanel({ selectedNode, onTraceFromHere, + neighbors, + onNeighborClick, }: InspectorPanelProps): React.ReactElement { return ( ); diff --git a/packages/extension/src/webview/components/LensesPanel.test.tsx b/packages/extension/src/webview/components/LensesPanel.test.tsx index e2baddc..057d030 100644 --- a/packages/extension/src/webview/components/LensesPanel.test.tsx +++ b/packages/extension/src/webview/components/LensesPanel.test.tsx @@ -117,4 +117,52 @@ describe("LensesPanel", () => { expect(onToggle).not.toHaveBeenCalled(); expect(screen.getByTestId("lens-row-god-class").getAttribute("aria-pressed")).toBe("true"); }); + + // Slice 033 US2 — mockup lens row structure + describe("mockup structure (slice 033 US2)", () => { + it("renders each lens row with icon, title, description, and count badge", () => { + render( + undefined} + />, + ); + + const row = screen.getByTestId("lens-row-god-class"); + // Icon is present (Codicon span inside .lensIcon) + expect(row.querySelector(".codicon-star")).not.toBeNull(); + // Title text is rendered + expect(row.textContent).toContain("God class / function"); + // Description is rendered + expect(row.textContent).toContain("Top-10 by PageRank"); + // Count badge shows value + expect(row.textContent).toContain("7"); + }); + + it("activates lens row with aria-pressed=true", () => { + render( + undefined} + />, + ); + + const row = screen.getByTestId("lens-row-god-class"); + expect(row.getAttribute("aria-pressed")).toBe("true"); + }); + + it("renders section header", () => { + render( + undefined} + />, + ); + + expect(screen.getByText("LENSES")).toBeTruthy(); + }); + }); }); diff --git a/packages/extension/src/webview/components/LensesPanel.tsx b/packages/extension/src/webview/components/LensesPanel.tsx index 0f5dd0b..7ceadb2 100644 --- a/packages/extension/src/webview/components/LensesPanel.tsx +++ b/packages/extension/src/webview/components/LensesPanel.tsx @@ -76,7 +76,7 @@ export function LensesPanel({ return (
- Lenses + LENSES

Highlight nodes by importance signal

@@ -105,14 +105,22 @@ export function LensesPanel({ title={disabled ? descriptor.disabledTooltip : undefined} onClick={handleClick} > -