fix(ui): keep Architecture cameras and call-chain position in the history (K27)

Review follow-up for K27:

- The opened Overview area or file reaches only Overview, and the hotspot
  area only Hotspots. Endpoints, Entry points and Hotspots no longer draw
  or report an area opened in Overview, so the chat context names nothing
  that is not on screen.
- Plan or 3D in System structure and in Behavior is part of the entry and a
  step, as on the shared map. The Behavior path to a destination is a step;
  the operation on the call chain and the page of direct calls stay with the
  entry without being steps, so Back and Forward return to them.
- The in-view "Back" buttons of System structure and Behavior are absorbed
  into the one Back beside the subtabs.
- Back, Forward or a Recent jump while typing in the Routes filter first
  records the typed text as a step instead of dropping it.
- replaceNavigation also merges with the step after it, so no two equal
  steps end up next to each other.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-03 23:02:53 +02:00
1 parent d354983e84
commit 8e6edc81f6
14 files changed
+403 -136

No files matched your search

+38 -17
View File
@@ -110,14 +110,30 @@ has opened.
| `routes` | The Routes perspective: Service map or Endpoints. |
| `filter` | The Routes filter. An opened route group ("Show these N routes") is that filter. |
| `system.focus`, `system.expanded` | The System structure focus and the expanded groups. |
| `system.structurePlanar`, `system.behaviorPlanar` | Plan or 3D in System structure and in Behavior. Each keeps its own camera, as before K27. |
| `system.behavior` | The Behavior start, the destination ("Reach") and, after a followed call (double-click, "Follow calls from here"), the start the hops began from. |
| `system.behavior.position` | Where the journey stands: the indexed path to the destination ("Path 2"), the operation on that call chain, and the page of direct calls. |
| `system.shown` | The start Behavior shows when none was requested (the server picks one, or the first entry point), so a tooltip can name it. |
The entry holds identities and names, never projections, scenes or cameras.
Its key covers only what the current subtab shows: a field another subtab holds
does not split a step, and the names are there for the tooltips only. The
Behavior start is the numeric identity of one analysis snapshot and stays
guarded by its generation, as before.
The entry holds identities, names and a few small numbers, never projections,
scenes or cameras. Its key covers only what the current subtab shows: a field
another subtab holds does not split a step, and the names are there for the
tooltips only. The Behavior start is the numeric identity of one analysis
snapshot and stays guarded by its generation, as before.
What another subtab holds stays out of that subtab entirely, not only out of
the key. The map reads the opened area or file only in Overview and the hotspot
area only in Hotspots, so Entry points and Endpoints neither draw nor report an
area opened in Overview, and the chat context there names nothing that is not
on screen. Back to Overview opens the area again.
Every view that draws a scene makes Plan or 3D a step: the shared map, System
structure and Behavior. Another path to a destination is a step too. Walking
the call chain (Previous, Next, the slider, a click on an operation) and paging
through direct calls are no steps, because Previous and Next already do that;
the entry keeps where the journey stood, so Back and Forward return to the same
operation. A new start, destination or followed call begins at the top of its
journey.
### Rules
@@ -129,21 +145,25 @@ keys, and a fresh history per project (the workspace remounts per project).
Architecture adds:
1. **One Back.** Back, Forward and Recent sit beside the subtabs and serve the
whole workspace. The "← Back" buttons in System structure and Behavior stay
where users know them and take the same step, with the same tooltip; there
is no second, local meaning of Back. "Whole system" stays as a way to the
top, and the Overview location trail stays; both are ordinary steps.
whole workspace. The "← Back" buttons System structure and Behavior had in
their own toolbars are absorbed into it: there is one Back control with one
meaning, and a step in System structure or Behavior is one Back away like
any other. "Whole system" stays as a way to the top, and the Overview
location trail stays; both are ordinary steps.
2. **Separate from Galaxy.** Galaxy and Architecture each keep their own
history, and Alt+Left and Alt+Right act only in the active workspace.
3. **Typing is one step.** Typing in the Routes filter becomes a step once it
pauses (600 ms), not one step per key. A navigation while typing first
records what the field showed. Back or Forward while typing drops the text
that is not a step yet.
pauses (600 ms), not one step per key. A navigation, Back, Forward or a
Recent jump while typing first records what the field showed, so typed text
is never lost: Back leaves it for the place before the typing and Forward
returns to it. Forward while typing finds no forward branch, because the
text is a new navigation; it records the text and stays.
4. **The page's own choices are no steps.** What the page sets by itself (the
suggested Behavior start such as `main`, a reset after reindexing) replaces
the current step (`replaceNavigation`). As a step of its own, Back would land
on an empty Behavior, the page would pick `main` again, and Back would never
get past it.
get past it. A replaced step that equals the step before or after it merges
with that step, so Back and Forward never lead to the same place twice.
5. **Followed calls.** A followed call is a new start that remembers where the
hops began. Empty background returns there, as it did before, and that is a
step too.
@@ -169,15 +189,16 @@ Architecture adds:
`SpatialArchitecture`, `RoutesArchitecture`, `SystemArchitecture` and
`BehaviorJourney` read their part of the place and report changes back
(`lifted-place.ts`). Rendered on their own, as in their unit tests, they keep
that part as their own state and offer no Back.
that part as their own state; none of them has a Back of its own.
- Tests: `architecture-history.test.ts` (entry, key, labels),
`ArchitecturePanel.history.test.tsx` (the wiring) and the browser run
`graph-ui/tools/handtest-fixes-k27.mjs` on django-demo and cbm.
### Not in scope
- Not steps: the Plan or 3D switch in System structure and Behavior, the Entry
points start and call depth, selections, the path and step within a call
chain, paging, the relationship filters and the camera.
- Neither steps nor kept in the entry: the Entry points start and call depth,
selections, the relationship filters, the System structure checkboxes, and
the camera position (zoom, pan, "Fit"). Plan or 3D is a step, the camera
position is not.
- No persistence across reloads. As before, only the subtab is remembered per
project.
@@ -22,9 +22,9 @@ vi.mock('./ContainerMap', () => ({ default: ({ filter }: { filter: string }) =>
vi.mock('./route-graph-source', () => ({ loadRouteGraph: vi.fn(async () => ({ relationships: [], truncated: false, warnings: [] })) }));
vi.mock('./BehaviorSourceEvidence', () => ({ default: () => <div /> }));
vi.mock('../app/atlas-api', () => ({ AtlasApi: class { flows() { return Promise.resolve([]); } } }));
vi.mock('./SystemArchitectureScene', () => ({ default: ({ model, onSelectNode, onExpandNode, onClearSelection }: {
model: SystemSceneModel; onSelectNode: (id: string) => void; onExpandNode?: (id: string) => void; onClearSelection?: () => void;
}) => <div data-testid="system-scene" data-focus={model.focusId ?? ''}><button onClick={onClearSelection}>System background</button>
vi.mock('./SystemArchitectureScene', () => ({ default: ({ model, onSelectNode, onExpandNode, onClearSelection, planar }: {
model: SystemSceneModel; onSelectNode: (id: string) => void; onExpandNode?: (id: string) => void; onClearSelection?: () => void; planar?: boolean;
}) => <div data-testid="system-scene" data-focus={model.focusId ?? ''} data-planar={String(Boolean(planar))}><button onClick={onClearSelection}>System background</button>
{model.nodes.map(node => <button key={node.id} data-system-node={node.id} onClick={() => onSelectNode(node.id)} onDoubleClick={() => onExpandNode?.(node.id)}>{node.label}</button>)}</div> }));
const graphNode = (id: number, name: string, file_path: string, label = 'Function'): GraphNode =>
@@ -54,6 +54,13 @@ function projection(): SystemProjection {
connections: dependencies.map(edge => ({ ...edge, source: `g:${edge.source}`, target: `g:${edge.target}` })), totals: { groups: 3, components: 3 }, limits: {} },
};
}
/** main reaches save on two indexed paths: through handle, and directly. */
function twoPaths(): SystemProjection {
const data = projection();
const [main, , save] = data.paths[0]!.nodes;
data.paths = [...data.paths, { entrypoint_id: 1, nodes: [main!, save!], edges: [{ id: 13, source_id: 1, target_id: 3, type: 'CALLS' }] }];
return data;
}
let container: HTMLDivElement;
let root: Root;
@@ -119,6 +126,14 @@ const filter = () => container.querySelector<HTMLInputElement>('input[type="sear
const focus = () => container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-focus');
const heading = () => container.querySelector('.behavior-heading h2')?.textContent;
const sceneLabels = () => [...container.querySelectorAll('[data-testid="scene"] [data-node]')].map(item => item.textContent);
/** The "← Back" System structure and Behavior had in their own toolbars before K27. */
const inViewBack = () => buttons().find(button => button.textContent === '← Back');
const systemCamera = () => container.querySelector('[aria-label="System camera"] button[aria-pressed="true"]')?.textContent;
const behaviorCamera = () => container.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')?.textContent;
const chainPosition = () => container.querySelector('[aria-label="Walk the call chain"] span')?.textContent;
const pathButtons = () => [...container.querySelectorAll<HTMLButtonElement>('[aria-label="Indexed paths"] button')];
const chosenPath = () => pathButtons().findIndex(button => button.getAttribute('aria-pressed') === 'true') + 1;
const lastEvidence = (listener: ReturnType<typeof vi.fn>) => listener.mock.lastCall?.[0] as { label: string; text: string } | undefined;
const key = (init: KeyboardEventInit, target: EventTarget = window) => {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
act(() => { target.dispatchEvent(event); });
@@ -204,9 +219,9 @@ describe('Architecture back and forward (K27)', () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="structure"]'));
expect(focus()).toBe('');
const oldBack = () => byText('← Back', container.querySelector('.system-scope-actions')!);
expect(oldBack()?.disabled).toBe(false);
expect(oldBack()?.title).toBe('Back to Overview (Alt+Left)');
// The old "← Back" is absorbed into the shared Back beside the subtabs: one Back control, one meaning.
expect(inViewBack()).toBeUndefined();
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
await doubleClick('[data-system-node="g:api"]');
expect(focus()).toBe('g:api');
expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull();
@@ -214,15 +229,16 @@ describe('Architecture back and forward (K27)', () => {
await press(container.querySelector<HTMLButtonElement>('[data-system-node="g:service"]'));
await clickText('Focus here');
expect(focus()).toBe('g:service');
expect(oldBack()?.title).toBe('Back to System structure · api · 1 group open (Alt+Left)');
expect(back()?.title).toBe('Back to System structure · api · 1 group open (Alt+Left)');
expect(inViewBack()).toBeUndefined();
await press(oldBack());
await goBack();
expect(focus()).toBe('g:api');
await goBack();
expect(focus()).toBe('');
expect(container.querySelector('[data-system-node="member:g:api:api"]')).toBeNull();
// One meaning of Back: the old button now also leaves System structure.
await press(oldBack());
// One meaning of Back: the same Back also leaves System structure.
await goBack();
expect(view()).toBe('overview');
await goForward(); await goForward();
expect([view(), focus()]).toEqual(['structure', 'g:api']);
@@ -246,11 +262,11 @@ describe('Architecture back and forward (K27)', () => {
expect(back()?.title).toBe('Back to Behavior · main (Alt+Left)');
await doubleClick('[data-system-node="journey-choice:3"]');
expect(heading()).toBe('What can save call?');
const oldBack = () => byText('← Back', container.querySelector('.behavior-navigation')!);
expect(oldBack()?.title).toBe('Back to Behavior · handle (Alt+Left)');
expect(inViewBack()).toBeUndefined();
expect(back()?.title).toBe('Back to Behavior · handle (Alt+Left)');
expect(forward()?.disabled).toBe(true);
await press(oldBack());
await goBack();
expect(heading()).toBe('What can handle call?');
expect(forward()?.title).toBe('Forward to Behavior · save · followed from handle (Alt+Right)');
await goBack();
@@ -289,6 +305,121 @@ describe('Architecture back and forward (K27)', () => {
expect(forward()?.title).toBe('Forward to Behavior · main (Alt+Right)');
});
it('makes Plan or 3D in System structure a step that Back restores', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="structure"]'));
expect(systemCamera()).toBe('3D');
expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('false');
await clickText('Plan', container.querySelector('[aria-label="System camera"]')!);
expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('true');
expect(back()?.title).toBe('Back to System structure (Alt+Left)');
await press(container.querySelector<HTMLButtonElement>('[data-view="overview"]'));
expect(back()?.title).toBe('Back to System structure · Plan (Alt+Left)');
await goBack();
expect([view(), systemCamera()]).toEqual(['structure', 'Plan']);
expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('true');
await goBack();
expect([view(), systemCamera()]).toEqual(['structure', '3D']);
await goForward(); await goForward();
expect(view()).toBe('overview');
// Behavior keeps a camera of its own, as before.
await goBack();
await press(container.querySelector<HTMLButtonElement>('[data-view="behavior"]'));
expect(behaviorCamera()).toBe('3D');
});
it('keeps the Behavior path, the operation on it and Plan or 3D in the entry, and only a path or camera change is a step', async () => {
loader.mockImplementation(async () => ({ status: 'ready', generation: 'g1', result: twoPaths() }));
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="behavior"]'));
expect(heading()).toBe('What can main call?');
await choose('Behavior destination', '3');
expect(heading()).toBe('main → save');
expect([chosenPath(), chainPosition()]).toEqual([1, '1 / 3']);
await clickText('Next →');
expect(chainPosition()).toBe('2 / 3');
// Walking the chain is no step of its own: Previous and Next do that; the entry keeps where it stands.
expect(back()?.title).toBe('Back to Behavior · main (Alt+Left)');
await press(pathButtons()[1]);
expect([chosenPath(), chainPosition()]).toEqual([2, '1 / 2']);
expect(back()?.title).toBe('Back to Behavior · main → save (Alt+Left)');
await clickText('Plan', container.querySelector('[aria-label="Behavior camera"]')!);
expect(back()?.title).toBe('Back to Behavior · main → save · Path 2 (Alt+Left)');
await press(container.querySelector<HTMLButtonElement>('[data-view="overview"]'));
expect(back()?.title).toBe('Back to Behavior · main → save · Path 2 · Plan (Alt+Left)');
await goBack();
expect([view(), behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['behavior', 'Plan', 2, '1 / 2']);
await goBack();
expect([behaviorCamera(), chosenPath()]).toEqual(['3D', 2]);
await goBack();
expect([heading(), chosenPath(), chainPosition()]).toEqual(['main → save', 1, '2 / 3']);
await goBack();
expect([heading(), container.querySelector('[aria-label="Walk the call chain"]')]).toEqual(['What can main call?', null]);
await goForward();
expect([chosenPath(), chainPosition()]).toEqual([1, '2 / 3']);
await goForward(); await goForward();
expect([behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['Plan', 2, '1 / 2']);
// System structure keeps a camera of its own, as before.
await press(container.querySelector<HTMLButtonElement>('[data-view="structure"]'));
expect(systemCamera()).toBe('3D');
});
it('records typing that has not paused yet as a step when Back, Forward or Recent leaves it', async () => {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await type('/adm');
await goBack();
expect([view(), filter()]).toEqual(['routes', '']);
expect(forward()?.title).toBe('Forward to Routes · Service map · /adm (Alt+Right)');
await goForward();
expect([view(), filter()]).toEqual(['routes', '/adm']);
await act(async () => { await new Promise(resolve => setTimeout(resolve, 900)); });
expect(history()?.getAttribute('data-position')).toBe('3/3');
// A recent place while typing: the text stays a step before the jump.
await type('/home');
const recent = container.querySelector<HTMLDetailsElement>('details.atlas-arch-recent')!;
const overviewPlace = [...recent.querySelectorAll<HTMLButtonElement>('[aria-label="Recently visited places"] li button')]
.find(item => item.querySelector('strong')?.textContent === 'Overview');
await press(overviewPlace);
expect(view()).toBe('overview');
expect(back()?.title).toBe('Back to Routes · Service map · /home (Alt+Left)');
await goBack();
expect([view(), filter()]).toEqual(['routes', '/home']);
// Forward while typing: the text is a new step and drops the forward branch, as after the pause.
await goBack();
expect(filter()).toBe('/adm');
await type('/admin');
await goForward();
expect(filter()).toBe('/admin');
expect(forward()?.disabled).toBe(true);
expect(back()?.title).toBe('Back to Routes · Service map · /adm (Alt+Left)');
});
it('reports an opened area only to the subtab that shows it, never to Routes or Hotspots', async () => {
const onSelectionEvidence = vi.fn();
await render({ onSelectionEvidence });
await openArea('django');
expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
expect(sceneLabels()).toContain('/edit · 2');
// Nothing is selected on the Endpoints map, so the chat context names nothing from Overview.
expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
await press(container.querySelector<HTMLButtonElement>('[data-view="hotspots"]'));
expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
await press(container.querySelector<HTMLButtonElement>('[data-view="overview"]'));
expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await goBack();
expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]);
expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
await goBack(); await goBack();
expect([view(), perspective()]).toEqual(['routes', 'Endpoints']);
expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
});
it('drops the forward branch on a new navigation after Back and keeps at most 25 steps', async () => {
await render();
for (const tab of ['routes', 'hotspots', 'structure']) await press(container.querySelector<HTMLButtonElement>(`[data-view="${tab}"]`));
@@ -9,7 +9,7 @@ import { architectureHistoryText as historyText, architectureText as text } from
import { peekNavigation, type NavigationHistory } from '../graph/navigation-history';
import {
architectureEntryDetail, architectureEntryLabel, architectureEntryName, architectureHistoryOptions, initialArchitecturePlace,
type ArchitectureHistoryEntry, type PlaceChange, type RoutesPerspective, type SharedBack, type SpatialPlace, type SystemPlace,
type ArchitectureHistoryEntry, type PlaceChange, type RoutesPerspective, type SpatialPlace, type SystemPlace,
} from './architecture-history';
import { useArchitectureHistory } from './use-architecture-history';
import './architecture.css';
@@ -161,11 +161,12 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
const storage = useMemo(browserStorage, []);
/*
* The place is the whole history entry (K27): subtab, opened area or file,
* Plan or 3D, route perspective and filter, System structure focus and
* groups, Behavior start. The views below read their part of it and report
* changes back, so Back and Forward restore every part at once. A filter is
* a search for this visit: one saved by an earlier session would silently
* hide routes, so only the subtab is read back.
* route perspective and filter, System structure focus and groups, Behavior
* start, destination and position, and Plan or 3D of each scene. The views
* below read their part of it and report changes back, so Back and Forward
* restore every part at once; the one Back for all of them sits beside the
* subtabs. A filter is a search for this visit: one saved by an earlier
* session would silently hide routes, so only the subtab is read back.
*/
const navigation = useArchitectureHistory(() => initialArchitecturePlace(readArchitectureConfig(storage, projectName).view), {
active, escapeTaken, onRestore: (from, to) => { if (from.view !== to.view) onSelectionEvidence?.(undefined); },
@@ -180,7 +181,6 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
const changePerspective = useCallback((routes: RoutesPerspective) => navigate(current => ({ ...current, routes })), [navigate]);
// An opened route group ("Show these N routes") is a step at once; typing waits for a pause.
const openRouteGroup = useCallback((filter: string) => navigate(current => ({ ...current, filter })), [navigate]);
const sharedBack: SharedBack = { target: navigation.back ? architectureEntryLabel(navigation.back) : undefined, onBack: () => navigation.go(-1) };
// A cached summary from another project must never appear here.
const data = overview?.projectName && overview.projectName !== projectName ? undefined : overview;
const ready = Boolean(data && !loading && !error && projectName);
@@ -207,7 +207,7 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
{systemView && projectName ? <Suspense fallback={<div role="status"><Empty>Preparing system analysis…</Empty></div>}><SystemArchitecture
project={projectName} generation={graphGeneration} view={systemView} filter={filter} active={active}
graph={graph} onSelect={onSelect} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence} onNavigate={onNavigate} loader={systemArchitectureLoader}
place={place.system} onPlace={changeSystem} back={sharedBack} /></Suspense> : null}
place={place.system} onPlace={changeSystem} /></Suspense> : null}
{!systemView && (!projectName ? <Empty>{text.chooseProject}</Empty> : loading ? <div role="status"><Empty>{text.loading}</Empty></div>
: error ? <div role="alert" className="atlas-arch-empty" data-error="true"><p>{text.loadFailed}</p><p className="atlas-arch-error-detail">{error}</p>
{onRefresh && <button className="atlas-arch-action" onClick={onRefresh}>{text.retry}</button>}</div>
@@ -70,16 +70,12 @@ describe('behavior journeys', () => {
expect([...container.querySelectorAll('[data-edge]')].map(item => item.getAttribute('data-edge'))).toEqual(['14', '15', '16', '17']);
expect(container.querySelector('[aria-label="Call-chain position"]')?.getAttribute('value')).toBe('5');
});
it('double-clicking an operation requests its actual symbol as a hop from the start, and Back is the shared Back (K27)', async () => {
const onBack = vi.fn();
const props = await render(fixture(), { back: { target: 'Behavior · operation1', onBack } });
it('double-clicking an operation requests its actual symbol as a hop from the start; Back is the workspace Back, not one of its own (K27)', async () => {
const props = await render(fixture());
const button = container.querySelector('[data-node="journey-choice:2"]')!;
await act(async () => button.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 2 }), undefined, { from: expect.objectContaining({ id: 1 }) });
const back = [...container.querySelectorAll<HTMLButtonElement>('.behavior-navigation button')].find(item => item.textContent === '← Back')!;
expect(back.title).toBe('Back to Behavior · operation1 (Alt+Left)');
await act(async () => back.click());
expect(onBack).toHaveBeenCalledOnce();
expect([...container.querySelectorAll('button')].some(item => item.textContent === '← Back')).toBe(false);
// A second hop still names the operation the hops started from.
const next = fixture(); next.entrypoints = [next.paths[0].nodes[1]];
next.paths = [{ ...next.paths[0], entrypoint_id: 2, nodes: next.paths[0].nodes.slice(1), edges: next.paths[0].edges.slice(1) }];
@@ -94,15 +90,35 @@ describe('behavior journeys', () => {
const clear = vi.fn(); const props = await render(fixture(), { onClearSelection: clear });
const next = fixture(); next.entrypoints = [next.paths[0].nodes[1]];
next.paths = [{ ...next.paths[0], entrypoint_id: 2, nodes: next.paths[0].nodes.slice(1), edges: next.paths[0].edges.slice(1) }];
await render(next, { ...props, data: next, entryId: 2, from: fixture().entrypoints[0], back: { onBack: vi.fn() } });
await render(next, { ...props, data: next, entryId: 2, from: fixture().entrypoints[0] });
await click('Empty background');
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 1 }), undefined, {});
expect(container.querySelector('[data-scene]')?.getAttribute('data-selected')).toBeNull();
expect(container.querySelector('[aria-label="Behavior evidence inspector"]')?.textContent).toContain('Select an operation or a call');
expect(clear).toHaveBeenCalledOnce();
const back = [...container.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent === '← Back');
expect(back?.disabled).toBe(true);
expect(back?.title).toBe('Nothing to go back to yet');
});
it('reads where it stands and its camera from a lifted place and reports changes there (K27)', async () => {
const onPlace = vi.fn();
const props = await render(fixture(), { targetId: 9, place: { step: 3, planar: true }, onPlace });
expect(container.querySelector('[aria-label="Walk the call chain"] span')?.textContent).toBe('4 / 9');
expect(container.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')?.textContent).toBe('Plan');
await click('Next →');
expect(onPlace).toHaveBeenLastCalledWith({ step: 4 }, undefined);
await click('3D');
expect(onPlace).toHaveBeenLastCalledWith({ planar: false }, undefined);
// The lifted place stays what it is handed: a new start brings its own, and Back brings the one it had.
onPlace.mockClear();
await render(fixture(), { ...props, targetId: undefined, place: { step: 3, planar: true }, onPlace });
expect(onPlace).not.toHaveBeenCalled();
});
it('keeps its own place when rendered on its own, and starts the chain over for another destination', async () => {
const props = await render(fixture(), { targetId: 9 });
await click('Next →'); await click('Next →'); await click('Plan');
expect(container.querySelector('[aria-label="Walk the call chain"] span')?.textContent).toBe('3 / 9');
await render(fixture(), { ...props, targetId: undefined });
await render(fixture(), { ...props, targetId: 9 });
expect(container.querySelector('[aria-label="Walk the call chain"] span')?.textContent).toBe('1 / 9');
expect(container.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')?.textContent).toBe('Plan');
});
it('shows exact argument expressions and declaration facts without inventing argument-to-parameter bindings', async () => {
const data = fixture();
+31 -22
View File
@@ -1,8 +1,9 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { behaviorJourney } from './behavior-journey-model';
import { projectionLimits, type SystemSceneModel } from './system-architecture-model';
import { architectureHistoryText as historyText, architectureText as text } from './strings';
import type { SharedBack } from './architecture-history';
import { architectureText as text } from './strings';
import type { JourneyPlace, PlaceChange } from './architecture-history';
import { useLiftedPlace, useOnIdentityChange } from './lifted-place';
import type { SystemProjection, SystemSymbol } from './system-architecture-source';
import BehaviorSourceEvidence, { type BehaviorSourceSnapshot } from './BehaviorSourceEvidence';
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
@@ -16,8 +17,12 @@ export interface BehaviorJourneyProps {
onRequest: (entry: SystemSymbol | undefined, targetId?: number, detail?: BehaviorRequestDetail) => void;
/** The start the followed calls began from (K27); empty background returns there. */
from?: SystemSymbol;
/** The shared Architecture Back; without it the journey offers no Back of its own. */
back?: SharedBack;
/**
* Where the journey stands (path, operation, page of direct calls) and Plan or 3D, lifted to the workspace
* for Back and Forward (K27). Rendered on its own, the journey keeps them itself.
*/
place?: JourneyPlace;
onPlace?: PlaceChange<JourneyPlace>;
/** Reports the start shown when none was requested, so a Back or Forward tooltip can name it (K27). */
onShownStart?: (name: string) => void;
onClearSelection?: () => void;
@@ -48,16 +53,20 @@ export function journeyPage(scene: SystemSceneModel, step: number, choices = fal
}
export default function BehaviorJourney({ project, generation, data, entries, targets, entryId, targetId, active, pending, error, filter,
onRequest, onRefresh, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, back, onShownStart }: BehaviorJourneyProps) {
const [pathIndex, setPathIndex] = useState(0), [step, setStep] = useState(0);
const [branchPage, setBranchPage] = useState(0);
onRequest, onRefresh, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart }: BehaviorJourneyProps) {
const [place, changePlace] = useLiftedPlace<JourneyPlace>(liftedPlace, onPlace, () => ({}));
const { path: pathIndex = 0, step = 0, page: branchPage = 0, planar = false } = place;
const [overview, setOverview] = useState(false);
const [selection, setSelection] = useState<{ node?: string; edge?: string }>();
const [planar, setPlanar] = useState(false), [resetKey, setResetKey] = useState(0);
const [resetKey, setResetKey] = useState(0);
const visual = useRef<HTMLDivElement>(null);
const [pathWindow, setPathWindow] = useState(5);
const [sourceEvidence, setSourceEvidence] = useState<BehaviorSourceSnapshot>();
useEffect(() => { setSelection(undefined); setPathIndex(0); setStep(0); setBranchPage(0); }, [project, generation, entryId, targetId, filter]);
const lifted = liftedPlace !== undefined;
const top = { path: 0, step: 0, page: 0 };
// A lifted journey is handed its position with every start, and Back hands the one it had; on its own it starts over.
useOnIdentityChange(JSON.stringify([project, generation, entryId, targetId]), () => { setSelection(undefined); if (!lifted) changePlace(top); });
useOnIdentityChange(filter, () => { setSelection(undefined); changePlace(top); });
const journey = useMemo(() => data ? behaviorJourney(data, { entryId, targetId, pathIndex, filter }) : undefined,
[data, entryId, targetId, pathIndex, filter]);
const path = journey?.path;
@@ -107,7 +116,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
/** Empty background leaves a destination and returns from followed calls to where they began. */
function clearSelection() {
const origin = from ?? entry;
setOverview(true); setSelection(undefined); setStep(0); setBranchPage(0); setPathIndex(0);
setOverview(true); setSelection(undefined); changePlace(top);
setResetKey(value => value + 1); onClearSelection?.();
if (from || targetId !== undefined) onRequest(origin, undefined, {});
}
@@ -117,7 +126,8 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
*/
function request(next: SystemSymbol | undefined, destination?: number, origin?: SystemSymbol) {
setOverview(false);
setSelection(undefined); setStep(0); setPathIndex(0);
// Lifted, the request itself is the new place and starts at the top.
setSelection(undefined); if (!lifted) changePlace({ path: 0, step: 0 });
const targetName = destination === undefined ? undefined : targetOptions.find(item => item.id === destination)?.name;
onRequest(next, destination, { ...(origin && origin.id !== next?.id ? { from: origin } : {}), ...(targetName ? { targetName } : {}) });
}
@@ -125,22 +135,22 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
setOverview(false);
const node = allNodes.find(item => item.id === id);
setSelection({ node: id });
if (path && node?.depth !== undefined) setStep(node.depth);
if (!path && node) setBranchPage(Math.max(0, Math.floor((allNodes.indexOf(node) - 1) / 4)));
if (path && node?.depth !== undefined) changePlace({ step: node.depth });
if (!path && node) changePlace({ page: Math.max(0, Math.floor((allNodes.indexOf(node) - 1) / 4)) });
if (node?.symbol) onSelectSymbol(node.symbol);
}
function selectEdge(id: string) {
setOverview(false);
const edge = journey?.scene.edges.find(item => item.id === id);
setSelection({ edge: id });
if (path && edge?.depth) setStep(edge.depth - 1);
if (!path && edge) setBranchPage(Math.max(0, Math.floor((allNodes.findIndex(node => node.id === edge.target) - 1) / 4)));
if (path && edge?.depth) changePlace({ step: edge.depth - 1 });
if (!path && edge) changePlace({ page: Math.max(0, Math.floor((allNodes.findIndex(node => node.id === edge.target) - 1) / 4)) });
}
function selectStep(index: number) {
setOverview(false);
const node = allNodes[index];
if (!node || !path) return;
setStep(index); setSelection({ node: node.id }); if (node.symbol) onSelectSymbol(node.symbol);
changePlace({ step: index }); setSelection({ node: node.id }); if (node.symbol) onSelectSymbol(node.symbol);
}
function follow(id: string) {
const operation = allNodes.find(node => node.id === id)?.symbol;
@@ -160,19 +170,18 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
<option value="">Explore immediate calls</option>{targetOptions.map(item => <option key={item.id} value={item.id}>{item.name} · {item.file_path ?? ''}</option>)}
</select></label>
</div>
<div className="behavior-navigation">{back && <button disabled={!back.target} title={back.target ? historyText.backTo(back.target) : historyText.noBack} onClick={back.onBack}>{historyText.inViewBack}</button>}
{targetId !== undefined && <button onClick={() => request(entry, undefined, from)}>Immediate calls</button>}
<div className="behavior-navigation">{targetId !== undefined && <button onClick={() => request(entry, undefined, from)}>Immediate calls</button>}
<span>{pending ? 'Updating this journey…' : path ? `${path.edges.length} call-chain hops · ${new Set(path.nodes.map(item => item.component_id)).size} components`
: text.directCallees(journey?.counts.directChoices ?? 0, journey?.counts.selfCalls ?? false)}</span>
<span className="behavior-static-badge" title="Graph relationships describe possible calls. They do not prove execution order, path feasibility or actual runtime values.">Static evidence</span>
</div>
{error && <p role="alert">{error} <button onClick={onRefresh}>Try again</button></p>}
{pending ? <div className="behavior-loading" role="status">Preparing the selected operation…</div> : !scene || !journey ? <div className="behavior-loading">Choose an operation to explore.</div> : <>
{journey.paths.length > 1 && <div className="behavior-paths" role="group" aria-label="Indexed paths">{journey.paths.map((item, index) => <button key={index} aria-pressed={pathIndex === index} onClick={() => { setPathIndex(index); setStep(0); setSelection(undefined); }}>
{journey.paths.length > 1 && <div className="behavior-paths" role="group" aria-label="Indexed paths">{journey.paths.map((item, index) => <button key={index} aria-pressed={pathIndex === index} onClick={() => { changePlace({ path: index, step: 0 }); setSelection(undefined); }}>
Path {index + 1}<small>{item.edges.length} hops{new Set(item.nodes.map(node => node.id)).size < item.nodes.length ? ' · revisits a symbol' : ''}</small></button>)}</div>}
<div className="behavior-stage"><div className="behavior-visual" ref={visual}>
<div className="behavior-visual-toolbar"><span>{path ? 'CALL CHAIN →' : 'DIRECT CALLS · UNORDERED'}</span>
<div className="system-view-switch" role="group" aria-label="Behavior camera"><button aria-pressed={!planar} onClick={() => setPlanar(false)}>3D</button><button aria-pressed={planar} onClick={() => setPlanar(true)}>Plan</button></div>
<div className="system-view-switch" role="group" aria-label="Behavior camera"><button aria-pressed={!planar} onClick={() => changePlace({ planar: false })}>3D</button><button aria-pressed={planar} onClick={() => changePlace({ planar: true })}>Plan</button></div>
<button onClick={() => setResetKey(value => value + 1)}>Fit</button></div>
<div className="system-map behavior-map">
{scene.nodes.length ? <Suspense fallback={<div className="behavior-loading">Preparing the journey…</div>}><Scene model={scene}
@@ -187,10 +196,10 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
{path && <nav className="behavior-walk" aria-label="Walk the call chain"><button disabled={activeStep === 0} onClick={() => selectStep(activeStep - 1)}>← Previous</button>
<input type="range" aria-label="Call-chain position" min={0} max={path.nodes.length - 1} value={activeStep} onChange={event => selectStep(Number(event.target.value))} />
<span>{activeStep + 1} / {path.nodes.length}</span><button disabled={activeStep >= path.nodes.length - 1} onClick={() => selectStep(activeStep + 1)}>Next →</button></nav>}
{!path && journey.scene.nodes.length > 5 && <nav className="behavior-walk" aria-label="Direct call pages"><button disabled={branchPage === 0} onClick={() => { setBranchPage(page => page - 1); setSelection(undefined); }}>← Earlier calls</button>
{!path && journey.scene.nodes.length > 5 && <nav className="behavior-walk" aria-label="Direct call pages"><button disabled={branchPage === 0} onClick={() => { changePlace({ page: branchPage - 1 }); setSelection(undefined); }}>← Earlier calls</button>
<span>{text.callPage(branchPage * 4 + 1, Math.min(branchPage * 4 + 4, journey.scene.nodes.length - 1), journey.scene.nodes.length - 1,
journey.counts.omittedNodes, Boolean(filter.trim()) && journey.scene.nodes.length - 1 + journey.counts.omittedNodes < journey.counts.directChoices)}</span>
<button disabled={(branchPage + 1) * 4 >= journey.scene.nodes.length - 1} onClick={() => { setBranchPage(page => page + 1); setSelection(undefined); }}>More calls →</button></nav>}
<button disabled={(branchPage + 1) * 4 >= journey.scene.nodes.length - 1} onClick={() => { changePlace({ page: branchPage + 1 }); setSelection(undefined); }}>More calls →</button></nav>}
</div><aside className="behavior-inspector" aria-label="Behavior evidence inspector">
{caller ? <><span className="system-eyebrow">{call ? call.type.replaceAll('_', ' ') : path ? `Operation ${activeStep + 1}` : 'Starting operation'}</span>
<h3>{caller.name}{callee && <><span className="behavior-call-arrow">↓</span>{callee.name}</>}</h3>
@@ -51,7 +51,14 @@ const relationKinds = ['CALLS', 'IMPORTS', 'USAGE', 'INHERITS', 'IMPLEMENTS', 'D
export default function SpatialArchitecture({ project, generation, graph, overview, view, filter, active, graphNote, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate, onView, onFilter, coverage, place: liftedPlace, onPlace }: Props) {
const [place, changePlace] = useLiftedPlace<SpatialPlace>(liftedPlace, onPlace, () => ({ planar: false }));
const { areaPath, filePath, hotspotArea, planar } = place;
const { planar } = place;
// The place keeps the opened area or file and the hotspot area for Back and Forward (K27), but only the view
// that opened them shows them: Entry points and Endpoints draw the same map without an area, and neither the
// scene nor the selection evidence for the chat may name one there.
const opensScope = view === 'overview' || view === 'dependencies';
const areaPath = opensScope ? place.areaPath : undefined;
const filePath = opensScope ? place.filePath : undefined;
const hotspotArea = view === 'hotspots' ? place.hotspotArea : undefined;
const [entryChoice, setEntryChoice] = useState<{ node: GraphNode; generation?: string }>();
const [depth, setDepth] = useState(2);
const [relations, setRelations] = useState<string[]>([]);
@@ -65,7 +72,7 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
const [memberLimit, setMemberLimit] = useState(5);
const [includeTestRoutes, setIncludeTestRoutes] = useState(false);
// A reindex clears the hotspot area; a view that mounts again keeps the place it is handed.
useOnIdentityChange(`${project}:${generation ?? ''}`, () => { if (hotspotArea) changePlace({ hotspotArea: undefined }, true); });
useOnIdentityChange(`${project}:${generation ?? ''}`, () => { if (place.hotspotArea) changePlace({ hotspotArea: undefined }, true); });
const routeKey = `${project}:${generation ?? ''}:${routeRevision}`;
useEffect(() => {
if (view !== 'routes' || !active) return;
@@ -102,7 +109,7 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
const selectedEdge = selection?.scope === scope ? model.edges.find(edge => edge.id === selection.edge) : undefined;
const selectedMeasure = selectedNode ? measureSourceNode(selectedNode, catalog) : undefined;
// Inside an opened area or file the inspector speaks about that scope, not the repository.
const openedScope = (view === 'overview' || view === 'dependencies') && (filePath || areaPath) ? filePath
const openedScope = filePath || areaPath ? filePath
? { eyebrow: text.openedFile, title: filePath.split('/').at(-1) ?? filePath, path: filePath, measure: measureSourceNode({ id: `file:${filePath}`, kind: 'file', label: filePath, detail: '', position: [0, 0, 0], count: 0, members: [], filePath }, catalog) }
: { eyebrow: text.openedArea, title: areaPath!, path: areaPath!, measure: catalog.areas.get(areaPath!) } : undefined;
const selectedHotspots = selectedNode ? hotspotsForNode(selectedNode, hotspotCatalog) : undefined;
@@ -248,27 +248,19 @@ describe('system architecture workspace', () => {
expect(container.querySelector('[data-node="g:store"]')).toBeNull();
expect(container.querySelector('[data-node="g:context"]')).toBeNull();
});
it('returns to immediate calls, and its Back is the shared Architecture Back (K27)', async () => {
it('returns to immediate calls, and leaves Back to the Architecture workspace (K27)', async () => {
const loader = vi.fn<SystemArchitectureLoader>().mockImplementation(request => Promise.resolve(response(request.targetNodeId === 3 ? corridorFixture() : overviewFixture())));
const onBack = vi.fn();
await render(loader, { view: 'behavior', back: { target: 'Behavior · start → save', onBack } });
await render(loader, { view: 'behavior' });
await choose('Behavior entry point', '1'); await choose('Behavior destination', '3');
await clickText('Immediate calls');
expect(container.querySelector<HTMLSelectElement>('[aria-label="Behavior destination"]')?.value).toBe('');
expect(container.querySelectorAll('[data-node]')).toHaveLength(2);
expect(container.textContent).toContain('DIRECT CALLS · UNORDERED');
const back = [...container.querySelectorAll<HTMLButtonElement>('.behavior-navigation button')].find(item => item.textContent === '← Back')!;
expect(back.title).toBe('Back to Behavior · start → save (Alt+Left)');
await act(async () => back.click());
expect(onBack).toHaveBeenCalledOnce();
await render(loader, { view: 'structure', back: { target: 'Overview', onBack } });
const structureBack = [...container.querySelectorAll<HTMLButtonElement>('.system-scope-actions button')].find(item => item.textContent === '← Back')!;
expect(structureBack.title).toBe('Back to Overview (Alt+Left)');
await act(async () => structureBack.click());
expect(onBack).toHaveBeenCalledTimes(2);
// Without a shared history (a view on its own) there is no second, local Back.
await render(loader, { view: 'structure', back: undefined });
// The old "← Back" buttons are absorbed into the one Back beside the Architecture subtabs.
expect([...container.querySelectorAll('button')].some(item => item.textContent === '← Back')).toBe(false);
await render(loader, { view: 'structure' });
expect([...container.querySelectorAll('button')].some(item => item.textContent === '← Back')).toBe(false);
expect([...container.querySelectorAll('.system-scope-actions button')].map(item => item.textContent)).toEqual(['Whole system']);
});
it('filters whole paths without drawing disconnected remnants and restores the path without refetching', async () => {
const loader = vi.fn<SystemArchitectureLoader>().mockImplementation(request => Promise.resolve(response(request.targetNodeId === 3 ? corridorFixture() : overviewFixture())));
@@ -8,8 +8,7 @@ import { connectionLoad } from '../graph/connection-load';
import { AtlasApi } from '../app/atlas-api';
import type { FlowSummary } from '../traces/trace-schemas';
import BehaviorJourney from './BehaviorJourney';
import type { BehaviorPlace, PlaceChange, SharedBack, SystemPlace } from './architecture-history';
import { architectureHistoryText as historyText } from './strings';
import type { BehaviorPlace, JourneyPlace, PlaceChange, SystemPlace } from './architecture-history';
import { useLiftedPlace, useOnIdentityChange } from './lifted-place';
import './system-architecture.css';
@@ -21,11 +20,9 @@ export interface SystemArchitectureProps {
onNavigate: (path: string, line?: number, name?: string) => void;
loader?: SystemArchitectureLoader;
flowsLoader?: FlowsLoader;
/** Focus, expanded groups and the Behavior start, lifted to the workspace for Back and Forward (K27). */
/** Focus, expanded groups, the Behavior start and position, and both cameras, lifted to the workspace for Back and Forward (K27). */
place?: SystemPlace;
onPlace?: PlaceChange<SystemPlace>;
/** The shared Architecture Back; without it the view offers no Back of its own. */
back?: SharedBack;
}
/** Ranked call-graph flows (route handlers, call-graph roots) from /api/flows. */
export type FlowsLoader = (project: string) => Promise<FlowSummary[]>;
@@ -73,7 +70,7 @@ const connectionAllowed = (type: string, view: ConnectionView) => view === 'all'
|| (view === 'calls' ? ['CALLS', 'IMPORTS', 'HTTP_CALLS', 'ASYNC_CALLS'] : ['INHERITS', 'IMPLEMENTS']).includes(type);
/** Poll only while this view is active. A new request key hides stale results before its effect runs. */
export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, place: liftedPlace, onPlace, back }: SystemArchitectureProps) {
export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, place: liftedPlace, onPlace }: SystemArchitectureProps) {
const [place, changePlace] = useLiftedPlace<SystemPlace>(liftedPlace, onPlace, () => ({ expanded: [] }));
const entryChoice = place.behavior;
/** A start the page picks itself (the suggestion, a reset) completes the current step instead of adding one. */
@@ -93,7 +90,7 @@ export default function SystemArchitecture({ project, generation, view, filter,
const [selection, setSelection] = useState<{ node?: string; edge?: string; dependencyIndex?: number }>();
const [listLimit, setListLimit] = useState(20);
const [resetKey, setResetKey] = useState(0);
const [planar, setPlanar] = useState(false);
const planar = place.structurePlanar ?? false;
const [stepIndex, setStepIndex] = useState(0);
const [showConnectionLoad, setShowConnectionLoad] = useState(true);
const [flows, setFlows] = useState<{ project: string; generation?: string; entries: SystemSymbol[] }>();
@@ -309,10 +306,20 @@ export default function SystemArchitecture({ project, generation, view, filter,
limitations: { analysis: data?.limits, warnings: data?.warnings, omittedNodes: model.omittedNodes, omittedEdges: model.omittedEdges,
interpretation: 'Groups are inferred from indexed interactions and source organization. They do not establish deployment boundaries or runtime behavior. Witnesses may be samples.' },
} : undefined, active && view === 'structure');
/*
* Where the journey stands belongs to the start it was walked from, so a new start begins at the top and Back
* returns to the operation it showed. Plan or 3D belongs to Behavior as a whole and stays across starts.
*/
const currentChoice = entryChoice?.project === project && entryChoice.generation === generation ? entryChoice : undefined;
const journeyPlace: JourneyPlace = { ...currentChoice?.position, planar: place.behaviorPlanar ?? false };
const changeJourney: PlaceChange<JourneyPlace> = ({ planar: behaviorPlanar, ...position }, automatic) => changePlace({
...(behaviorPlanar === undefined ? {} : { behaviorPlanar }),
...(Object.keys(position).length ? { behavior: { ...(currentChoice ?? { project, generation }), position: { ...currentChoice?.position, ...position } } } : {}),
}, automatic);
const behaviorPage = view === 'behavior' ? <BehaviorJourney project={project} generation={projectionGeneration} data={queryData}
entries={entries} targets={targets} entryId={requestedEntry} targetId={requestedTarget} active={active} pending={queryPending}
error={error} filter={filter} onRefresh={() => setRevision(value => value + 1)} onNavigate={onNavigate} onSelectSymbol={selectSymbol} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence}
from={requestedEntry !== undefined ? entryChoice?.from : undefined} back={back}
from={requestedEntry !== undefined ? entryChoice?.from : undefined} place={journeyPlace} onPlace={changeJourney}
onShownStart={name => { if (name !== place.shown) changePlace({ shown: name }, true); }}
onRequest={(entry, targetId, detail) => {
const analysisGeneration = projectionGeneration ?? sourceGeneration;
@@ -327,8 +334,7 @@ export default function SystemArchitecture({ project, generation, view, filter,
<p>{view === 'structure' ? 'Inspect the parts and the code that connects them.' : 'Follow a question through the same system map.'}</p>
<button className="atlas-arch-action system-refresh" onClick={() => { setRevision(value => value + 1); setResetKey(value => value + 1); }}>Refresh analysis</button>
</div><div className="system-controls">
<div className="system-scope-actions">{back && <button disabled={!back.target} title={back.target ? historyText.backTo(back.target) : historyText.noBack} onClick={back.onBack}>{historyText.inViewBack}</button>}
<button disabled={!focusId} onClick={clearSelection}>Whole system</button></div>
<div className="system-scope-actions"><button disabled={!focusId} onClick={clearSelection}>Whole system</button></div>
{view === 'structure' && <label>Connections <select aria-label="Connection view" value={connectionView} onChange={event => { setConnectionView(event.target.value as ConnectionView); setCyclesOnly(false); }}>
<option value="calls">Calls &amp; imports</option><option value="types">Type relationships</option><option value="all">All relationships</option>
</select></label>}
@@ -360,7 +366,7 @@ export default function SystemArchitecture({ project, generation, view, filter,
<div className="system-display-controls">
<span className="system-layout-hint" title="Directory ancestry determines placement. Connections still come from the indexed graph. Representatives provide location hints when no directory group is reported.">{data.overview || view === 'structure' ? 'Grouped by source location' : 'Ordered by call paths'}</span>
<label title="Ring area follows log(1 + visible links), relative to the busiest item in this view. Each directed relationship type counts once; this is visual load, not runtime activity."><input type="checkbox" checked={showConnectionLoad} onChange={event => setShowConnectionLoad(event.target.checked)} />Connection load</label>
<div className="system-view-switch" role="group" aria-label="System camera"><button aria-pressed={!planar} onClick={() => setPlanar(false)}>3D</button><button aria-pressed={planar} onClick={() => setPlanar(true)}>Plan</button></div>
<div className="system-view-switch" role="group" aria-label="System camera"><button aria-pressed={!planar} onClick={() => changePlace({ structurePlanar: false })}>3D</button><button aria-pressed={planar} onClick={() => changePlace({ structurePlanar: true })}>Plan</button></div>
</div>
<div className="system-workspace"><div className="system-map">
{/* The reason for an empty projection outranks any filter that would also find nothing. */}
@@ -87,6 +87,24 @@ describe('the Architecture history entry (K27)', () => {
expect(architectureEntryDetail(start)).toBe('');
});
it('holds Plan or 3D in System structure and Behavior, and where a call chain stands (K27)', () => {
const structure = at({ view: 'structure' });
// Plan or 3D is a step in every view that draws a scene, each with its own camera as before.
expect(key({ ...structure, system: { expanded: [], structurePlanar: true } })).not.toBe(key(structure));
expect(key({ ...structure, system: { expanded: [], behaviorPlanar: true } })).toBe(key(structure));
const reach = behavior(1, 'main', { targetId: 3, targetName: 'save' });
expect(key({ ...reach, system: { ...reach.system, behaviorPlanar: true } })).not.toBe(key(reach));
expect(key({ ...reach, system: { ...reach.system, structurePlanar: true } })).toBe(key(reach));
// Another indexed path to the destination is a step; the operation on it and the page of direct calls are not.
const second = { ...reach, system: { ...reach.system, behavior: { ...reach.system.behavior!, position: { path: 1 } } } };
expect(key(second)).not.toBe(key(reach));
expect(key({ ...reach, system: { ...reach.system, behavior: { ...reach.system.behavior!, position: { path: 0, step: 2, page: 1 } } } })).toBe(key(reach));
expect(recentKey(second)).toBe(recentKey(reach));
expect(architectureEntryLabel({ ...structure, system: { expanded: [], structurePlanar: true } })).toBe('System structure · Plan');
expect(architectureEntryLabel({ ...second, system: { ...second.system, behaviorPlanar: true } })).toBe('Behavior · main → save · Path 2 · Plan');
expect(architectureEntryLabel({ ...reach, system: { ...reach.system, behavior: { ...reach.system.behavior!, position: { step: 2 } } } })).toBe('Behavior · main → save');
});
it('keeps one recent place per subtab location, whatever its Plan or expanded groups', () => {
expect(recentKey(at({ spatial: { planar: true } }))).toBe(recentKey(start));
expect(recentKey(at({ spatial: { planar: false, areaPath: 'django' } }))).not.toBe(recentKey(start));
@@ -3,10 +3,11 @@
* workspace shows, without what it loaded for it.
*
* One entry is the subtab and what that subtab has opened: in Overview the area
* or file and Plan or 3D, in Routes the perspective and the route filter (an
* opened route group is that filter), in System structure the focus and the
* expanded groups, in Behavior the start, the destination and where a followed
* call began. It holds identities and names only, never projections, scenes or
* or file, in Routes the perspective and the route filter (an opened route
* group is that filter), in System structure the focus and the expanded groups,
* in Behavior the start, the destination, where a followed call began and where
* the journey stands; and in every view that draws a scene, Plan or 3D. It holds
* identities, names and small numbers only, never projections, scenes or
* cameras, so nothing grows with the session. The model behind it is the one
* Galaxy uses (src/graph/navigation-history.ts); the concept is in
* docs/development/pr-2068-galaxy-history.md.
@@ -21,24 +22,41 @@ export type RoutesPerspective = 'services' | 'endpoints';
/** Overview, Entry points, Hotspots and Endpoints draw the same map (SpatialArchitecture). */
export interface SpatialPlace { areaPath?: string; filePath?: string; hotspotArea?: string; planar: boolean }
/**
* Where a Behavior journey stands: the indexed path to a destination (`path`),
* the operation on that call chain (`step`) and the page of direct calls
* (`page`), all counted from 0. Another path is a step; walking the chain or
* paging is not (Previous and Next do that), but the entry keeps where it
* stood, so Back and Forward return there.
*/
export interface JourneyPosition { path?: number; step?: number; page?: number }
/**
* A Behavior start as SystemArchitecture requests it. The numeric identity
* belongs to one analysis snapshot, which `generation` and `expectedGeneration`
* guard; the names are for the tooltip.
* guard; the names are for the tooltip. A new start begins at the top of its
* journey, because it carries no position.
*/
export interface BehaviorPlace {
project: string; generation?: string; expectedGeneration?: string;
id?: number; name?: string; targetId?: number; targetName?: string;
/** The start a followed call ("follow calls", double-click) began from; empty background returns there. */
from?: SystemSymbol;
position?: JourneyPosition;
}
export interface SystemPlace {
focus?: { id: string; label: string }; expanded: readonly string[]; behavior?: BehaviorPlace;
/** The start Behavior shows when none was requested (the server picks one, or the first entry point): for the tooltip only. */
shown?: string;
/** Plan or 3D. System structure and Behavior each keep their own camera, as they did before K27. */
structurePlanar?: boolean;
behaviorPlanar?: boolean;
}
/** What BehaviorJourney reads and reports: where it stands, and Plan or 3D. */
export interface JourneyPlace extends JourneyPosition { planar?: boolean }
export interface ArchitectureHistoryEntry {
view: ArchitectureView;
spatial: SpatialPlace;
@@ -51,14 +69,11 @@ export interface ArchitectureHistoryEntry {
/** A part of the place handed to a view, and how it reports a change; `automatic` marks one the page made itself. */
export type PlaceChange<T> = (change: Partial<T>, automatic?: boolean) => void;
/** What System structure and Behavior need for the shared Back in their own toolbar. */
export interface SharedBack { target?: string; onBack: () => void }
export function initialArchitecturePlace(view: ArchitectureView): ArchitectureHistoryEntry {
return { view: view === 'dependencies' ? 'overview' : view, spatial: { planar: false }, routes: 'services', filter: '', system: { expanded: [] } };
}
/** What the subtab of an entry shows, without Plan or 3D and without expanded groups. */
/** What the subtab of an entry shows, without Plan or 3D, expanded groups or the path to a destination. */
function location(entry: ArchitectureHistoryEntry): unknown[] {
const { spatial, system } = entry;
switch (entry.view) {
@@ -71,16 +86,28 @@ function location(entry: ArchitectureHistoryEntry): unknown[] {
}
}
/** Whether the subtab of an entry draws the shared map with its Plan and 3D switch. */
function drawsMap(entry: ArchitectureHistoryEntry): boolean {
return entry.view !== 'structure' && entry.view !== 'behavior' && (entry.view !== 'routes' || entry.routes === 'endpoints');
/**
* Whether the subtab of an entry shows Plan: the shared map (Overview, Entry
* points, Hotspots, Endpoints), System structure and Behavior each have a
* switch of their own; the Service map has none.
*/
function planar(entry: ArchitectureHistoryEntry): boolean {
switch (entry.view) {
case 'structure': return entry.system.structurePlanar ?? false;
case 'behavior': return entry.system.behaviorPlanar ?? false;
case 'routes': return entry.routes === 'endpoints' && entry.spatial.planar;
default: return entry.spatial.planar;
}
}
/** The indexed path a Behavior journey follows to its destination, counted from 0. */
const behaviorPath = (entry: ArchitectureHistoryEntry): number => (entry.view === 'behavior' ? entry.system.behavior?.position?.path ?? 0 : 0);
const viewOf = (entry: ArchitectureHistoryEntry): ArchitectureView => (entry.view === 'dependencies' ? 'overview' : entry.view);
export const architectureHistoryOptions: NavigationHistoryOptions<ArchitectureHistoryEntry> = {
key: (entry) => JSON.stringify([viewOf(entry), ...location(entry), drawsMap(entry) && entry.spatial.planar,
entry.view === 'structure' ? [...entry.system.expanded].sort() : null]),
key: (entry) => JSON.stringify([viewOf(entry), ...location(entry), planar(entry),
entry.view === 'structure' ? [...entry.system.expanded].sort() : null, behaviorPath(entry)]),
recentKey: (entry) => JSON.stringify([viewOf(entry), ...location(entry)]),
};
@@ -101,11 +128,12 @@ function details(entry: ArchitectureHistoryEntry): string[] {
case 'behavior': {
const start = system.behavior?.id === undefined ? system.behavior?.name ?? system.shown : system.behavior.name;
const target = system.behavior?.targetId === undefined ? undefined : system.behavior.targetName;
parts.push(start && target ? text.reach(start, target) : start, system.behavior?.from ? text.followedFrom(system.behavior.from.name) : undefined);
parts.push(start && target ? text.reach(start, target) : start, system.behavior?.from ? text.followedFrom(system.behavior.from.name) : undefined,
behaviorPath(entry) > 0 ? text.path(behaviorPath(entry) + 1) : undefined);
break;
}
}
if (drawsMap(entry) && spatial.planar) parts.push(text.plan);
if (planar(entry)) parts.push(text.plan);
return parts.filter((part): part is string => Boolean(part));
}
+2 -2
View File
@@ -116,8 +116,6 @@ export const architectureHistoryText = {
forwardTo: (label: string) => `Forward to ${label} (Alt+Right)`,
noBack: 'Nothing to go back to yet',
noForward: 'Nothing to go forward to',
/** System structure and Behavior keep their own Back button; it takes the same step as the one above. */
inViewBack: '← Back',
recent: 'Recent',
recentGlyph: '▾',
recentTitle: 'Jump straight to a recently visited place in Architecture, as it was last seen',
@@ -126,5 +124,7 @@ export const architectureHistoryText = {
plan: 'Plan',
groupsOpen: (count: number) => (count === 1 ? '1 group open' : `${count} groups open`),
reach: (start: string, target: string) => `${start} → ${target}`,
/** Another indexed path to a Behavior destination, named as its button: "Path 2". */
path: (number: number) => `Path ${number}`,
followedFrom: (name: string) => `followed from ${name}`,
};
@@ -6,8 +6,9 @@
* Three refinements Architecture needs and Galaxy does not:
*
* - Typing in the Routes filter becomes one step once it pauses
* (`FILTER_STEP_MS`), not one step per key. A navigation while typing first
* records what the field showed.
* (`FILTER_STEP_MS`), not one step per key. A navigation, Back, Forward or
* a recent jump while typing first records what the field showed, so no
* typed text is lost.
* - A change the page makes itself (the suggested Behavior start, a reset
* after reindexing) replaces the current step instead of adding one, so Back
* never lands on a state the page would immediately fill in again.
@@ -118,14 +119,34 @@ export function useArchitectureHistory(initial: () => ArchitectureHistoryEntry,
automaticStep.current = undefined;
setPlace(entry);
}, [clearDraft]);
/** Typing that has not paused yet, as the step it is about to become; undefined while nothing is typed. */
const typedStep = useCallback((): ArchitectureHistoryEntry | undefined =>
(typed.current === undefined ? undefined : { ...latest.current, filter: typed.current }), []);
/** The history with that typing as its newest step, the way the pause would record it. */
const withTyping = useCallback((): NavigationHistory<ArchitectureHistoryEntry> => {
const entry = typedStep();
return entry === undefined ? history : pushNavigation(history, entry, options);
}, [history, typedStep]);
const go = useCallback((step: -1 | 1) => {
const entry = peekNavigation(history, step);
if (entry === undefined) return;
const typedEntry = typedStep();
const base = withTyping();
const entry = peekNavigation(base, step);
if (entry === undefined) {
// Forward while typing: the text is a new step and drops the forward branch, as after the pause.
if (typedEntry === undefined) return;
clearDraft();
setHistory(base);
setPlace(typedEntry);
return;
}
restoringKey.current = options.key(entry);
setHistory((current) => moveNavigation(current, step, options));
setHistory(moveNavigation(base, step, options));
apply(entry);
}, [history, apply]);
const jump = useCallback((entry: ArchitectureHistoryEntry) => apply(entry), [apply]);
}, [typedStep, withTyping, apply, clearDraft]);
const jump = useCallback((entry: ArchitectureHistoryEntry) => {
setHistory(withTyping());
apply(entry);
}, [withTyping, apply]);
useEffect(() => {
if (!active || escapeTaken) return;
@@ -134,13 +155,13 @@ export function useArchitectureHistory(initial: () => ArchitectureHistoryEntry,
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
if (isTypingTarget(event.target instanceof Element ? event.target : null)) return;
const step = event.key === 'ArrowLeft' ? -1 : 1;
if (peekNavigation(history, step) === undefined) return;
if (peekNavigation(withTyping(), step) === undefined) return;
event.preventDefault();
go(step);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, escapeTaken, history, go]);
}, [active, escapeTaken, withTyping, go]);
return { place, filter: draft ?? place.filter, history, back: peekNavigation(history, -1), forward: peekNavigation(history, 1), navigate, type, go, jump };
}
@@ -85,6 +85,23 @@ describe('bounded back and forward history', () => {
expect(keys(replaceNavigation(emptyNavigationHistory<Entry>(), { root: 'A', depth: 1 }, options))).toEqual(['A@1']);
});
it('merges a replaced step with the step after it too, so Forward never leads to the same place (K27)', () => {
// After Back, the page resets the place it shows (a reindex), and the reset equals the step ahead.
let history = push(emptyNavigationHistory<Entry>(), { root: 'A', depth: 1 }, { root: 'B', depth: 2 }, { root: 'B', depth: 1 });
history = moveNavigation(history, -1, options);
history = replaceNavigation(history, { root: 'B', depth: 1 }, options);
expect(keys(history)).toEqual(['A@1', 'B@1']);
expect(history.index).toBe(1);
expect(peekNavigation(history, 1)).toBeUndefined();
// Equal to the steps on both sides: all three become one.
let between = push(emptyNavigationHistory<Entry>(), { root: 'A', depth: 1 }, { root: 'B', depth: 1 }, { root: 'A', depth: 1 }, { root: 'C', depth: 1 });
between = moveNavigation(moveNavigation(between, -1, options), -1, options);
between = replaceNavigation(between, { root: 'A', depth: 1 }, options);
expect(keys(between)).toEqual(['A@1', 'C@1']);
expect(between.index).toBe(0);
for (let at = 1; at < between.entries.length; at++) expect(options.key(between.entries[at]!)).not.toBe(options.key(between.entries[at - 1]!));
});
it('refreshes the current step with newer details of the same place, without a step or reordering (K27)', () => {
interface Named extends Entry { name?: string }
const named: NavigationHistoryOptions<Named> = options;
+11 -10
View File
@@ -69,24 +69,25 @@ export function pushNavigation<T>(history: NavigationHistory<T>, entry: T, optio
* ihn sofort wieder ein, und Zurueck kaeme nie darueber hinaus.
*
* Der Vorwaertszweig bleibt. Der ersetzte Ort verlaesst die Liste der letzten
* Orte, wenn er dort vorn steht. Gleicht der neue Eintrag dem davor, fallen
* beide zusammen, wie bei `pushNavigation`. Ohne aktuellen Eintrag ist es ein
* gewoehnlicher erster Schritt.
* Orte, wenn er dort vorn steht. Gleicht der neue Eintrag dem davor oder dem
* danach, fallen sie zusammen, wie bei `pushNavigation`: sonst stuenden zwei
* gleiche Schritte nebeneinander, und Vor oder Zurueck fuehrte an denselben
* Ort. Ohne aktuellen Eintrag ist es ein gewoehnlicher erster Schritt.
*/
export function replaceNavigation<T>(history: NavigationHistory<T>, entry: T, options: NavigationHistoryOptions<T>): NavigationHistory<T> {
const current = history.entries[history.index];
if (current === undefined) return pushNavigation(history, entry, options);
if (options.key(current) === options.key(entry)) return history;
const key = options.key(entry);
if (options.key(current) === key) return history;
const replaced = options.recentKey?.(current);
const front = history.recent[0];
const recent = replaced !== undefined && front !== undefined && options.recentKey?.(front) === replaced ? history.recent.slice(1) : history.recent;
const same = (other: T | undefined): other is T => other !== undefined && options.key(other) === key;
const before = history.entries.slice(0, history.index);
const after = history.entries.slice(history.index + (same(history.entries[history.index + 1]) ? 2 : 1));
const previous = history.entries[history.index - 1];
if (previous !== undefined && options.key(previous) === options.key(entry)) {
const entries = [...history.entries.slice(0, history.index), ...history.entries.slice(history.index + 1)];
return { entries, index: history.index - 1, recent: visit(recent, previous, options) };
}
const entries = [...history.entries.slice(0, history.index), entry, ...history.entries.slice(history.index + 1)];
return { entries, index: history.index, recent: visit(recent, entry, options) };
if (same(previous)) return { entries: [...before, ...after], index: history.index - 1, recent: visit(recent, previous, options) };
return { entries: [...before, entry, ...after], index: history.index, recent: visit(recent, entry, options) };
}
/**