mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 06:35:50 +00:00
feat(dep-graph): quieter refresh, persistent viewport, public surface, search highlight
- plugin: fingerprint the analyzer output (services/edges/unknownTokens, excluding generatedAt) and only invalidate the virtual module on real content diffs, so ordinary edits no longer trigger a browser reload. - web viewer: persist the ReactFlow viewport (x/y/zoom) to sessionStorage and restore on mount, so reloads no longer snap back to fitView. - analyzer: expose each service's public interface surface via a new optional ServiceNode.publicMembers (methods + property signatures, _serviceBrand filtered out), and register PRODUCTION_OVERRIDES so bootstrap-seeded bindings resolve to their real backends. - web viewer: seed node inPorts from publicMembers so uncalled methods still render; dim the handle and label of ports with no incoming edge so the connected-vs-declared distinction reads at a glance. - web viewer: extend search to also match publicMembers, and turn search into a highlight/dim treatment (matches + neighbors stay bright, the rest dim) instead of filtering — matched nodes get a cyan outline distinct from the yellow selection outline.
This commit is contained in:
parent
b9259abd3f
commit
6e8beccba1
5 changed files with 415 additions and 54 deletions
|
|
@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url';
|
|||
import {
|
||||
type CallExpression,
|
||||
type ClassDeclaration,
|
||||
type InterfaceDeclaration,
|
||||
type Node,
|
||||
type ParameterDeclaration,
|
||||
Project,
|
||||
|
|
@ -70,6 +71,28 @@ const FRAMEWORK_BINDINGS: readonly { token: string; scope: ServiceScope; impl: s
|
|||
{ token: 'IAgentScopeContext', scope: 'Agent', impl: 'AgentScopeContext' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Production composition-root bindings seeded by `bootstrap()` via
|
||||
* `ScopeOptions.extra`. `buildCollection` applies `extra` AFTER the static
|
||||
* `registerScopedService` registry, so these take precedence at runtime: they
|
||||
* override a static default where one exists (e.g. `ISkillCatalogStore` →
|
||||
* `FileSkillCatalogStore`) and supply the binding where the layer ships no
|
||||
* in-package default (the Storage-layer tokens → `FileStorageService`, whose
|
||||
* in-memory backend is no longer auto-registered). The analyzer mirrors that
|
||||
* so the graph reflects the backend that actually runs in production.
|
||||
*
|
||||
* Each entry's `file`/`line`/`domain` are derived from the impl class
|
||||
* declaration at analysis time, so the node points at the real backend rather
|
||||
* than any registration site it replaces.
|
||||
*/
|
||||
const PRODUCTION_OVERRIDES: readonly { token: string; scope: ServiceScope; impl: string }[] = [
|
||||
{ token: 'IStorageService', scope: 'App', impl: 'FileStorageService' },
|
||||
{ token: 'IAppendLogStorage', scope: 'App', impl: 'FileStorageService' },
|
||||
{ token: 'IAtomicDocumentStorage', scope: 'App', impl: 'FileStorageService' },
|
||||
{ token: 'IBlobStorage', scope: 'App', impl: 'FileStorageService' },
|
||||
{ token: 'ISkillCatalogStore', scope: 'App', impl: 'FileSkillCatalogStore' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Turn a `(scope, token)` pair into the unique node id used across the
|
||||
* graph. This matches the DI registration identity: one `registerScopedService`
|
||||
|
|
@ -169,6 +192,53 @@ function sameRef(a: EdgeRef, b: EdgeRef): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every top-level `interface` declaration in the tree, keyed by
|
||||
* name. Used to pull each service's public callable surface out of its
|
||||
* token interface (e.g. `interface IAgentSystemReminderService { ... }`)
|
||||
* so the graph view can render every method as a port row even when
|
||||
* nothing calls into it yet.
|
||||
*
|
||||
* Duplicate names win latest — TS itself would merge them via declaration
|
||||
* merging, but the codebase does not intentionally split a service
|
||||
* interface across files, so ties here are effectively edge cases.
|
||||
*/
|
||||
function collectInterfaces(sourceFiles: SourceFile[]): Map<string, InterfaceDeclaration> {
|
||||
const out = new Map<string, InterfaceDeclaration>();
|
||||
for (const file of sourceFiles) {
|
||||
for (const iface of file.getInterfaces()) {
|
||||
const name = iface.getName();
|
||||
if (!name) continue;
|
||||
out.set(name, iface);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the public callable surface names on an interface — every method
|
||||
* signature name plus every property name — sorted and de-duplicated.
|
||||
* Skips `_serviceBrand` (the DI type-erased identity marker) and index /
|
||||
* call signatures (they have no member name to render as a port). TS
|
||||
* interfaces cannot declare `private` members, so every remaining name is
|
||||
* part of the public API by construction.
|
||||
*/
|
||||
function collectInterfaceMembers(iface: InterfaceDeclaration): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const member of iface.getMembers()) {
|
||||
const kind = member.getKind();
|
||||
if (kind === SyntaxKind.MethodSignature) {
|
||||
const name = member.asKindOrThrow(SyntaxKind.MethodSignature).getName();
|
||||
names.add(name);
|
||||
} else if (kind === SyntaxKind.PropertySignature) {
|
||||
const name = member.asKindOrThrow(SyntaxKind.PropertySignature).getName();
|
||||
if (name === '_serviceBrand') continue;
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the token identifier from a `registerScopedService(...)` call.
|
||||
* Returns `undefined` if the call doesn't match the expected shape.
|
||||
|
|
@ -471,6 +541,7 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}
|
|||
const sourceFiles = project.getSourceFiles();
|
||||
|
||||
const { services, implClasses, bindings } = collectServices(sourceFiles);
|
||||
const interfacesByName = collectInterfaces(sourceFiles);
|
||||
|
||||
// Seed the framework tokens as synthetic nodes so edges to them resolve
|
||||
// like any other registered service. They are marked domain=`framework`
|
||||
|
|
@ -495,6 +566,40 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}
|
|||
if (!scopeMap.has(node.scope)) scopeMap.set(node.scope, node);
|
||||
}
|
||||
|
||||
// Apply production composition-root bindings: bootstrap() seeds these tokens
|
||||
// via `extra`, which the container applies after the static registry. They
|
||||
// override any static default (skill catalog) or supply the binding outright
|
||||
// (storage layer, which ships no in-package default). Mirror that here so
|
||||
// edges resolve to the backend that actually runs in production.
|
||||
for (const override of PRODUCTION_OVERRIDES) {
|
||||
const id = nodeId(override.scope, override.token);
|
||||
const cls = implClasses.get(override.impl);
|
||||
const file = cls ? relFromRepo(cls.getSourceFile().getFilePath()) : SRC_ROOT;
|
||||
const domain = cls ? domainOf(cls.getSourceFile().getFilePath()) : 'unknown';
|
||||
const line = cls ? cls.getStartLineNumber() : 0;
|
||||
const node: ServiceNode = {
|
||||
id,
|
||||
token: override.token,
|
||||
impl: override.impl,
|
||||
scope: override.scope,
|
||||
domain,
|
||||
file,
|
||||
line,
|
||||
};
|
||||
const existingIndex = services.findIndex((s) => s.id === id);
|
||||
if (existingIndex >= 0) {
|
||||
services[existingIndex] = node;
|
||||
} else {
|
||||
services.push(node);
|
||||
}
|
||||
let scopeMap = bindings.get(override.token);
|
||||
if (!scopeMap) {
|
||||
scopeMap = new Map();
|
||||
bindings.set(override.token, scopeMap);
|
||||
}
|
||||
scopeMap.set(override.scope, node);
|
||||
}
|
||||
|
||||
const acc: EdgeAccumulator = {
|
||||
services,
|
||||
edges: new Map(),
|
||||
|
|
@ -502,6 +607,18 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}
|
|||
unknownRefs: new Set(),
|
||||
};
|
||||
|
||||
// Attach each service's public callable surface. Runs after registration,
|
||||
// framework seeding, and PRODUCTION_OVERRIDES so every node in the graph
|
||||
// gets the same treatment. Nodes whose token has no interface declaration
|
||||
// in `src/` (framework tokens, synthetic overrides) simply get no
|
||||
// `publicMembers` field — the view falls back to the edge-derived ports.
|
||||
for (const svc of services) {
|
||||
const iface = interfacesByName.get(svc.token);
|
||||
if (!iface) continue;
|
||||
const members = collectInterfaceMembers(iface);
|
||||
if (members.length > 0) svc.publicMembers = members;
|
||||
}
|
||||
|
||||
for (const svc of services) {
|
||||
const cls = implClasses.get(svc.impl);
|
||||
if (!cls) continue;
|
||||
|
|
@ -516,6 +633,53 @@ export function analyze(options: { srcRoot?: string; generatedAt?: string } = {}
|
|||
collectRuntimeEdges(cls, svc, injectedFields, acc);
|
||||
}
|
||||
|
||||
// Synthesise interface-only nodes for tokens referenced by edges but with no
|
||||
// registered impl at any scope. Each unresolved edge already targets
|
||||
// `unresolved::${token}`; creating a matching node lets the viewer render it
|
||||
// (with a distinct border) instead of dropping the edge as dangling. The node
|
||||
// is placed at the outer-most scope that references it — a hint at where the
|
||||
// missing binding is first needed — and inherits the interface's declared
|
||||
// public surface so its ports read like a real service.
|
||||
const nodeById = new Map(services.map((s) => [s.id, s]));
|
||||
const unresolvedReferrers = new Map<string, Set<ServiceScope>>();
|
||||
for (const edge of acc.edges.values()) {
|
||||
if (!edge.unresolved) continue;
|
||||
let scopes = unresolvedReferrers.get(edge.token);
|
||||
if (!scopes) {
|
||||
scopes = new Set();
|
||||
unresolvedReferrers.set(edge.token, scopes);
|
||||
}
|
||||
const source = nodeById.get(edge.from);
|
||||
if (source) scopes.add(source.scope);
|
||||
}
|
||||
for (const [token, scopes] of unresolvedReferrers) {
|
||||
let scope: ServiceScope = 'App';
|
||||
let minLevel = Number.POSITIVE_INFINITY;
|
||||
for (const s of scopes) {
|
||||
const lvl = SCOPE_LEVEL[s];
|
||||
if (lvl < minLevel) {
|
||||
minLevel = lvl;
|
||||
scope = s;
|
||||
}
|
||||
}
|
||||
const node: ServiceNode = {
|
||||
id: `unresolved::${token}`,
|
||||
token,
|
||||
impl: token,
|
||||
scope,
|
||||
domain: 'unresolved',
|
||||
file: '',
|
||||
line: 0,
|
||||
unresolved: true,
|
||||
};
|
||||
const iface = interfacesByName.get(token);
|
||||
if (iface) {
|
||||
const members = collectInterfaceMembers(iface);
|
||||
if (members.length > 0) node.publicMembers = members;
|
||||
}
|
||||
services.push(node);
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: options.generatedAt ?? new Date(0).toISOString(),
|
||||
services: services.sort(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,22 @@ export interface ServiceNode {
|
|||
file: string;
|
||||
/** 1-indexed line of the `registerScopedService(...)` call. */
|
||||
line: number;
|
||||
/**
|
||||
* Public callable surface of this service — the method/property names
|
||||
* declared on the interface identified by `token`. Sorted, deduped, with
|
||||
* the `_serviceBrand` DI marker filtered out. Absent when the analyzer
|
||||
* couldn't locate an interface declaration for the token (e.g. synthetic
|
||||
* framework bindings whose token has no interface in `src/`).
|
||||
*/
|
||||
publicMembers?: string[];
|
||||
/**
|
||||
* True for synthesized interface-only nodes: the token is referenced by at
|
||||
* least one edge but has no implementation registered at any scope. These
|
||||
* nodes have no real impl (so `impl` mirrors `token`) and the viewer renders
|
||||
* them with a distinct border so missing bindings stand out from concrete
|
||||
* services rather than being dropped as dangling edges.
|
||||
*/
|
||||
unresolved?: true;
|
||||
}
|
||||
|
||||
export interface EdgeRef {
|
||||
|
|
|
|||
|
|
@ -45,31 +45,59 @@ interface PluginOptions {
|
|||
writeSnapshotFile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural fingerprint of a graph: services + edges + unknownTokens only,
|
||||
* with `generatedAt` deliberately excluded. The analyzer already sorts each
|
||||
* of these arrays deterministically, so a stable `JSON.stringify` is enough
|
||||
* to detect real content changes and ignore metadata-only churn (e.g. the
|
||||
* HEAD sha bumping without any DI edit).
|
||||
*/
|
||||
function fingerprint(g: Graph): string {
|
||||
return JSON.stringify({
|
||||
services: g.services,
|
||||
edges: g.edges,
|
||||
unknownTokens: g.unknownTokens,
|
||||
});
|
||||
}
|
||||
|
||||
export function depGraphPlugin(options: PluginOptions = {}): Plugin {
|
||||
const shouldWrite = options.writeSnapshotFile ?? true;
|
||||
let cached: Graph | undefined;
|
||||
let cachedFingerprint: string | undefined;
|
||||
let server: ViteDevServer | undefined;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let watcher: FSWatcher | undefined;
|
||||
|
||||
function analyzeNow(reason: string): void {
|
||||
/**
|
||||
* Re-run the analyzer and swap `cached` only when the structural
|
||||
* fingerprint changed. Returns whether the graph actually changed so the
|
||||
* caller can decide whether to invalidate the virtual module.
|
||||
*/
|
||||
function analyzeNow(reason: string): boolean {
|
||||
const started = Date.now();
|
||||
cached = analyze({ generatedAt: tag() });
|
||||
if (shouldWrite) writeSnapshot(cached);
|
||||
const next = analyze({ generatedAt: tag() });
|
||||
const nextFingerprint = fingerprint(next);
|
||||
const changed = nextFingerprint !== cachedFingerprint;
|
||||
if (changed) {
|
||||
cached = next;
|
||||
cachedFingerprint = nextFingerprint;
|
||||
if (shouldWrite) writeSnapshot(next);
|
||||
}
|
||||
const took = Date.now() - started;
|
||||
console.log(
|
||||
`[dep-graph] ${reason} → ${summarize(cached)}${
|
||||
shouldWrite ? ` (wrote ${relative(process.cwd(), SNAPSHOT_PATH)})` : ''
|
||||
} in ${took}ms`,
|
||||
);
|
||||
const suffix = changed
|
||||
? shouldWrite
|
||||
? ` (wrote ${relative(process.cwd(), SNAPSHOT_PATH)})`
|
||||
: ''
|
||||
: ' (no change)';
|
||||
console.log(`[dep-graph] ${reason} → ${summarize(next)}${suffix} in ${took}ms`);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function scheduleRefresh(reason: string): void {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = undefined;
|
||||
analyzeNow(reason);
|
||||
invalidate();
|
||||
if (analyzeNow(reason)) invalidate();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,9 +62,10 @@ export function Filters({ graph, domains, state, onChange }: FiltersProps): JSX.
|
|||
</div>
|
||||
|
||||
<input
|
||||
placeholder="filter by token/impl…"
|
||||
placeholder="search service, interface, method…"
|
||||
value={state.search}
|
||||
onChange={(e) => onChange({ ...state, search: e.target.value })}
|
||||
title="Substring match across impl class, token interface, domain, and public members"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Position,
|
||||
ReactFlow,
|
||||
type Edge as RFEdge,
|
||||
type Viewport,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { useMemo } from 'react';
|
||||
|
|
@ -36,6 +37,13 @@ const PORTS_PAD_TOP = 4;
|
|||
interface ServicePortsInfo {
|
||||
inPorts: string[];
|
||||
outPorts: string[];
|
||||
/**
|
||||
* Subset of `inPorts` that actually has at least one edge terminating on
|
||||
* it (as opposed to being seeded from the interface's declared surface
|
||||
* with no caller). Used to dim the handle / label so unused public
|
||||
* methods stand out visually.
|
||||
*/
|
||||
connectedIn: Set<string>;
|
||||
}
|
||||
|
||||
interface GraphViewProps {
|
||||
|
|
@ -49,6 +57,12 @@ interface GraphViewProps {
|
|||
interface ServiceNodeData extends Record<string, unknown> {
|
||||
service: ServiceNode;
|
||||
selected: boolean;
|
||||
/**
|
||||
* True when the search box has content and this node matches. Rendered
|
||||
* as a distinct cyan outline so search hits are visually separable from
|
||||
* the yellow-outlined click-selected node.
|
||||
*/
|
||||
matched: boolean;
|
||||
dim: boolean;
|
||||
ports: ServicePortsInfo;
|
||||
}
|
||||
|
|
@ -71,18 +85,34 @@ function effectiveToMethod(kind: EdgeKind, refTo: string | undefined): string |
|
|||
/**
|
||||
* Build the port lists per node from a set of edges.
|
||||
*
|
||||
* The port list depends on which edges are actually rendered — filtering out
|
||||
* a whole edge kind, for example, also hides the port rows it would have
|
||||
* populated. Computing this off the filtered edges keeps the node from
|
||||
* showing dangling ports with nothing connected.
|
||||
* `inPorts` are seeded from `service.publicMembers` — every method /
|
||||
* property declared on the service's interface, whether anything actually
|
||||
* calls it or not, so the node advertises its full public surface. Any
|
||||
* inbound edge method that isn't already in that seed (unusual — usually
|
||||
* event-bus edges named after the kind) is folded in too.
|
||||
*
|
||||
* `outPorts` remain edge-driven: they are the methods on THIS service
|
||||
* that make a call outward, so filtering out an edge kind naturally
|
||||
* collapses the rows it would have populated.
|
||||
*/
|
||||
function computeServicePorts(
|
||||
services: ServiceNode[],
|
||||
edges: Edge[],
|
||||
): Map<string, ServicePortsInfo> {
|
||||
const acc = new Map<string, { in: Set<string>; out: Set<string> }>();
|
||||
const acc = new Map<
|
||||
string,
|
||||
{ in: Set<string>; out: Set<string>; connectedIn: Set<string> }
|
||||
>();
|
||||
for (const s of services) {
|
||||
acc.set(s.id, { in: new Set(), out: new Set() });
|
||||
const bucket = {
|
||||
in: new Set<string>(),
|
||||
out: new Set<string>(),
|
||||
connectedIn: new Set<string>(),
|
||||
};
|
||||
if (s.publicMembers) {
|
||||
for (const name of s.publicMembers) bucket.in.add(name);
|
||||
}
|
||||
acc.set(s.id, bucket);
|
||||
}
|
||||
for (const e of edges) {
|
||||
const src = acc.get(e.from);
|
||||
|
|
@ -90,7 +120,10 @@ function computeServicePorts(
|
|||
for (const ref of e.refs) {
|
||||
const toMethod = effectiveToMethod(e.kind, ref.toMethod);
|
||||
if (ref.fromMethod !== undefined && src) src.out.add(ref.fromMethod);
|
||||
if (toMethod !== undefined && dst) dst.in.add(toMethod);
|
||||
if (toMethod !== undefined && dst) {
|
||||
dst.in.add(toMethod);
|
||||
dst.connectedIn.add(toMethod);
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = new Map<string, ServicePortsInfo>();
|
||||
|
|
@ -98,6 +131,7 @@ function computeServicePorts(
|
|||
result.set(id, {
|
||||
inPorts: [...sets.in].sort(),
|
||||
outPorts: [...sets.out].sort(),
|
||||
connectedIn: sets.connectedIn,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
|
|
@ -110,17 +144,36 @@ function nodeHeight(ports: ServicePortsInfo): number {
|
|||
}
|
||||
|
||||
function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Element {
|
||||
const { service, selected, dim, ports } = data;
|
||||
const { service, selected, matched, dim, ports } = data;
|
||||
const bg = SCOPE_STYLE[service.scope].color;
|
||||
const rowCount = Math.max(ports.inPorts.length, ports.outPorts.length);
|
||||
// Interface-only node: the token is referenced but has no registered impl.
|
||||
// Flagged with a dashed warning border so missing bindings stand out from
|
||||
// concrete services at a glance. Selection / search-match still win so the
|
||||
// active node stays unambiguous.
|
||||
const isUnresolved = service.unresolved === true;
|
||||
const borderColor = selected
|
||||
? '#ffdf5d'
|
||||
: matched
|
||||
? '#79c0ff'
|
||||
: isUnresolved
|
||||
? '#f85149'
|
||||
: 'rgba(0,0,0,0.4)';
|
||||
const borderWidth = selected || matched || isUnresolved ? 2 : 1;
|
||||
const borderStyle = isUnresolved && !selected && !matched ? 'dashed' : 'solid';
|
||||
const glow = selected
|
||||
? '0 0 0 3px rgba(255,223,93,0.25)'
|
||||
: matched
|
||||
? '0 0 0 3px rgba(121,192,255,0.25)'
|
||||
: 'none';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: bg,
|
||||
color: 'white',
|
||||
borderRadius: 6,
|
||||
border: selected ? '2px solid #ffdf5d' : '1px solid rgba(0,0,0,0.4)',
|
||||
boxShadow: selected ? '0 0 0 3px rgba(255,223,93,0.25)' : 'none',
|
||||
border: `${borderWidth}px ${borderStyle} ${borderColor}`,
|
||||
boxShadow: glow,
|
||||
fontSize: 12,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
|
||||
|
|
@ -171,7 +224,7 @@ function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Elemen
|
|||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, opacity: 0.65, marginTop: 2, fontStyle: 'italic' }}>
|
||||
{service.token}
|
||||
{isUnresolved ? 'no implementation registered' : service.token}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, opacity: 0.75, marginTop: 2 }}>{service.domain}</div>
|
||||
</div>
|
||||
|
|
@ -215,7 +268,13 @@ function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Elemen
|
|||
id={`in:${inn}`}
|
||||
type="target"
|
||||
position={Position.Right}
|
||||
style={{ background: '#a8c8f6' }}
|
||||
// Dim handle when the port is only there because it's
|
||||
// declared on the interface — nothing calls into it.
|
||||
// The connected-vs-declared distinction reads at a
|
||||
// glance without hunting for edges.
|
||||
style={{
|
||||
background: ports.connectedIn.has(inn) ? '#a8c8f6' : '#3d444d',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
|
|
@ -247,7 +306,10 @@ function ServiceNodeView({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Elemen
|
|||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: '#c8e0fb',
|
||||
color:
|
||||
inn !== undefined && !ports.connectedIn.has(inn)
|
||||
? '#6e7681'
|
||||
: '#c8e0fb',
|
||||
}}
|
||||
>
|
||||
{inn ?? ''}
|
||||
|
|
@ -289,6 +351,42 @@ function BandLabelView({ data }: NodeProps<Node<{ scope: string; width: number }
|
|||
|
||||
const nodeTypes = { service: ServiceNodeView, band: BandLabelView };
|
||||
|
||||
/**
|
||||
* Persist the pan/zoom viewport across dev-server reloads so a source-code
|
||||
* edit (which triggers a `full-reload` from the `virtual:dep-graph` plugin)
|
||||
* doesn't wipe the position the user carefully panned to. Scoped to
|
||||
* `sessionStorage` so each fresh browser session starts with `fitView`.
|
||||
*/
|
||||
const VIEWPORT_STORAGE_KEY = 'agent-core-v2:dep-graph:viewport';
|
||||
|
||||
function loadViewport(): Viewport | undefined {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(VIEWPORT_STORAGE_KEY);
|
||||
if (raw === null) return undefined;
|
||||
const parsed = JSON.parse(raw) as Partial<Viewport> | null;
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed.x !== 'number' ||
|
||||
typeof parsed.y !== 'number' ||
|
||||
typeof parsed.zoom !== 'number'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { x: parsed.x, y: parsed.y, zoom: parsed.zoom };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveViewport(v: Viewport): void {
|
||||
try {
|
||||
sessionStorage.setItem(VIEWPORT_STORAGE_KEY, JSON.stringify(v));
|
||||
} catch {
|
||||
// Storage disabled (private mode / quota) — silently drop; the graph
|
||||
// still works, it just won't remember the viewport across reloads.
|
||||
}
|
||||
}
|
||||
|
||||
function passesFilter(
|
||||
service: ServiceNode,
|
||||
filters: FilterState,
|
||||
|
|
@ -296,29 +394,40 @@ function passesFilter(
|
|||
): boolean {
|
||||
if (!filters.scopes.has(service.scope)) return false;
|
||||
if (filters.hiddenDomains.has(service.domain)) return false;
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase();
|
||||
const hay = `${service.token} ${service.impl} ${service.domain}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
// NOTE: search intentionally does NOT filter here — it drives the
|
||||
// highlight/dim treatment below so context around a hit stays visible.
|
||||
if (filters.hideOrphans && !connected.has(service.id)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring match across the identity fields and public
|
||||
* surface. Kept close to `passesFilter` so the two search-related pieces
|
||||
* (highlight input, matches predicate) stay obviously in sync.
|
||||
*/
|
||||
function matchesSearch(service: ServiceNode, query: string): boolean {
|
||||
const members = service.publicMembers ? ` ${service.publicMembers.join(' ')}` : '';
|
||||
const hay = `${service.token} ${service.impl} ${service.domain}${members}`.toLowerCase();
|
||||
return hay.includes(query);
|
||||
}
|
||||
|
||||
export function GraphView({
|
||||
graph,
|
||||
filters,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: GraphViewProps): JSX.Element {
|
||||
// Compute once at mount so a re-render that adds nodes doesn't yank the
|
||||
// viewport back to the stored value while the user is panning.
|
||||
const initialViewport = useMemo(() => loadViewport(), []);
|
||||
|
||||
const { nodes, edges, selectedService, selectedEdges } = useMemo(() => {
|
||||
// Which edges survive the edge-kind filter?
|
||||
const survivingEdges: Edge[] = graph.edges
|
||||
.filter((e) => filters.kinds.has(e.kind))
|
||||
// Drop unresolved edges — their `to` points at a pseudo id that isn't
|
||||
// in the node set. The lint reports them separately; showing them
|
||||
// here would just clutter the graph with dangling arrows.
|
||||
.filter((e) => !e.unresolved);
|
||||
// Which edges survive the edge-kind filter? Unresolved edges are kept: the
|
||||
// analyzer now synthesises an interface-only node for each unresolved token
|
||||
// (rendered with a distinct border), so their `to` resolves to a real node
|
||||
// instead of dangling. Edges whose endpoint is filtered out are dropped
|
||||
// below via the `visibleIds` check.
|
||||
const survivingEdges: Edge[] = graph.edges.filter((e) => filters.kinds.has(e.kind));
|
||||
|
||||
// Node ids that appear on either end of any surviving edge — for the
|
||||
// orphan filter.
|
||||
|
|
@ -342,16 +451,35 @@ export function GraphView({
|
|||
// dead weight on the node, so we compute after filter+visibility.
|
||||
const ports = computeServicePorts(visibleServices, finalEdges);
|
||||
|
||||
// Neighbours of the selected node — used to dim non-related ones.
|
||||
const highlighted = new Set<string>();
|
||||
if (selectedId) {
|
||||
highlighted.add(selectedId);
|
||||
for (const e of finalEdges) {
|
||||
if (e.from === selectedId) highlighted.add(e.to);
|
||||
if (e.to === selectedId) highlighted.add(e.from);
|
||||
// Compute the two focus drivers:
|
||||
// • `selectedId` — the click-selected node (0 or 1 at a time).
|
||||
// • `matched` — every node whose identity or public surface hits
|
||||
// the current search string.
|
||||
// Their neighbours (nodes touched by any surviving edge) are folded in
|
||||
// so the graph keeps enough context around a hit to be readable —
|
||||
// this is the "act like a click" behaviour: nothing disappears, just
|
||||
// dims. `focused` is the union used to decide dim vs bright.
|
||||
const searchQuery = filters.search.trim().toLowerCase();
|
||||
const matched = new Set<string>();
|
||||
if (searchQuery) {
|
||||
for (const s of visibleServices) {
|
||||
if (matchesSearch(s, searchQuery)) matched.add(s.id);
|
||||
}
|
||||
}
|
||||
|
||||
const focused = new Set<string>();
|
||||
const seedFocus = (id: string): void => {
|
||||
focused.add(id);
|
||||
for (const e of finalEdges) {
|
||||
if (e.from === id) focused.add(e.to);
|
||||
if (e.to === id) focused.add(e.from);
|
||||
}
|
||||
};
|
||||
if (selectedId !== undefined) seedFocus(selectedId);
|
||||
for (const id of matched) seedFocus(id);
|
||||
|
||||
const focusActive = selectedId !== undefined || matched.size > 0;
|
||||
|
||||
const layout = layoutDagre(visibleServices, finalEdges, {
|
||||
groupByScope: filters.groupByScope,
|
||||
nodeSize: (id) => {
|
||||
|
|
@ -369,8 +497,13 @@ export function GraphView({
|
|||
data: {
|
||||
service,
|
||||
selected: service.id === selectedId,
|
||||
dim: selectedId !== undefined && !highlighted.has(service.id),
|
||||
ports: ports.get(service.id) ?? { inPorts: [], outPorts: [] },
|
||||
matched: matched.has(service.id),
|
||||
dim: focusActive && !focused.has(service.id),
|
||||
ports: ports.get(service.id) ?? {
|
||||
inPorts: [],
|
||||
outPorts: [],
|
||||
connectedIn: new Set<string>(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -396,8 +529,11 @@ export function GraphView({
|
|||
const rfEdges: RFEdge[] = [];
|
||||
for (const e of finalEdges) {
|
||||
const style = EDGE_STYLE[e.kind];
|
||||
const isHighlighted =
|
||||
selectedId !== undefined && (e.from === selectedId || e.to === selectedId);
|
||||
// With a focus (click or search) active, an edge is bright when both
|
||||
// ends are in the focus set — i.e. it either sits directly on a hit
|
||||
// or bridges two things adjacent to a hit. When no focus is active
|
||||
// every edge stays at its default opacity.
|
||||
const isHighlighted = focusActive && focused.has(e.from) && focused.has(e.to);
|
||||
// Group refs by (fromMethod, effectiveToMethod) so identical method
|
||||
// pairs on different lines collapse into a single arrow between the
|
||||
// same two handles instead of stacking.
|
||||
|
|
@ -423,7 +559,7 @@ export function GraphView({
|
|||
stroke: style.color,
|
||||
strokeWidth: isHighlighted ? 2.2 : 1.2,
|
||||
strokeDasharray: style.dashed ? '4 3' : undefined,
|
||||
opacity: selectedId !== undefined ? (isHighlighted ? 1 : 0.1) : 0.75,
|
||||
opacity: focusActive ? (isHighlighted ? 1 : 0.1) : 0.75,
|
||||
},
|
||||
animated: false,
|
||||
});
|
||||
|
|
@ -446,7 +582,14 @@ export function GraphView({
|
|||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
// Only `fitView` on the very first mount of a fresh browser session.
|
||||
// Once a viewport is remembered, hand it to React Flow as
|
||||
// `defaultViewport` so the pan/zoom the user last landed on is
|
||||
// preserved across dev-server reloads.
|
||||
{...(initialViewport
|
||||
? { defaultViewport: initialViewport }
|
||||
: { fitView: true })}
|
||||
onMoveEnd={(_, viewport) => saveViewport(viewport)}
|
||||
minZoom={0.1}
|
||||
maxZoom={1.6}
|
||||
onNodeClick={(_, node) => {
|
||||
|
|
@ -464,7 +607,8 @@ export function GraphView({
|
|||
nodeColor={(n) => {
|
||||
if (n.id.startsWith('band::')) return 'transparent';
|
||||
const service = (n.data as ServiceNodeData | undefined)?.service;
|
||||
return service ? SCOPE_STYLE[service.scope].color : '#7d8590';
|
||||
if (!service) return '#7d8590';
|
||||
return service.unresolved ? '#f85149' : SCOPE_STYLE[service.scope].color;
|
||||
}}
|
||||
/>
|
||||
<Controls showInteractive={false} style={{ background: '#151b23' }} />
|
||||
|
|
@ -512,13 +656,21 @@ function ServicePanel({ service, graph, edges, onClose }: ServicePanelProps): JS
|
|||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{service.impl}</div>
|
||||
<div style={{ color: '#a5b0bc', fontSize: 11 }}>{service.token}</div>
|
||||
{service.unresolved ? (
|
||||
<div style={{ color: '#f85149', fontSize: 11, marginTop: 2 }}>
|
||||
No implementation registered
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#a5b0bc', fontSize: 11 }}>{service.token}</div>
|
||||
)}
|
||||
<div style={{ color: '#7d8590', fontSize: 11 }}>
|
||||
<b>{service.scope}</b> · {service.domain}
|
||||
</div>
|
||||
<div style={{ color: '#7d8590', fontSize: 10, marginTop: 4, wordBreak: 'break-all' }}>
|
||||
{service.file}:{service.line}
|
||||
</div>
|
||||
{!service.unresolved && (
|
||||
<div style={{ color: '#7d8590', fontSize: 10, marginTop: 4, wordBreak: 'break-all' }}>
|
||||
{service.file}:{service.line}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue