mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-10 09:13:53 +08:00
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:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user