feat(ui): add a path and call order view to the scoped galaxy

The call asked for step-by-step tracing, modelled on Graphify's shortest-path
view. In the scoped Galaxy workspace the toolbar now offers:

- "Path to...": a searchable list of the nodes already loaded in the scope.
  The shortest path from the root follows the scope's trace direction
  (outgoing, incoming walked backwards, or both) over the loaded edges only.
- "Call order": the root's outgoing CALLS sorted by call-site line.

The path or call order is highlighted and everything else dimmed; its edges
are drawn over the scene in their type colour with type labels on the path
edges only, and the active step carries a ring. A compact step list shows
"A --CALLS--> B" with the hop (or call-site line), Previous/Next moves the
highlight, and Clear or Escape returns to the normal view. A new root,
direction or edge filter drops the path; an expansion recomputes it. Hops
are the only distance used here, in line with the plan (G6). Nothing is
fetched from the server, and /api/trace is unchanged.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-02 18:59:05 +02:00
parent 0e8b2c17ba
commit 080c79f2b4
10 changed files with 527 additions and 8 deletions
@@ -10,12 +10,14 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import GalaxyPanel from './GalaxyPanel';
import type { GraphData, GraphEdge, GraphNode } from './types';
import type { ScenePath } from './PathLayer';
const scene = vi.hoisted(() => ({ mounts: 0, unmounts: 0, renders: 0, roots: undefined as ReadonlySet<number> | undefined }));
const scene = vi.hoisted(() => ({ mounts: 0, unmounts: 0, renders: 0, roots: undefined as ReadonlySet<number> | undefined,
highlighted: null as Set<number> | null, path: undefined as ScenePath | undefined }));
vi.mock('./GraphScene', async importOriginal => ({
...await importOriginal<typeof import('./GraphScene')>(),
GraphScene: ({ data, rootIds }: { data: GraphData; rootIds?: ReadonlySet<number> }) => {
scene.renders += 1; scene.roots = rootIds;
GraphScene: ({ data, rootIds, highlightedIds, path }: { data: GraphData; rootIds?: ReadonlySet<number>; highlightedIds: Set<number> | null; path?: ScenePath }) => {
scene.renders += 1; scene.roots = rootIds; scene.highlighted = highlightedIds; scene.path = path;
useEffect(() => { scene.mounts += 1; return () => { scene.unmounts += 1; }; }, []);
return <output data-testid="scene-nodes">{data.nodes.length}</output>;
},
@@ -27,6 +29,7 @@ const nodes = [1, 2, 3, 4, 5].map(node);
const edges: GraphEdge[] = [
{ source: 1, target: 2, type: 'CALLS', line: 3 }, { source: 3, target: 1, type: 'CALLS', line: 4 },
{ source: 2, target: 4, type: 'CALLS', line: 2 }, { source: 4, target: 5, type: 'IMPORTS' },
{ source: 1, target: 5, type: 'CALLS', line: 1 },
];
/** /api/layout plus the two query_graph shapes the scope loader sends. */
@@ -77,8 +80,8 @@ it('keeps one scene mounted through selection and expansion and fits once per se
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => {
expect(seam().nodes).toBe(3);
expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe('3 nodes · 2 edges');
expect(seam().nodes).toBe(4);
expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe('4 nodes · 3 edges');
});
expect(scene.mounts).toBe(1); expect(scene.unmounts).toBe(0);
expect(seam().fits).toBe(fitsBefore + 1);
@@ -88,7 +91,7 @@ it('keeps one scene mounted through selection and expansion and fits once per se
const expand = [...host.querySelectorAll('button')].find(button => button.textContent === 'Expand +1')!;
await act(async () => expand.click());
await settle(() => expect(seam().nodes).toBe(4));
await settle(() => expect(seam().nodes).toBe(5));
expect(scene.mounts).toBe(1); expect(scene.unmounts).toBe(0);
// An expansion is the same scope: no new fit, the scene keeps the reader's camera.
expect(seam().fits).toBe(fitsBefore + 1);
@@ -98,3 +101,55 @@ it('keeps one scene mounted through selection and expansion and fits once per se
expect(scene.mounts).toBe(1);
expect(seam().fits).toBe(fitsBefore + 2);
});
const button = (label: string) => [...host.querySelectorAll('button')].find(entry => entry.textContent === label)!;
const steps = () => [...host.querySelectorAll('[data-testid="atlas-galaxy-path-panel"] li code')].map(entry => entry.textContent);
const heading = () => host.querySelector('[data-testid="atlas-galaxy-path-panel"] strong')?.textContent;
it('highlights a path and the call order of the root inside the loaded scope, and clears with Escape or a new trace', async () => {
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={graphFetch()} />));
await settle(() => expect(seam().nodes).toBe(5));
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => expect(seam().nodes).toBe(4));
await act(async () => button('Expand +1').click());
await settle(() => expect(seam().nodes).toBe(5));
await act(async () => button('Call order').click());
expect(heading()).toBe('Calls of n1 · 2 calls');
expect(steps()).toEqual(['n1 --CALLS--> n5', 'n1 --CALLS--> n2']);
expect([...scene.highlighted ?? []].sort()).toEqual([1, 2, 5]);
expect(scene.path).toMatchObject({ active: 0, labels: 'active' });
await act(async () => button('Next').click());
expect(scene.path?.active).toBe(1);
host.querySelector<HTMLDetailsElement>('.atlas-graph-path-picker')!.open = true;
const target = [...host.querySelectorAll<HTMLButtonElement>('.atlas-graph-path-menu li button')]
.find(entry => entry.querySelector('strong')?.textContent === 'n4')!;
await act(async () => target.click());
expect(heading()).toBe('Path to n4 · 2 hops');
expect(steps()).toEqual(['n1 --CALLS--> n2', 'n2 --CALLS--> n4']);
expect([...scene.highlighted ?? []].sort()).toEqual([1, 2, 4]);
expect(scene.path).toMatchObject({ active: 0, labels: 'all' });
expect(seam().nodes).toBe(5);
await act(async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
expect(host.querySelector('[data-testid="atlas-galaxy-path-panel"]')).toBeNull();
expect(scene.path).toBeUndefined();
// Incoming: the path walks the CALLS edge backwards and says so.
const direction = (value: string) => act(async () => {
const select = host.querySelector<HTMLSelectElement>('select[aria-label="Trace direction"]')!;
select.value = value; select.dispatchEvent(new Event('change', { bubbles: true }));
});
await direction('inbound');
await settle(() => expect(seam().nodes).toBe(2));
host.querySelector<HTMLDetailsElement>('.atlas-graph-path-picker')!.open = true;
const caller = [...host.querySelectorAll<HTMLButtonElement>('.atlas-graph-path-menu li button')]
.find(entry => entry.querySelector('strong')?.textContent === 'n3')!;
await act(async () => caller.click());
expect(heading()).toBe('Path to n3 · 1 hop');
expect(steps()).toEqual(['n1 <--CALLS-- n3']);
// A new trace direction is a new scope: the path view returns to the normal picture.
await direction('outbound');
expect(host.querySelector('[data-testid="atlas-galaxy-path-panel"]')).toBeNull();
});
+61 -2
View File
@@ -137,6 +137,9 @@ import type { LabelBox } from './NodeLabels';
import { NodeTooltipCard } from './NodeTooltipCard';
import GalaxyNavigator from './GalaxyNavigator';
import { TraceEdgeFilter } from './TraceEdgeFilter';
import { PathPicker, PathSteps } from './ScopePathControls';
import { callOrder, pathNodes, shortestScopePath, type ScopePathStep } from './scope-path';
import { galaxyPathText } from './galaxy-strings';
import { useOrganicLayout } from './use-organic-layout';
import RenderProgress from './RenderProgress';
import { useGraphScope } from './use-graph-scope';
@@ -1185,6 +1188,52 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
});
}, [props.workspaceExpanded, scope.scope, traceTypes, kinds, changeTraceTypes]);
/*
* Der Pfad und die Aufrufreihe (Review-Befund G5, nach dem Vorbild von
* Graphify).
*
* Beide rechnen nur auf dem, was dieser Scope schon geladen hat, und in der
* Richtung, in der er verfolgt wird: der Server wird nicht gefragt, und ein
* fehlender Pfad heisst "nicht in diesen Beziehungen", nicht "gibt es
* nicht". Der Abstand ist hier die Zahl der Hops und nur hier (G6); die
* Wolke selbst behauptet keinen. Die Wahl haengt am Scope (`organicKey`):
* eine neue Wurzel, Richtung oder Kantenart verwirft sie, ein Expand
* rechnet sie auf dem groesseren Bild neu.
*/
const [trail, setTrail] = useState<{ key: string; kind: 'path'; target: number } | { key: string; kind: 'calls' }>();
const [trailStep, setTrailStep] = useState(0);
const trailRoot = scope.result?.roots.size === 1 ? [...scope.result.roots][0] : undefined;
const rootCalls = useMemo(() => (data && trailRoot !== undefined ? callOrder(data.edges, trailRoot) : []), [data, trailRoot]);
const pathCandidates = useMemo(() => data?.nodes.filter(node => !scope.result?.roots.has(node.id)) ?? [], [data, scope.result]);
const trailView = useMemo(() => {
if (!props.workspaceExpanded || mode !== 'galaxy' || trail?.key !== organicKey || !data || !scope.result) return undefined;
const names = new Map(data.nodes.map(node => [node.id, node.name]));
const nameOf = (id: number) => names.get(id) ?? `#${id}`;
if (trail.kind === 'calls') {
return { nameOf, lines: true, labels: 'active' as const, steps: rootCalls,
heading: galaxyPathText.callsHeading(trailRoot === undefined ? '' : nameOf(trailRoot), rootCalls.length) };
}
const steps: ScopePathStep[] | undefined = shortestScopePath(data.edges, scope.result.roots, trail.target, scope.direction);
const target = nameOf(trail.target);
return { nameOf, lines: false, labels: 'all' as const, steps: steps ?? [],
heading: galaxyPathText.pathHeading(target, steps?.length ?? 0),
note: steps === undefined ? galaxyPathText.noPath(target) : steps.length === 0 ? galaxyPathText.isRoot(target) : undefined };
}, [props.workspaceExpanded, mode, trail, organicKey, data, scope.result, scope.direction, rootCalls, trailRoot]);
const trailActive = trailView ? Math.min(trailStep, Math.max(0, trailView.steps.length - 1)) : 0;
const trailIds = useMemo(() => trailView?.steps.length && scope.result ? pathNodes(trailView.steps, scope.result.roots) : undefined,
[trailView, scope.result]);
const clearTrail = useCallback(() => { setTrail(undefined); setTrailStep(0); }, []);
useEffect(() => {
if (!trailView) return;
const onKey = (event: globalThis.KeyboardEvent): void => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
event.preventDefault();
clearTrail();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [trailView, clearTrail]);
const index = useMemo(
() => (picture === undefined ? new Map<string, GraphNode>() : nodesByQualifiedName(picture.nodes)),
[picture],
@@ -2269,6 +2318,13 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
<span>{scope.depth} {scope.depth === 1 ? 'layer' : 'layers'}</span>
<button type="button" disabled={scope.loading || scope.result?.exhausted}
onClick={() => scope.setDepth(scope.depth + 1)}>Expand +1</button>
{props.workspaceExpanded && mode === 'galaxy' && <>
<PathPicker nodes={pathCandidates} onPick={node => { setTrail({ key: organicKey, kind: 'path', target: node.id }); setTrailStep(0); }} />
<button type="button" disabled={rootCalls.length === 0} aria-pressed={trail?.key === organicKey && trail.kind === 'calls'}
title={rootCalls.length ? galaxyPathText.callOrderTitle : galaxyPathText.callOrderUnavailable}
onClick={() => { if (trail?.key === organicKey && trail.kind === 'calls') clearTrail(); else { setTrail({ key: organicKey, kind: 'calls' }); setTrailStep(0); } }}>
{galaxyPathText.callOrder}</button>
</>}
<span className="atlas-graph-scope-count" role="status">{scope.validating ? 'Checking index…' : scope.loading ? 'Loading relationships…' : organicTask.loading ? 'Arranging nodes…'
: scope.complete ? `${data?.nodes.length ?? 0} ${data?.nodes.length === 1 ? 'node' : 'nodes'} · ${data?.edges.length ?? 0} ${data?.edges.length === 1 ? 'edge' : 'edges'}${scope.result?.exhausted ? ' · end of trace' : ''}`
: 'Partial preview'}</span>
@@ -2407,6 +2463,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
<div className="atlas-galaxy-scene" data-testid="atlas-galaxy-scene" ref={scene}
aria-busy={layoutLoading || scope.loading || organicTask.loading || spacingBusy}>
<RenderProgress busy={visible && (layoutLoading || scope.loading || organicTask.loading || spacingBusy)} />
{trailView && <PathSteps heading={trailView.heading} note={trailView.note} steps={trailView.steps} active={trailActive}
nameOf={trailView.nameOf} lines={trailView.lines} onStep={setTrailStep} onClear={clearTrail} />}
{/*
* Der Weg zurueck zur eingepassten Ansicht (AC5).
*
@@ -2440,7 +2498,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
rootIds={mode === 'galaxy' && sceneScoped ? scope.result?.roots : undefined}
data={shown}
display={display}
highlightedIds={scope.scope && scope.depth > 1 ? null : highlighted}
highlightedIds={trailIds ?? (scope.scope && scope.depth > 1 ? null : highlighted)}
path={trailIds && trailView ? { steps: trailView.steps, active: trailActive, labels: trailView.labels } : undefined}
emphasizeIncidentEdges={mode === 'galaxy' && Boolean(props.focusFilePath)}
cameraTarget={cameraTarget}
/*
@@ -2477,7 +2536,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
showLabels={
mode === 'hierarchy'
? shown.nodes.length <= HIERARCHY_LABEL_BUDGET
: highlighted !== null && highlighted.size > 0
: trailIds !== undefined || (highlighted !== null && highlighted.size > 0)
}
/*
* Landmarken nur in der Galaxie: der Halo sitzt auf den
+8
View File
@@ -96,6 +96,9 @@
* Scopes bekommen einen Ring und einen Namen, der immer zu sehen ist, und
* die Trennung auf dem Schirm schiebt sie nie weg. Ohne die Prop zeichnet
* die Szene wie vorher.
* 18. Neu (Review-Call 2026-10-02): die Prop `path`. Ein Pfad oder eine
* Aufrufreihe liegt mit beschrifteten Kanten ueber der Szene
* (src/galaxy/PathLayer.tsx). Ohne die Prop zeichnet die Szene wie vorher.
*/
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
@@ -110,6 +113,7 @@ import { HaloLayer } from './HaloLayer';
import { EdgeLines } from './EdgeLines';
import { NodeLabels } from './NodeLabels';
import { ScreenNodeSeparation } from './ScreenNodeSeparation';
import { PathLayer, type ScenePath } from './PathLayer';
import type { LabelBox } from './NodeLabels';
import { FRAME_MIN_DISTANCE, containShift, fitCamera, flatBounds, frameDistance, orthographicZoom } from './camera-frame';
import type { CameraFit, FrameBox } from './camera-frame';
@@ -813,6 +817,8 @@ interface GraphSceneProps {
frameCap?: number;
/* Die Wurzeln des gewaehlten Scopes (Aenderung 17). */
rootIds?: ReadonlySet<number>;
/* Ein Pfad oder eine Aufrufreihe ueber der Szene (Aenderung 18). */
path?: ScenePath;
}
export type { CameraTarget };
@@ -852,6 +858,7 @@ export function GraphScene({
labelDistanceFactor = 0,
frameCap = 0,
rootIds,
path,
}: GraphSceneProps) {
const [hovered, setHovered] = useState<GraphNode | null>(null);
const [hoveredShadow, setHoveredShadow] = useState<CoverageShadowNode | null>(null);
@@ -1016,6 +1023,7 @@ export function GraphScene({
)}
{landmarks && <HaloLayer nodes={renderedCode} />}
{rootNodes.length > 0 && <RootMarkers nodes={rootNodes} />}
{path !== undefined && <PathLayer nodes={renderedCode} path={path} />}
{renderedShadow && renderedShadow.nodes.length > 0 && <group>
{drawEdges && <CoverageShadowEdges shadow={renderedShadow} brightness={display.edgeBrightness} />}
+68
View File
@@ -0,0 +1,68 @@
/*
* Der Pfad in der Szene (Review-Befund G5).
*
* Die Kanten des Pfades liegen als eigene Linien UEBER der Kantenebene, in der
* Farbe ihrer Art und ohne Tiefentest, damit sie im abgedunkelten Rest nicht
* verschwinden. Ihre Art steht als Beschriftung daneben, und nur an ihnen:
* eine Beschriftung an jeder Kante waere bei hundert Kanten kein Text mehr.
* Der Schritt, auf dem der Leser steht, ist heller und traegt einen Ring an
* seinem Ziel. Gezeichnet wird an den Positionen, die die Szene wirklich
* zeichnet, also nach der Trennung auf dem Schirm.
*/
import { useEffect, useMemo } from 'react';
import { Html } from '@react-three/drei';
import * as THREE from 'three';
import { edgeColor } from '../graph/edge-style';
import type { ScopePathStep } from './scope-path';
import type { GraphNode } from './types';
export interface ScenePath {
steps: readonly ScopePathStep[];
active: number;
/** `all` beschriftet jede Kante des Pfades, `active` nur den aktuellen Schritt. */
labels: 'all' | 'active';
}
export function PathLayer({ nodes, path }: { nodes: readonly GraphNode[]; path: ScenePath }) {
const byId = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const segments = useMemo(() => path.steps.flatMap((step, index) => {
const from = byId.get(step.from), to = byId.get(step.to);
return from && to ? [{ step, index, from, to }] : [];
}), [path.steps, byId]);
const geometry = useMemo(() => {
const positions = new Float32Array(segments.length * 6), colors = new Float32Array(segments.length * 6);
const white = new THREE.Color('#ffffff');
segments.forEach(({ step, index, from, to }, at) => {
positions.set([from.x, from.y, from.z, to.x, to.y, to.z], at * 6);
const color = new THREE.Color(edgeColor(step.edge.type));
if (index === path.active) color.lerp(white, 0.55);
colors.set([color.r, color.g, color.b, color.r, color.g, color.b], at * 6);
});
const result = new THREE.BufferGeometry();
result.setAttribute('position', new THREE.BufferAttribute(positions, 3));
result.setAttribute('color', new THREE.BufferAttribute(colors, 3));
return result;
}, [segments, path.active]);
useEffect(() => () => geometry.dispose(), [geometry]);
const goal = segments.find((segment) => segment.index === path.active);
return (
<>
<lineSegments geometry={geometry} renderOrder={18}>
<lineBasicMaterial vertexColors transparent depthTest={false} depthWrite={false} toneMapped={false} />
</lineSegments>
{segments.filter(({ index }) => path.labels === 'all' || index === path.active).map(({ step, index, from, to }) => (
<Html key={index} position={[(from.x + to.x) / 2, (from.y + to.y) / 2, (from.z + to.z) / 2]} center
zIndexRange={[9, 0]} style={{ pointerEvents: 'none' }}>
<span className="atlas-galaxy-path-label" data-active={index === path.active}>{step.edge.type}</span>
</Html>
))}
{goal && (
<Html position={[goal.to.x, goal.to.y, goal.to.z]} zIndexRange={[10, 0]} style={{ pointerEvents: 'none' }}>
<span className="atlas-galaxy-path-step" data-testid="atlas-galaxy-path-step" data-node={goal.to.id}>
<i aria-hidden="true" />
</span>
</Html>
)}
</>
);
}
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { PathPicker, PathSteps } from './ScopePathControls';
import type { GraphNode } from './types';
let host: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
const node = (id: number, name: string, file = `src/${name}.py`): GraphNode => ({ id, name, file_path: file, qualified_name: `p.${name}`,
label: 'Function', x: 0, y: 0, z: 0, size: 2, color: '#999999' });
const type = async (input: HTMLInputElement, value: string) => act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
it('searches only the loaded scope, sorted by name, and closes after a pick', async () => {
const onPick = vi.fn();
await act(async () => root.render(<PathPicker nodes={[node(3, 'save'), node(1, 'as_sql'), node(2, 'resolve', 'other/save_helpers.py')]} onPick={onPick} />));
const details = host.querySelector('details')!;
details.open = true;
const names = () => [...host.querySelectorAll('li strong')].map(entry => entry.textContent);
expect(names()).toEqual(['as_sql', 'resolve', 'save']);
await type(host.querySelector<HTMLInputElement>('input[aria-label="Find a path target"]')!, 'save');
expect(names()).toEqual(['resolve', 'save']);
await type(host.querySelector<HTMLInputElement>('input')!, 'nothing');
expect(host.textContent).toContain('No matching node in the loaded scope.');
await type(host.querySelector<HTMLInputElement>('input')!, 'sql');
await act(async () => host.querySelector<HTMLButtonElement>('li button')!.click());
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
expect(details.open).toBe(false);
});
it('lists every hop with its indexed direction and steps a highlight through them', async () => {
const onStep = vi.fn(), onClear = vi.fn();
const names = new Map([[1, 'JSONBAgg'], [2, 'test_jsonb_agg'], [3, 'Aggregate']]);
const steps = [{ edge: { id: 7, source: 2, target: 1, type: 'CALLS' }, from: 1, to: 2 },
{ edge: { id: 8, source: 1, target: 3, type: 'INHERITS' }, from: 1, to: 3 }];
const render = (active: number) => act(async () => root.render(<PathSteps heading="Path" steps={steps} active={active}
nameOf={id => names.get(id)!} lines={false} onStep={onStep} onClear={onClear} />));
await render(0);
expect([...host.querySelectorAll('li code')].map(entry => entry.textContent))
.toEqual(['JSONBAgg <--CALLS-- test_jsonb_agg', 'JSONBAgg --INHERITS--> Aggregate']);
expect([...host.querySelectorAll('.atlas-galaxy-path-hop')].map(entry => entry.textContent)).toEqual(['hop 1', 'hop 2']);
expect(host.querySelector('[aria-current="step"]')?.textContent).toContain('test_jsonb_agg');
const button = (label: string) => [...host.querySelectorAll('button')].find(entry => entry.textContent === label)!;
expect(button('Previous').disabled).toBe(true);
await act(async () => button('Next').click());
expect(onStep).toHaveBeenLastCalledWith(1);
await render(1);
expect(button('Next').disabled).toBe(true);
expect(host.textContent).toContain('2 of 2');
await act(async () => button('Clear').click());
expect(onClear).toHaveBeenCalledOnce();
});
it('shows call-site lines for a call order and a note without steps', async () => {
const steps = [{ edge: { id: 1, source: 1, target: 2, type: 'CALLS', line: 14 }, from: 1, to: 2 },
{ edge: { id: 2, source: 1, target: 3, type: 'CALLS' }, from: 1, to: 3 }];
await act(async () => root.render(<PathSteps heading="Calls" steps={steps} active={0} nameOf={String} lines onStep={vi.fn()} onClear={vi.fn()} />));
expect([...host.querySelectorAll('.atlas-galaxy-path-hop')].map(entry => entry.textContent)).toEqual(['line 14', 'no line']);
await act(async () => root.render(<PathSteps heading="Path" note="No path" steps={[]} active={0} nameOf={String} lines={false} onStep={vi.fn()} onClear={vi.fn()} />));
expect(host.querySelector('ol')).toBeNull();
expect(host.textContent).toContain('No path');
});
+77
View File
@@ -0,0 +1,77 @@
import { useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { galaxyPathText as text } from './galaxy-strings';
import type { ScopePathStep } from './scope-path';
import type { GraphNode } from './types';
const PICKER_LIMIT = 40;
function closeOnEscape(event: KeyboardEvent<HTMLDetailsElement>) {
if (event.key !== 'Escape') return;
event.stopPropagation(); event.currentTarget.open = false;
event.currentTarget.querySelector('summary')?.focus();
}
/** A searchable list of the nodes already loaded in this scope; nothing is fetched. */
export function PathPicker({ nodes, onPick }: { nodes: readonly GraphNode[]; onPick: (node: GraphNode) => void }) {
const [query, setQuery] = useState('');
const details = useRef<HTMLDetailsElement>(null);
const matches = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return nodes.filter(node => !needle || [node.name, node.qualified_name, node.file_path]
.some(value => value?.toLocaleLowerCase().includes(needle)))
.sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id);
}, [nodes, query]);
return <details ref={details} className="atlas-graph-path-picker" onKeyDown={closeOnEscape}>
<summary title={text.pathToTitle}>{text.pathTo}</summary>
<div className="atlas-graph-path-menu" role="group" aria-label={text.pathToTitle}>
<input type="search" aria-label={text.pathSearch} placeholder={text.pathSearchPlaceholder}
value={query} onChange={event => setQuery(event.target.value)} />
<ul>{matches.slice(0, PICKER_LIMIT).map(node => <li key={node.id}>
<button type="button" onClick={() => {
onPick(node);
if (details.current) details.current.open = false;
}}><strong>{node.name}</strong><span>{node.file_path ?? node.qualified_name ?? node.label}</span></button>
</li>)}</ul>
{matches.length === 0 && <small>{text.pathNoMatch}</small>}
{matches.length > PICKER_LIMIT && <small>{text.pathMore(matches.length - PICKER_LIMIT)}</small>}
</div>
</details>;
}
/** The compact step list: one line per hop or call site, stepping moves the highlight. */
export function PathSteps({ heading, note, steps, active, nameOf, lines, onStep, onClear }: {
heading: string;
note?: string;
steps: readonly ScopePathStep[];
active: number;
nameOf: (id: number) => string;
/** Call order shows the call-site line; a path shows the hop. */
lines: boolean;
onStep: (index: number) => void;
onClear: () => void;
}) {
return <section className="atlas-galaxy-path-panel" aria-label={text.panel} data-testid="atlas-galaxy-path-panel">
<header>
<strong>{heading}</strong>
<button type="button" title={text.clearTitle} onClick={onClear}>{text.clear}</button>
</header>
{note && <p>{note}</p>}
{steps.length > 0 && <>
<ol>{steps.map((step, index) => <li key={`${index}:${step.edge.id ?? ''}`}>
<button type="button" data-active={index === active} aria-current={index === active ? 'step' : undefined}
onClick={() => onStep(index)}>
<span className="atlas-galaxy-path-hop">{lines
? step.edge.line === undefined ? text.lineUnknown : text.line(step.edge.line)
: text.hop(index + 1)}</span>
<code>{text.step(nameOf(step.from), step.edge.type, nameOf(step.to), step.edge.source === step.from)}</code>
</button>
</li>)}</ol>
<footer>
<button type="button" disabled={active <= 0} onClick={() => onStep(active - 1)}>{text.previous}</button>
<span>{text.position(active + 1, steps.length)}</span>
<button type="button" disabled={active >= steps.length - 1} onClick={() => onStep(active + 1)}>{text.next}</button>
</footer>
</>}
</section>;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* The words of the Galaxy path view, beside the view like the other domain
* copy (agents/agent-strings.ts, architecture/strings.ts).
*
* One rule for every sentence here: a path is counted in hops over the
* relationships that are loaded in this scope, nothing more. It is not a
* runtime trace and it does not claim that nothing else connects the two.
*/
export const galaxyPathText = {
pathTo: 'Path to…',
pathToTitle: 'Highlight the shortest path from the root to a node in this scope',
pathSearch: 'Find a path target',
pathSearchPlaceholder: 'Node in this scope…',
pathNoMatch: 'No matching node in the loaded scope.',
pathMore: (count: number) => `${count.toLocaleString()} more, type to narrow`,
callOrder: 'Call order',
callOrderTitle: 'Step through the outgoing calls of the root in source line order',
callOrderUnavailable: 'The root has no outgoing calls in the loaded scope',
panel: 'Path steps',
pathHeading: (target: string, hops: number) => `Path to ${target} · ${hops === 1 ? '1 hop' : `${hops} hops`}`,
callsHeading: (root: string, calls: number) => `Calls of ${root} · ${calls === 1 ? '1 call' : `${calls} calls`}`,
noPath: (target: string) => `No path to ${target} over the loaded relationships in this trace direction. Expand the scope or trace both directions.`,
isRoot: (target: string) => `${target} is the root of this scope.`,
hop: (hop: number) => `hop ${hop}`,
line: (line: number) => `line ${line}`,
lineUnknown: 'no line',
/** The hop as the reader follows it, with the indexed direction kept. */
step: (from: string, type: string, to: string, forward: boolean) =>
(forward ? `${from} --${type}--> ${to}` : `${from} <--${type}-- ${to}`),
previous: 'Previous',
next: 'Next',
clear: 'Clear',
clearTitle: 'Return to the whole scope (Esc)',
position: (index: number, total: number) => `${index} of ${total}`,
};
+25
View File
@@ -44,6 +44,31 @@
@keyframes atlas-graph-render-spin { to { transform:rotate(360deg); } }
@media (prefers-reduced-motion:reduce) { .atlas-graph-render-spinner { animation:none; } }
/* Der Pfad: Auswahl in der Werkzeugleiste, Schrittliste in der Szene, Beschriftung und Ring an den Kanten. */
.atlas-graph-path-picker { position:relative; }
.atlas-graph-path-picker > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; white-space:nowrap; }
.atlas-graph-path-menu { position:absolute; top:calc(100% + .35rem); left:0; z-index:30; width:min(22rem, calc(100vw - 3rem)); padding:.55rem; border:1px solid #355246; border-radius:8px; background:rgba(13,25,19,.98); box-shadow:0 8px 24px #0005; }
.atlas-graph-path-menu input { box-sizing:border-box; width:100%; padding:.35rem .5rem; border:1px solid #29473a; border-radius:5px; background:rgba(7,17,12,.65); color:var(--atlas-fg,#cbdbd0); font:inherit; }
.atlas-graph-path-menu ul { max-height:18rem; overflow:auto; list-style:none; padding:0; margin:.45rem 0 0; }
.atlas-graph-path-menu button { width:100%; display:flex; gap:.5rem; align-items:baseline; text-align:left; border:0; background:transparent; }
.atlas-graph-path-menu button span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.72rem; color:#95a89e; }
.atlas-graph-path-menu small { display:block; color:#95a89e; margin-top:.4rem; }
.atlas-graph-exploration button[aria-pressed=true] { border-color:#80ac96; background:rgba(68, 112, 86, .3); }
.atlas-galaxy-path-panel { position:absolute; z-index:25; top:12px; left:12px; display:flex; flex-direction:column; gap:.35rem; width:min(26rem, calc(100% - 24px)); max-height:min(22rem, 55%); box-sizing:border-box; padding:.55rem .65rem; border:1px solid #355246; border-radius:8px; background:rgba(13, 25, 19, .94); color:#cbdbd0; font-size:.78rem; }
.atlas-galaxy-path-panel header, .atlas-galaxy-path-panel footer { display:flex; align-items:center; justify-content:space-between; gap:.5rem; }
.atlas-galaxy-path-panel p { margin:0; color:#c6aa82; }
.atlas-galaxy-path-panel ol { flex:1 1 auto; min-height:0; overflow:auto; margin:0; padding:0; list-style:none; }
.atlas-galaxy-path-panel li button { width:100%; display:flex; gap:.5rem; align-items:baseline; text-align:left; padding:.2rem .35rem; border:1px solid transparent; border-radius:5px; background:transparent; color:inherit; font:inherit; cursor:pointer; }
.atlas-galaxy-path-panel li button[data-active=true] { border-color:#80ac96; background:rgba(68, 112, 86, .28); }
.atlas-galaxy-path-panel code { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font:11px var(--atlas-mono, ui-monospace, monospace); }
.atlas-galaxy-path-hop { flex:none; min-width:3.6rem; color:#91b6a3; font-variant-numeric:tabular-nums; }
.atlas-galaxy-path-panel header button, .atlas-galaxy-path-panel footer button { padding:.15rem .5rem; border:1px solid #29473a; border-radius:5px; background:rgba(17, 34, 26, .72); color:inherit; font:inherit; cursor:pointer; }
.atlas-galaxy-path-panel footer button:disabled { opacity:.4; cursor:not-allowed; }
.atlas-galaxy-path-label { display:block; padding:0 4px; border-radius:3px; background:rgba(13, 15, 18, .78); color:#cfe0d6; font:600 10px/1.4 var(--atlas-mono, ui-monospace, monospace); white-space:nowrap; }
.atlas-galaxy-path-label[data-active=true] { color:#ffffff; outline:1px solid #80ac96; }
.atlas-galaxy-path-step { position:relative; display:block; width:0; height:0; pointer-events:none; }
.atlas-galaxy-path-step > i { position:absolute; left:-12px; top:-12px; width:24px; height:24px; box-sizing:border-box; border:2px solid #9fe0bd; border-radius:50%; box-shadow:0 0 12px rgba(159, 224, 189, .55); }
/* Die Wurzel eines Scopes: ein Ring auf dem Punkt und ihr Name darunter, immer sichtbar. */
.atlas-galaxy-root-marker { position:relative; display:block; width:0; height:0; pointer-events:none; }
.atlas-galaxy-root-marker > i { position:absolute; left:-15px; top:-15px; width:30px; height:30px; box-sizing:border-box; border:2px solid #f0cf73; border-radius:50%; box-shadow:0 0 0 4px rgba(240, 207, 115, .16), 0 0 16px rgba(240, 207, 115, .5); }
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { callOrder, pathNodes, shortestScopePath } from './scope-path';
import type { GraphEdge } from './types';
const edge = (id: number, source: number, target: number, type = 'CALLS', line?: number): GraphEdge => ({ id, source, target, type, line });
const hops = (steps: ReturnType<typeof shortestScopePath>) => steps?.map(step => `${step.from}>${step.to}:${step.edge.id}`);
describe('shortest path inside the loaded scope', () => {
// 1 -> 2 -> 3 -> 4, a shortcut 1 -> 4 typed IMPORTS, and a caller 5 -> 1.
const edges = [edge(10, 1, 2), edge(11, 2, 3), edge(12, 3, 4), edge(13, 1, 4, 'IMPORTS'), edge(14, 5, 1), edge(15, 6, 6)];
const roots = new Set([1]);
it('follows outgoing relationships and takes the fewest hops', () => {
expect(hops(shortestScopePath(edges, roots, 4, 'outbound'))).toEqual(['1>4:13']);
expect(hops(shortestScopePath(edges, roots, 3, 'outbound'))).toEqual(['1>2:10', '2>3:11']);
expect(shortestScopePath(edges, roots, 5, 'outbound')).toBeUndefined();
});
it('walks edges backwards for incoming traces and keeps their indexed direction', () => {
const steps = shortestScopePath(edges, roots, 5, 'inbound')!;
expect(hops(steps)).toEqual(['1>5:14']);
expect(steps[0]!.edge.source).toBe(5);
expect(shortestScopePath(edges, roots, 2, 'inbound')).toBeUndefined();
});
it('ignores direction for both and returns the empty path for a root', () => {
expect(hops(shortestScopePath(edges, roots, 5, 'both'))).toEqual(['1>5:14']);
expect(hops(shortestScopePath(edges, roots, 3, 'both'))).toHaveLength(2);
expect(shortestScopePath(edges, roots, 1, 'both')).toEqual([]);
expect(shortestScopePath(edges, roots, 6, 'both')).toBeUndefined();
});
it('is deterministic under edge order and starts from the nearest of several roots', () => {
const diamond = [edge(1, 1, 2), edge(2, 1, 3), edge(3, 2, 4), edge(4, 3, 4)];
const forward = hops(shortestScopePath(diamond, roots, 4, 'outbound'));
expect(hops(shortestScopePath([...diamond].reverse(), roots, 4, 'outbound'))).toEqual(forward);
expect(forward).toEqual(['1>2:1', '2>4:3']);
expect(hops(shortestScopePath(diamond, new Set([1, 3]), 4, 'outbound'))).toEqual(['3>4:4']);
});
it('collects every node a path touches', () => {
const steps = shortestScopePath(edges, roots, 3, 'outbound')!;
expect([...pathNodes(steps, roots)].sort()).toEqual([1, 2, 3]);
expect([...pathNodes([], roots)]).toEqual([1]);
});
});
describe('call order of the root', () => {
it('lists outgoing CALLS by call-site line, unknown lines last, every call site once', () => {
const edges = [edge(1, 1, 4, 'CALLS', 30), edge(2, 1, 2, 'CALLS', 12), edge(3, 1, 3, 'IMPORTS', 1),
edge(4, 5, 1, 'CALLS', 2), edge(5, 1, 3, 'CALLS'), edge(6, 1, 2, 'CALLS', 40), edge(7, 1, 1, 'CALLS', 5)];
expect(callOrder(edges, 1).map(step => `${step.to}@${step.edge.line ?? '-'}`)).toEqual(['2@12', '4@30', '2@40', '3@-']);
expect(callOrder(edges, 1).every(step => step.from === 1 && step.edge.source === 1)).toBe(true);
expect(callOrder(edges, 9)).toEqual([]);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { TraceDirection } from './graph-scope';
import type { GraphEdge } from './types';
/** One hop of a path, in path order: `from` is one hop closer to the root.
* The edge keeps its indexed direction, so `edge.source === from` tells
* whether the hop follows the relationship or walks it backwards. */
export interface ScopePathStep { edge: GraphEdge; from: number; to: number }
/** The traversal key for deterministic ties: neighbor first, then edge identity. */
const order = (a: { to: number; edge: GraphEdge; index: number }, b: { to: number; edge: GraphEdge; index: number }) =>
a.to - b.to || (a.edge.id ?? Number.MAX_SAFE_INTEGER) - (b.edge.id ?? Number.MAX_SAFE_INTEGER)
|| a.edge.type.localeCompare(b.edge.type) || a.index - b.index;
/** Shortest path from any root to `target` over the loaded scope edges only.
* `outbound` follows edges, `inbound` walks them backwards (who reaches the
* root), `both` ignores direction. Undefined means no path exists inside the
* loaded scope; a root target has the empty path. Hops are counted, nothing
* else: there is no runtime or architectural distance in this number. */
export function shortestScopePath(edges: readonly GraphEdge[], roots: ReadonlySet<number>, target: number,
direction: TraceDirection): ScopePathStep[] | undefined {
if (roots.has(target)) return [];
const next = new Map<number, { to: number; edge: GraphEdge; index: number }[]>();
const link = (from: number, to: number, edge: GraphEdge, index: number) => {
const list = next.get(from) ?? []; list.push({ to, edge, index }); next.set(from, list);
};
edges.forEach((edge, index) => {
if (edge.source === edge.target) return;
if (direction !== 'inbound') link(edge.source, edge.target, edge, index);
if (direction !== 'outbound') link(edge.target, edge.source, edge, index);
});
next.forEach(list => list.sort(order));
const reached = new Map<number, { from: number; edge: GraphEdge } | undefined>([...roots].sort((a, b) => a - b).map(root => [root, undefined]));
const queue = [...reached.keys()];
for (let at = 0; at < queue.length && !reached.has(target); at++) {
const from = queue[at]!;
for (const hop of next.get(from) ?? []) {
if (reached.has(hop.to)) continue;
reached.set(hop.to, { from, edge: hop.edge });
queue.push(hop.to);
}
}
if (!reached.has(target)) return undefined;
const steps: ScopePathStep[] = [];
let node = target, parent = reached.get(node);
while (parent) {
steps.push({ edge: parent.edge, from: parent.from, to: node });
node = parent.from; parent = reached.get(node);
}
return steps.reverse();
}
/** The root's outgoing CALLS in source order: by call-site line, calls without
* a recorded line last, then by callee. Every call site is its own step. */
export function callOrder(edges: readonly GraphEdge[], root: number): ScopePathStep[] {
return edges.map((edge, index) => ({ edge, index }))
.filter(({ edge }) => edge.type === 'CALLS' && edge.source === root && edge.target !== root)
.sort((a, b) => (a.edge.line ?? Number.MAX_SAFE_INTEGER) - (b.edge.line ?? Number.MAX_SAFE_INTEGER)
|| a.edge.target - b.edge.target || a.index - b.index)
.map(({ edge }) => ({ edge, from: root, to: edge.target }));
}
/** Every node a path touches, roots included. */
export function pathNodes(steps: readonly ScopePathStep[], roots: ReadonlySet<number>): Set<number> {
const ids = new Set<number>(steps.length ? [steps[0]!.from] : roots);
for (const step of steps) { ids.add(step.from); ids.add(step.to); }
return ids;
}