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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,32 @@ export default [
],
},
},
{
// Two-engine boundary (RULE-ARCH-002): Sigma.js is the main graph view;
// React Flow (@xyflow/react) is allowed ONLY for focused subgraphs under
// webview/blast-radius/. The legacy `reactflow` package is banned outright.
// Mirrors the driver-behind-adapter confinement used in core.
files: ["packages/extension/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.ts", "**/*.test.tsx", "packages/extension/src/webview/blast-radius/**"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "reactflow",
message:
"Use @xyflow/react (v12+), not the legacy reactflow package (RULE-ARCH-002).",
},
{
name: "@xyflow/react",
message:
"React Flow is for focused subgraphs only — import it under webview/blast-radius/. The main graph uses Sigma.",
},
],
},
],
},
},
prettier,
];
1 change: 1 addition & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"dependencies": {
"@sigma/node-border": "^3.0.0",
"@sigma/node-square": "^3.0.0",
"@xyflow/react": "^12.11.0",
"dompurify": "^3.4.7",
"graphology": "^0.26.0",
"graphology-communities-louvain": "^2.0.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* Focused (React Flow) view canvas. VS Code variables only. */

.canvas {
position: relative;
width: 100%;
height: 100%;
background: var(--vscode-editor-background);
}

.truncationNotice {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
padding: 4px 10px;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-descriptionForeground);
font-size: 11px;
pointer-events: none;
}
100 changes: 100 additions & 0 deletions packages/extension/src/webview/blast-radius/FocusedGraphView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
Background,
Controls,
MarkerType,
ReactFlow,
ReactFlowProvider,
type Edge,
type Node,
} from "@xyflow/react";
import type React from "react";
import { useMemo } from "react";

import "@xyflow/react/dist/style.css";

import type { GraphEdge, GraphNode } from "@dextree/core";

import type { SelectionTraversal, ThemeColors } from "../components/graphViewTypes.js";
import { focusedEdgeStyle } from "./edgeStyles.js";
import { buildFocusedGraphModel } from "./focusedGraphModel.js";
import { computeFocusedLayout } from "./focusedLayout.js";
import { SymbolNode } from "./SymbolNode.js";
import styles from "./FocusedGraphView.module.css";

/**
* The React Flow focused view: a node's neighbourhood as boxed cards with routed
* edges. Pure-model-driven — `buildFocusedGraphModel` / `computeFocusedLayout` /
* `focusedEdgeStyle` (all unit-tested without this runtime) decide *what* and
* *where*; this component only maps those into React Flow's `Node`/`Edge` shapes
* and renders the canvas. The only `@xyflow/react` import lives here + SymbolNode,
* confined to blast-radius/ by ESLint (RULE-ARCH-002).
*/

const nodeTypes = { symbol: SymbolNode };

export interface FocusedGraphViewProps {
graphNodes: readonly GraphNode[];
graphEdges: readonly GraphEdge[];
traversal: SelectionTraversal;
colors: ThemeColors;
}

export function FocusedGraphView({
graphNodes,
graphEdges,
traversal,
colors,
}: FocusedGraphViewProps): React.ReactElement {
const { nodes, edges, truncatedCount } = useMemo(() => {
const model = buildFocusedGraphModel(graphNodes, graphEdges, traversal);
const positions = computeFocusedLayout(model.nodes);

const rfNodes: Node[] = model.nodes.map((n) => ({
id: n.id,
type: "symbol",
position: positions.get(n.id) ?? { x: 0, y: 0 },
data: n as unknown as Record<string, unknown>,
}));

const rfEdges: Edge[] = model.edges.map((e) => {
const style = focusedEdgeStyle(e, colors);
return {
id: e.id,
source: e.source,
target: e.target,
type: style.type,
style: { stroke: style.color, strokeWidth: style.strokeWidth },
...(style.markerEnd
? { markerEnd: { type: MarkerType.ArrowClosed, color: style.color } }
: {}),
};
});

return { nodes: rfNodes, edges: rfEdges, truncatedCount: model.truncatedCount };
}, [graphNodes, graphEdges, traversal, colors]);

return (
<ReactFlowProvider>
<div className={styles.canvas} data-testid="focused-graph-view">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
proOptions={{ hideAttribution: true }}
nodesDraggable
nodesConnectable={false}
elementsSelectable
>
<Background />
<Controls showInteractive={false} />
</ReactFlow>
{truncatedCount > 0 ? (
<div className={styles.truncationNotice} data-testid="focused-truncation">
Showing the {nodes.length} most relevant nodes; {truncatedCount} more hidden.
</div>
) : null}
</div>
</ReactFlowProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type React from "react";
import { lazy, Suspense } from "react";

import type { FocusedGraphViewProps } from "./FocusedGraphView.js";

/**
* Lazy entry point for the focused view. `FocusedGraphView` (the sole importer of
* `@xyflow/react`) is loaded via `React.lazy` so the React Flow component tree is
* only constructed when a user actually focuses a node.
*
* NOTE: the webview is built as a single IIFE (`vite lib`, `formats: ["iife"]`),
* which cannot emit dynamic-import chunks — so this does NOT keep React Flow out
* of the bundle (it ships in the one webview.js regardless). The deferral is of
* render/construction work, not bytes. Kept because it is correct, costs nothing,
* and becomes a real code-split if the webview build ever moves to multi-chunk.
*/
const FocusedGraphViewInner = lazy(() =>
import("./FocusedGraphView.js").then((m) => ({ default: m.FocusedGraphView })),
);

export function LazyFocusedGraphView(props: FocusedGraphViewProps): React.ReactElement {
return (
<Suspense fallback={null}>
<FocusedGraphViewInner {...props} />
</Suspense>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* Boxed-card node for the focused (React Flow) view. VS Code variables only. */

.card {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 6px 10px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-foreground);
font-size: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}

/* The focused node — emphasised with the accent border. */
.focus {
border-color: var(--vscode-focusBorder);
border-width: 2px;
}

/* High-importance / core nodes get a subtler emphasis than the focus node. */
.core {
border-color: var(--vscode-charts-yellow, var(--vscode-panel-border));
}

.icon {
flex: 0 0 auto;
font-size: 14px;
color: var(--vscode-symbolIcon-classForeground, var(--vscode-foreground));
}

.label {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}

.badge {
flex: 0 0 auto;
padding: 1px 6px;
border-radius: 999px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
font-size: 10px;
text-transform: lowercase;
}

.handle {
width: 6px;
height: 6px;
background: var(--vscode-panel-border);
border: none;
}
57 changes: 57 additions & 0 deletions packages/extension/src/webview/blast-radius/SymbolNode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ReactFlowProvider } from "@xyflow/react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import type { FocusedNode } from "./focusedGraphModel.js";
import { SymbolNode } from "./SymbolNode.js";

afterEach(cleanup);

function node(over: Partial<FocusedNode> = {}): FocusedNode {
return {
id: "n1",
label: "getUser",
nodeType: "symbol",
symbolKind: "function",
importance: 1,
isCore: false,
ring: 1,
isFocus: false,
...over,
};
}

/** SymbolNode uses React Flow's <Handle>, which needs the provider context. */
function renderNode(data: FocusedNode) {
// NodeProps has many required fields; the card only reads `data`.
const props = { data } as unknown as Parameters<typeof SymbolNode>[0];
return render(
<ReactFlowProvider>
<SymbolNode {...props} />
</ReactFlowProvider>,
);
}

describe("SymbolNode", () => {
it("renders the symbol name and kind badge inside a card", () => {
renderNode(node({ label: "getUser", symbolKind: "function" }));
const card = screen.getByTestId("focused-symbol-node");
expect(card.textContent).toContain("getUser");
expect(card.textContent).toContain("function");
});

it("shows the file badge/icon for a file node", () => {
renderNode(node({ nodeType: "file", symbolKind: undefined, label: "user.ts" }));
const card = screen.getByTestId("focused-symbol-node");
expect(card.textContent).toContain("user.ts");
expect(card.textContent).toContain("file");
expect(card.querySelector(".codicon-symbol-file")).not.toBeNull();
});

it("renders a class node with the class icon", () => {
renderNode(node({ symbolKind: "class", label: "UserService" }));
const card = screen.getByTestId("focused-symbol-node");
expect(card.querySelector(".codicon-symbol-class")).not.toBeNull();
expect(card.textContent).toContain("class");
});
});
64 changes: 64 additions & 0 deletions packages/extension/src/webview/blast-radius/SymbolNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type React from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";

import type { FocusedNode } from "./focusedGraphModel.js";
import styles from "./SymbolNode.module.css";

/**
* Boxed-card custom node for the focused (React Flow) view: the symbol name and a
* type badge rendered *inside* a box (vs Sigma's point + outside label). Source/
* target handles let React Flow route edges to the card's edge, not its centre,
* which is what keeps routed edges from crossing through cards.
*
* VS Code CSS variables + codicons only (project rules). The node payload is the
* pure `FocusedNode` from `focusedGraphModel`.
*/

/** Codicon for a node's kind — symbols by symbolKind, containers by type. */
function codiconFor(node: FocusedNode): string {
if (node.nodeType === "file") return "symbol-file";
if (node.nodeType === "folder") return "folder";
switch (node.symbolKind) {
case "class":
return "symbol-class";
case "interface":
return "symbol-interface";
case "method":
return "symbol-method";
case "enum":
return "symbol-enum";
case "type":
return "symbol-type-parameter";
case "variable":
return "symbol-variable";
case "function":
return "symbol-method";
default:
return "symbol-misc";
}
}

/** Human label for the badge — the symbolKind, or the node type for containers. */
function kindLabel(node: FocusedNode): string {
if (node.nodeType !== "symbol") return node.nodeType;
return node.symbolKind ?? "symbol";
}

export type SymbolNodeData = FocusedNode;

export function SymbolNode({ data }: NodeProps): React.ReactElement {
const node = data as unknown as SymbolNodeData;
const classes = [styles.card];
if (node.isFocus) classes.push(styles.focus);
else if (node.isCore) classes.push(styles.core);

return (
<div className={classes.join(" ")} data-testid="focused-symbol-node" title={node.label}>
<Handle type="target" position={Position.Top} className={styles.handle} />
<span className={`codicon codicon-${codiconFor(node)} ${styles.icon}`} aria-hidden="true" />
<span className={styles.label}>{node.label}</span>
<span className={styles.badge}>{kindLabel(node)}</span>
<Handle type="source" position={Position.Bottom} className={styles.handle} />
</div>
);
}
Loading
Loading