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) }; } /**