fix(ui): open node and symbol scopes one layer deep

Selecting a symbol in the Galaxy loaded zero hops, so the toolbar read
"0 layers, 1 node, 0 edges" and the edge-type filter had nothing to offer
until the reader pressed Expand. A single node has no relationships of its
own, so node and symbol scopes now start at depth 1 and cannot go below it.
File and folder scopes keep depth 0, where their internal edges are already
included. The reader range label uses a plain hyphen.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-02 18:34:23 +02:00
parent 92224c4463
commit 6a9a9d03ae
4 changed files with 37 additions and 6 deletions
@@ -51,7 +51,7 @@ it('returns a standalone hierarchy selection to the overall Galaxy without stale
await act(async () => select.click());
const evidence = JSON.parse(onSelectionEvidence.mock.lastCall![0].text).evidence;
expect(evidence.selected.scope).toMatchObject({ kind: 'node', id: 1, name: 'node1' });
expect(evidence.scope).toMatchObject({ depth: 0, direction: 'both', edgeTypes: 'all' });
expect(evidence.scope).toMatchObject({ depth: 1, direction: 'both', edgeTypes: 'all' });
expect(evidence.limitations.state).not.toBe('complete-indexed-scope');
await act(async () => host.querySelector<HTMLButtonElement>('[data-mode="hierarchy"]')!.click());
expect(globalThis.__atlasGalaxy?.mode).toBe('hierarchy');
+1 -1
View File
@@ -2215,7 +2215,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
<option value="both">Both directions</option><option value="inbound">Incoming</option><option value="outbound">Outgoing</option>
</select></label>}
{props.workspaceExpanded && <TraceEdgeFilter kinds={traceKinds} availableTypes={kinds.map(kind => kind.type)} selected={traceTypes} onChange={changeTraceTypes} />}
<button type="button" disabled={scope.depth <= (!props.workspaceExpanded && props.focusFilePath ? 1 : 0) || scope.loading}
<button type="button" disabled={scope.depth <= scope.minDepth || scope.loading}
onClick={() => scope.setDepth(scope.depth - 1)} aria-label="Remove graph layer">−</button>
<span>{scope.depth} {scope.depth === 1 ? 'layer' : 'layers'}</span>
<button type="button" disabled={scope.loading || scope.result?.exhausted}
@@ -127,3 +127,27 @@ it('does not display excluded prior-layer nodes while the filtered request is pe
await act(async () => root.render(<Probe types={[]} />));
expect(host.textContent).toBe('1:pending');
});
it('opens node and symbol scopes one layer deep and keeps them there, while clusters start at their members', async () => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
dispose = async () => { await act(async () => root.unmount()); host.remove(); };
state.load.mockImplementation(async (...args: unknown[]) => scoped([1], Number(args[2])));
let latest: ReturnType<typeof useGraphScope> | undefined;
function Probe() { latest = useGraphScope({ project: 'p' }); return null; }
await act(async () => root.render(<Probe />));
await act(async () => latest!.select({ kind: 'node', id: 1, name: 'n1', qualifiedName: 'p.n1' }));
expect(latest!.depth).toBe(1); expect(latest!.minDepth).toBe(1);
expect(state.load.mock.calls.at(-1)![2]).toBe(1);
await act(async () => latest!.setDepth(0));
expect(latest!.depth).toBe(1);
expect(state.load).toHaveBeenCalledTimes(1);
await act(async () => latest!.select({ kind: 'symbol', qualifiedName: 'p.n2', name: 'n2' }));
expect(latest!.depth).toBe(1);
expect(state.load.mock.calls.at(-1)![2]).toBe(1);
await act(async () => latest!.select({ kind: 'folder', path: 'src', name: 'src/' }));
expect(latest!.depth).toBe(0); expect(latest!.minDepth).toBe(0);
expect(state.load.mock.calls.at(-1)![2]).toBe(0);
await act(async () => latest!.select({ kind: 'file', path: 'src/a.ts', name: 'a.ts' }));
expect(latest!.depth).toBe(0);
});
+11 -4
View File
@@ -5,6 +5,12 @@ import { readerGraphFocus, type SourceFocusRange } from './reader-graph-focus';
import type { GraphData } from './types';
import { graphNeighborhoodCache, graphNeighborhoodTransportKey, peekGraphNeighborhoodCache } from './graph-neighborhood-cache';
/** A node or symbol alone has no relationships to show, so it starts one hop
* out. Files and folders already carry their internal edges at depth 0. */
export function minimumScopeDepth(scope: GraphScope | undefined): number {
return scope?.kind === 'node' || scope?.kind === 'symbol' ? 1 : 0;
}
/** Scoped requests are independent of the whole-repository layout budget. */
export function useGraphScope({ project, layout, filePath, range, fetch: fetchImpl, edgeTypes }: {
project: string; layout?: GraphData; filePath?: string; range?: SourceFocusRange; fetch?: typeof globalThis.fetch; edgeTypes?: readonly string[];
@@ -23,7 +29,7 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm
const [readerDepth, setReaderDepth] = useState<{ key: string; depth: number }>();
const actualDepth = filePath ? readerDepth?.key === readerKey ? readerDepth.depth : 1 : depth;
const scope = useMemo<GraphScope | undefined>(() => filePath ? { kind: 'file', path: filePath,
name: `${filePath.split('/').pop()}${range ? `:${range.startLine}–${range.endLine}` : ''}`, ...(range ? { range } : {}) } : chosen,
name: `${filePath.split('/').pop()}${range ? `:${range.startLine}-${range.endLine}` : ''}`, ...(range ? { range } : {}) } : chosen,
[filePath, range?.startLine, range?.endLine, chosen]);
const edgeTypesKey = graphEdgeTypesKey(edgeTypes);
const selectedTypes = useMemo<string[] | undefined>(() => edgeTypesKey === 'null' ? undefined : JSON.parse(edgeTypesKey) as string[], [edgeTypesKey]);
@@ -122,13 +128,14 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm
return { data: { nodes: layout.nodes.filter(node => ids.has(node.id)), edges: [...edges], total_nodes: ids.size },
roots, depth: actualDepth, exhausted: false, levels };
}, [scope, layout, actualDepth, actualDirection, selectedTypes]);
return { scope, depth: actualDepth, direction, setDirection,
const minDepth = filePath ? 1 : minimumScopeDepth(scope);
return { scope, depth: actualDepth, minDepth, direction, setDirection,
result: complete ?? (cachedPreview?.key === key ? cachedPreview.value : undefined)
?? (result?.scopeKey === scopeKey && result.value.depth <= actualDepth ? result.value : fallback), loading: Boolean(scope && !complete && !error), error,
validating: Boolean(scope && !complete && !error && pendingPhase?.key === key && pendingPhase.validating),
complete: Boolean(complete), retry: () => setReload(value => value + 1),
select: (next: GraphScope) => { setChosen(next); setDepth(0); setDirection('both'); },
select: (next: GraphScope) => { setChosen(next); setDepth(minimumScopeDepth(next)); setDirection('both'); },
reset: () => { setChosen(undefined); setDepth(0); setReaderDepth(undefined); setDirection('both'); },
setDepth: (next: number) => filePath ? setReaderDepth({ key: readerKey, depth: Math.max(1, next) }) : setDepth(Math.max(0, next)),
setDepth: (next: number) => filePath ? setReaderDepth({ key: readerKey, depth: Math.max(minDepth, next) }) : setDepth(Math.max(minDepth, next)),
};
}