From 8e6edc81f6d26d7c8f2181d39860be08a70c8d63 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sat, 3 Oct 2026 23:02:53 +0200 Subject: [PATCH] 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 --- docs/development/pr-2068-galaxy-history.md | 55 ++++-- .../ArchitecturePanel.history.test.tsx | 157 ++++++++++++++++-- .../src/architecture/ArchitecturePanel.tsx | 16 +- .../src/architecture/BehaviorJourney.test.tsx | 38 +++-- graph-ui/src/architecture/BehaviorJourney.tsx | 53 +++--- .../src/architecture/SpatialArchitecture.tsx | 13 +- .../architecture/SystemArchitecture.test.tsx | 20 +-- .../src/architecture/SystemArchitecture.tsx | 28 ++-- .../architecture/architecture-history.test.ts | 18 ++ .../src/architecture/architecture-history.ts | 60 +++++-- graph-ui/src/architecture/strings.ts | 4 +- .../architecture/use-architecture-history.ts | 39 ++++- graph-ui/src/graph/navigation-history.test.ts | 17 ++ graph-ui/src/graph/navigation-history.ts | 21 +-- 14 files changed, 403 insertions(+), 136 deletions(-) diff --git a/docs/development/pr-2068-galaxy-history.md b/docs/development/pr-2068-galaxy-history.md index 7d8cb00f7..dd4829cf1 100644 --- a/docs/development/pr-2068-galaxy-history.md +++ b/docs/development/pr-2068-galaxy-history.md @@ -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. diff --git a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx index a705eb34d..4791f2c4c 100644 --- a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx +++ b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx @@ -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: () =>
})); 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; -}) =>
+vi.mock('./SystemArchitectureScene', () => ({ default: ({ model, onSelectNode, onExpandNode, onClearSelection, planar }: { + model: SystemSceneModel; onSelectNode: (id: string) => void; onExpandNode?: (id: string) => void; onClearSelection?: () => void; planar?: boolean; +}) =>
{model.nodes.map(node => )}
})); 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('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('[aria-label="Indexed paths"] button')]; +const chosenPath = () => pathButtons().findIndex(button => button.getAttribute('aria-pressed') === 'true') + 1; +const lastEvidence = (listener: ReturnType) => 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('[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('[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('[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('[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('[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('[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('[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('[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('[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('details.atlas-arch-recent')!; + const overviewPlace = [...recent.querySelectorAll('[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('[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('[data-view="hotspots"]')); + expect(lastEvidence(onSelectionEvidence)).toBeUndefined(); + await press(container.querySelector('[data-view="overview"]')); + expect(lastEvidence(onSelectionEvidence)?.label).toBe('django'); + await press(container.querySelector('[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(`[data-view="${tab}"]`)); diff --git a/graph-ui/src/architecture/ArchitecturePanel.tsx b/graph-ui/src/architecture/ArchitecturePanel.tsx index 5c90b625a..f91103022 100644 --- a/graph-ui/src/architecture/ArchitecturePanel.tsx +++ b/graph-ui/src/architecture/ArchitecturePanel.tsx @@ -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 ? Preparing system analysis…
}> : null} + place={place.system} onPlace={changeSystem} /> : null} {!systemView && (!projectName ? {text.chooseProject} : loading ?
{text.loading}
: error ?

{text.loadFailed}

{error}

{onRefresh && }
diff --git a/graph-ui/src/architecture/BehaviorJourney.test.tsx b/graph-ui/src/architecture/BehaviorJourney.test.tsx index 4e8c905db..79bfc4b31 100644 --- a/graph-ui/src/architecture/BehaviorJourney.test.tsx +++ b/graph-ui/src/architecture/BehaviorJourney.test.tsx @@ -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('.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('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(); diff --git a/graph-ui/src/architecture/BehaviorJourney.tsx b/graph-ui/src/architecture/BehaviorJourney.tsx index 2bb87061b..2437c444b 100644 --- a/graph-ui/src/architecture/BehaviorJourney.tsx +++ b/graph-ui/src/architecture/BehaviorJourney.tsx @@ -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; /** 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(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(null); const [pathWindow, setPathWindow] = useState(5); const [sourceEvidence, setSourceEvidence] = useState(); - 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 {targetOptions.map(item => )}
-
{back && } - {targetId !== undefined && } +
{targetId !== undefined && } {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)} Static evidence
{error &&

{error}

} {pending ?
Preparing the selected operation…
: !scene || !journey ?
Choose an operation to explore.
: <> - {journey.paths.length > 1 &&
{journey.paths.map((item, index) => )}
}
{path ? 'CALL CHAIN →' : 'DIRECT CALLS · UNORDERED'} -
+
{scene.nodes.length ? Preparing the journey…
}> selectStep(Number(event.target.value))} /> {activeStep + 1} / {path.nodes.length}} - {!path && journey.scene.nodes.length > 5 && }
-
{back && } -
+
{view === 'structure' && } @@ -360,7 +366,7 @@ export default function SystemArchitecture({ project, generation, view, filter,
{data.overview || view === 'structure' ? 'Grouped by source location' : 'Ordered by call paths'} -
+
{/* The reason for an empty projection outranks any filter that would also find nothing. */} diff --git a/graph-ui/src/architecture/architecture-history.test.ts b/graph-ui/src/architecture/architecture-history.test.ts index ec8009f13..1610174cc 100644 --- a/graph-ui/src/architecture/architecture-history.test.ts +++ b/graph-ui/src/architecture/architecture-history.test.ts @@ -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)); diff --git a/graph-ui/src/architecture/architecture-history.ts b/graph-ui/src/architecture/architecture-history.ts index 0d7122190..04614f164 100644 --- a/graph-ui/src/architecture/architecture-history.ts +++ b/graph-ui/src/architecture/architecture-history.ts @@ -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 = (change: Partial, 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 = { - 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)); } diff --git a/graph-ui/src/architecture/strings.ts b/graph-ui/src/architecture/strings.ts index 65035afec..7de342626 100644 --- a/graph-ui/src/architecture/strings.ts +++ b/graph-ui/src/architecture/strings.ts @@ -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}`, }; diff --git a/graph-ui/src/architecture/use-architecture-history.ts b/graph-ui/src/architecture/use-architecture-history.ts index a2f3b4815..ed8f42fe2 100644 --- a/graph-ui/src/architecture/use-architecture-history.ts +++ b/graph-ui/src/architecture/use-architecture-history.ts @@ -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 => { + 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 }; } diff --git a/graph-ui/src/graph/navigation-history.test.ts b/graph-ui/src/graph/navigation-history.test.ts index eb7919018..9ced2b08b 100644 --- a/graph-ui/src/graph/navigation-history.test.ts +++ b/graph-ui/src/graph/navigation-history.test.ts @@ -85,6 +85,23 @@ describe('bounded back and forward history', () => { expect(keys(replaceNavigation(emptyNavigationHistory(), { 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(), { 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(), { 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 = options; diff --git a/graph-ui/src/graph/navigation-history.ts b/graph-ui/src/graph/navigation-history.ts index d33929ae4..57a93166b 100644 --- a/graph-ui/src/graph/navigation-history.ts +++ b/graph-ui/src/graph/navigation-history.ts @@ -69,24 +69,25 @@ export function pushNavigation(history: NavigationHistory, 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(history: NavigationHistory, entry: T, options: NavigationHistoryOptions): NavigationHistory { 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) }; } /**