mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 18:58:00 +00:00
Merge second hand test fixes: Architecture navigation, Recent menus, Refresh feedback, Behavior start list, workspace in the address (K40 to K44)
Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
commit
6002e391fe
26 files changed
+1056
-75
No files matched your search
@@ -311,7 +311,7 @@ import type { SettingsMeasurement } from './settings/SettingsPanel';
|
||||
import AddProjectIndexDialog from './projects/AddProjectIndexDialog';
|
||||
import ProjectSwitcher from './projects/ProjectSwitcher';
|
||||
import type { ProjectsSource } from './projects/ProjectsPanel';
|
||||
import { ProjectWindows, type ProjectWindow } from './app/project-windows';
|
||||
import { ProjectWindows, useParamInAddress, type ProjectWindow } from './app/project-windows';
|
||||
import { MODEL_SUGGESTIONS, fetchCommand } from './settings/model-catalog';
|
||||
import { modelKey, readModelPreference, recordModelPreference } from './settings/model-preference';
|
||||
import {
|
||||
@@ -758,10 +758,12 @@ interface WindowCarry {
|
||||
/**
|
||||
* Jedes Projekt bekommt ein frisches Fenster in derselben Seite (K24, Kopf von
|
||||
* src/app/project-windows.tsx). Der Agent reicht beim Wechsel sein geladenes
|
||||
* Modell an das Fenster des neuen Projekts weiter.
|
||||
* Modell an das Fenster des neuen Projekts weiter. Die Adresse behaelt dabei
|
||||
* den Arbeitsbereich und die Grenzen des Walks, nichts vom alten Projekt (A5).
|
||||
*/
|
||||
const PAGE_PARAMS = ['workspace', CLOSURE_DEPTH_PARAM, CLOSURE_CAP_PARAM] as const;
|
||||
export default function App(): JSX.Element {
|
||||
return <ProjectWindows<WindowCarry> around={switchKeepingAgent}>
|
||||
return <ProjectWindows<WindowCarry> around={switchKeepingAgent} keepParams={PAGE_PARAMS}>
|
||||
{shown => <ProjectWindowView key={shown.key} projectWindow={shown} />}
|
||||
</ProjectWindows>;
|
||||
}
|
||||
@@ -783,11 +785,10 @@ function ProjectWindowView({ projectWindow }: { projectWindow: ProjectWindow<Win
|
||||
setProactiveSelection(undefined); setChatGraphSelection(undefined); setChatAttachment(undefined);
|
||||
}
|
||||
setWorkspace(value);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('workspace', value);
|
||||
window.history.replaceState(window.history.state, '', url);
|
||||
try { localStorage.setItem('cbm.workspace', value); } catch { /* Session-only preference. */ }
|
||||
};
|
||||
// The address always names the workspace in view, also one carried over a project switch, so a reload keeps it (A5).
|
||||
useParamInAddress('workspace', workspace);
|
||||
const [welcomeOpen, setWelcomeOpen] = useState(() => {
|
||||
try { return localStorage.getItem('cbm.workspace.setup') !== 'done'; } catch { return false; }
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { act, useEffect } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ProjectWindows, type ProjectWindow } from './project-windows';
|
||||
import { ProjectWindows, useParamInAddress, type ProjectWindow } from './project-windows';
|
||||
|
||||
/*
|
||||
* K24: a project switch stays in the page. Each project gets a fresh window (fresh state in
|
||||
@@ -77,6 +77,34 @@ describe('an in-page project switch (K24)', () => {
|
||||
expect(mounted).toHaveLength(3);
|
||||
});
|
||||
|
||||
/* Hand test 2026-10-04 (A5): the switcher turned "?project=django-demo&workspace=galaxy" into "?project=cbm". */
|
||||
it('keeps the page-wide parameters it is given, the workspace among them, and nothing project specific', async () => {
|
||||
window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py');
|
||||
await act(async () => root.render(<ProjectWindows<Carry> keepParams={['workspace', 'codeatlasClosureDepth', 'codeatlasClosureCap']}>
|
||||
{shown => <Window key={shown.key} window={shown} />}</ProjectWindows>));
|
||||
await act(async () => current!.open('atlas sample'));
|
||||
expect(window.location.search).toBe('?project=atlas%20sample&workspace=galaxy&codeatlasClosureDepth=4');
|
||||
expect(shownProject()).toBe('atlas sample');
|
||||
// Back and Forward of the browser still switch between both projects, each with its own address.
|
||||
const popped = () => new Promise<void>(resolve => window.addEventListener('popstate', () => resolve(), { once: true }));
|
||||
await act(async () => { const done = popped(); window.history.back(); await done; });
|
||||
expect(window.location.search).toBe('?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py');
|
||||
expect(shownProject()).toBe('django-demo');
|
||||
await act(async () => { const done = popped(); window.history.forward(); await done; });
|
||||
expect(shownProject()).toBe('atlas sample');
|
||||
});
|
||||
|
||||
it('writes the workspace in view into the address without a new history entry', async () => {
|
||||
window.history.replaceState(null, '', '/?project=django-demo');
|
||||
const length = window.history.length;
|
||||
function Workspace({ name }: { name: string }) { useParamInAddress('workspace', name); return null; }
|
||||
await act(async () => root.render(<Workspace name="architecture" />));
|
||||
expect(window.location.search).toBe('?project=django-demo&workspace=architecture');
|
||||
await act(async () => root.render(<Workspace name="galaxy" />));
|
||||
expect(window.location.search).toBe('?project=django-demo&workspace=galaxy');
|
||||
expect(window.history.length).toBe(length);
|
||||
});
|
||||
|
||||
it('commits the switch inside the frame it is given, so the old window is gone and the new one there when it ends', async () => {
|
||||
const seen: string[] = [];
|
||||
const around = (commit: () => void) => { seen.push(`before:${mounted.join(',')}`); commit(); seen.push(`after:${mounted.join(',')}|${unmounted.join(',')}`); };
|
||||
|
||||
@@ -33,6 +33,30 @@ export function projectOfLocation(search = window.location.search): string {
|
||||
return new URLSearchParams(search).get('project') ?? '';
|
||||
}
|
||||
|
||||
/*
|
||||
* Handtest vom 2026-10-04 (A5): der Wechsel machte aus
|
||||
* "?project=django-demo&workspace=galaxy" die Adresse "?project=cbm", und ein
|
||||
* Neuladen danach konnte einen anderen Arbeitsbereich zeigen. Die Adresse des
|
||||
* neuen Projekts behaelt nun die Parameter der ganzen Seite, die der Aufrufer
|
||||
* nennt (den Arbeitsbereich, die Grenzen des Walks), und nichts, was zum alten
|
||||
* Projekt gehoert.
|
||||
*/
|
||||
export function projectSwitchHref(project: string, search: string, keep: readonly string[]): string {
|
||||
const from = new URLSearchParams(search);
|
||||
const kept = keep.flatMap(name => { const value = from.get(name); return value === null ? [] : [`&${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; });
|
||||
return `${projectHref(project)}${kept.join('')}`;
|
||||
}
|
||||
|
||||
/** Haelt einen Parameter der Adresse auf dem Wert, den die Seite zeigt, ohne neuen Eintrag im Verlauf (A5: der Arbeitsbereich). */
|
||||
export function useParamInAddress(name: string, value: string): void {
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get(name) === value) return;
|
||||
url.searchParams.set(name, value);
|
||||
window.history.replaceState(window.history.state, '', url);
|
||||
}, [name, value]);
|
||||
}
|
||||
|
||||
export interface ProjectWindow<C> {
|
||||
/** Wechselt mit jedem Projekt; als React-Schluessel des Fensters gedacht. */
|
||||
key: number;
|
||||
@@ -50,9 +74,11 @@ export interface ProjectWindow<C> {
|
||||
|
||||
interface Shown<C> { key: number; project: string; controller: AbortController; carried?: C }
|
||||
|
||||
export function ProjectWindows<C>({ children, around = commit => commit() }: {
|
||||
export function ProjectWindows<C>({ children, around = commit => commit(), keepParams = [] }: {
|
||||
children: (window: ProjectWindow<C>) => ReactNode;
|
||||
around?: (commit: () => void) => void;
|
||||
/** Parameter der ganzen Seite, die ein Wechsel in die neue Adresse mitnimmt (A5). */
|
||||
keepParams?: readonly string[];
|
||||
}): JSX.Element {
|
||||
const [shown, setShown] = useState<Shown<C>>(() => ({ key: 0, project: projectOfLocation(), controller: new AbortController() }));
|
||||
const current = useRef(shown); current.current = shown;
|
||||
@@ -65,9 +91,10 @@ export function ProjectWindows<C>({ children, around = commit => commit() }: {
|
||||
around(() => flushSync(() => setShown(next)));
|
||||
previous.controller.abort();
|
||||
}, [around]);
|
||||
const keep = useRef(keepParams); keep.current = keepParams;
|
||||
const open = useCallback((project: string) => {
|
||||
if (project === current.current.project) return;
|
||||
window.history.pushState(null, '', projectHref(project));
|
||||
window.history.pushState(null, '', projectSwitchHref(project, window.location.search, keep.current));
|
||||
show(project);
|
||||
}, [show]);
|
||||
const report = useCallback((value: C) => { carry.current = value; }, []);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ArchitecturePanel, { type ArchitecturePanelProps } from './ArchitecturePanel';
|
||||
import { fitToolbar } from '../galaxy/toolbar-fit';
|
||||
import type { ArchitectureSceneProps } from './ArchitectureScene';
|
||||
import type { ArchitectureOverviewDto } from '../core/intelligence-provider';
|
||||
import type { GraphData, GraphNode } from '../galaxy/types';
|
||||
@@ -126,8 +127,8 @@ const filter = () => container.querySelector<HTMLInputElement>('input[type="sear
|
||||
const focus = () => container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-focus');
|
||||
const heading = () => container.querySelector('.behavior-heading h2')?.textContent;
|
||||
const sceneLabels = () => [...container.querySelectorAll('[data-testid="scene"] [data-node]')].map(item => item.textContent);
|
||||
/** The "← Back" System structure and Behavior had in their own toolbars before K27. */
|
||||
const inViewBack = () => buttons().find(button => button.textContent === '← Back');
|
||||
/** The "← Back" System structure and Behavior had in their own toolbars before K27; the shared one carries the same words. */
|
||||
const inViewBack = () => buttons().find(button => button.textContent === '← Back' && !history()?.contains(button));
|
||||
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;
|
||||
@@ -495,3 +496,61 @@ describe('Architecture back and forward (K27)', () => {
|
||||
expect(container.querySelector('details.atlas-arch-recent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* Hand test 2026-10-04: Back, Forward and Recent stood as bare arrows at the right end of the subtab row,
|
||||
* while Galaxy has "← Back", "Forward →" and "▾" at the start of its toolbar (A1), and the Recent menu stayed
|
||||
* open over the content through several steps (A2).
|
||||
*/
|
||||
describe('Architecture history controls as in Galaxy (hand test 2026-10-04)', () => {
|
||||
const recentMenu = () => container.querySelector<HTMLDetailsElement>('details.atlas-arch-recent');
|
||||
async function withRecent(): Promise<HTMLDetailsElement> {
|
||||
await render();
|
||||
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
|
||||
await press(container.querySelector<HTMLButtonElement>('[data-view="hotspots"]'));
|
||||
const menu = recentMenu();
|
||||
expect(menu).not.toBeNull();
|
||||
await act(async () => { menu!.open = true; });
|
||||
return menu!;
|
||||
}
|
||||
|
||||
it('A1: puts Back, Forward and Recent at the start of the subtab row, worded as in Galaxy', async () => {
|
||||
await render();
|
||||
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
|
||||
const row = container.querySelector<HTMLElement>('.atlas-arch-tabrow')!;
|
||||
expect([...row.children].map(item => item.getAttribute('aria-label'))).toEqual(['Architecture history', 'Architecture views']);
|
||||
expect(back()?.textContent).toBe('← Back');
|
||||
expect(back()?.querySelector('.atlas-fit-narrow')?.getAttribute('data-label')).toBe('←');
|
||||
expect(forward()?.textContent).toBe('Forward →');
|
||||
expect(forward()?.querySelector('.atlas-fit-narrow')?.getAttribute('data-label')).toBe('→');
|
||||
expect(back()?.title).toBe('Back to Overview (Alt+Left)');
|
||||
expect(recentMenu()?.querySelector('summary')?.textContent).toBe('▾');
|
||||
// The row measures itself like Galaxy's toolbar: words while the tabs fit, the glyphs once they would scroll.
|
||||
expect(row.dataset.fit).toBe('full');
|
||||
const tabs = row.querySelector<HTMLElement>('nav')!;
|
||||
Object.defineProperties(tabs, { clientWidth: { configurable: true, value: 300 }, scrollWidth: { configurable: true, value: 520 } });
|
||||
expect(fitToolbar(row)).toBe('compact');
|
||||
});
|
||||
|
||||
it('A2: closes the Recent menu on a press elsewhere and on Escape, which stays with the menu', async () => {
|
||||
const menu = await withRecent();
|
||||
await act(async () => { container.querySelector('.atlas-arch-tabs')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); });
|
||||
expect(menu.open).toBe(false);
|
||||
await act(async () => { menu.open = true; });
|
||||
expect(key({ key: 'Escape' }, menu.querySelector('summary')!).defaultPrevented).toBe(true);
|
||||
expect(menu.open).toBe(false);
|
||||
expect(view()).toBe('hotspots');
|
||||
});
|
||||
|
||||
it('A2: closes the Recent menu when Back or Alt+Right change the place', async () => {
|
||||
const menu = await withRecent();
|
||||
await goBack();
|
||||
expect(view()).toBe('routes');
|
||||
expect(menu.open).toBe(false);
|
||||
await act(async () => { menu.open = true; });
|
||||
expect(key({ key: 'ArrowRight', altKey: true }).defaultPrevented).toBe(true);
|
||||
await settle();
|
||||
expect(view()).toBe('hotspots');
|
||||
expect(menu.open).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { JSX, ReactNode } from 'react';
|
||||
import type { ArchitectureHotspot, ArchitectureOverviewDto } from '../core/intelligence-provider';
|
||||
import {
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
import type { ArchitectureView, ConfigStorage } from './architecture-model';
|
||||
import { architectureHistoryText as historyText, architectureText as text } from './strings';
|
||||
import { peekNavigation, type NavigationHistory } from '../graph/navigation-history';
|
||||
import { useDismissibleMenu } from '../graph/use-dismissible-menu';
|
||||
import { FitLabel, useToolbarFit } from '../galaxy/toolbar-fit';
|
||||
import {
|
||||
architectureEntryDetail, architectureEntryLabel, architectureEntryName, architectureHistoryOptions, initialArchitecturePlace,
|
||||
type ArchitectureHistoryEntry, type PlaceChange, type RoutesPerspective, type SpatialPlace, type SystemPlace,
|
||||
@@ -125,6 +127,9 @@ const paletteStyle = scenePaletteStyle();
|
||||
* Back, Forward and the Recent list for the whole workspace (K27). The
|
||||
* tooltips name the target ("Back to Overview · django"); the list holds the
|
||||
* last distinct places, newest first, and the current one is not a target.
|
||||
* They stand at the start of the subtab row and read as in Galaxy, "← Back",
|
||||
* "Forward →" and "▾", with the glyphs alone once the tabs would scroll
|
||||
* (hand test 2026-10-04, A1). The menu closes as Galaxy's does (A2).
|
||||
*/
|
||||
function HistoryControls({ history, place, onGo, onJump }: {
|
||||
history: NavigationHistory<ArchitectureHistoryEntry>; place: ArchitectureHistoryEntry;
|
||||
@@ -132,23 +137,23 @@ function HistoryControls({ history, place, onGo, onJump }: {
|
||||
}): JSX.Element {
|
||||
const back = peekNavigation(history, -1), forward = peekNavigation(history, 1);
|
||||
const here = architectureHistoryOptions.recentKey?.(place);
|
||||
const menu = useDismissibleMenu(architectureHistoryOptions.key(place));
|
||||
return <div className="atlas-arch-history" role="group" aria-label={historyText.group} data-position={`${history.index + 1}/${history.entries.length}`}>
|
||||
<button type="button" aria-label={historyText.back} disabled={!back} onClick={() => onGo(-1)}
|
||||
title={back ? historyText.backTo(architectureEntryLabel(back)) : historyText.noBack}>{historyText.backGlyph}</button>
|
||||
title={back ? historyText.backTo(architectureEntryLabel(back)) : historyText.noBack}>
|
||||
<FitLabel wide={historyText.backWide} narrow={historyText.backGlyph} /></button>
|
||||
<button type="button" aria-label={historyText.forward} disabled={!forward} onClick={() => onGo(1)}
|
||||
title={forward ? historyText.forwardTo(architectureEntryLabel(forward)) : historyText.noForward}>{historyText.forwardGlyph}</button>
|
||||
{history.recent.length > 1 && <details className="atlas-arch-recent" onKeyDown={event => {
|
||||
if (event.key === 'Escape') { event.stopPropagation(); event.currentTarget.open = false; }
|
||||
}}>
|
||||
title={forward ? historyText.forwardTo(architectureEntryLabel(forward)) : historyText.noForward}>
|
||||
<FitLabel wide={historyText.forwardWide} narrow={historyText.forwardGlyph} /></button>
|
||||
{history.recent.length > 1 && <details className="atlas-arch-recent" ref={menu.ref}>
|
||||
<summary title={historyText.recentTitle} aria-label={historyText.recent}>{historyText.recentGlyph}</summary>
|
||||
<ul className="atlas-arch-recent-menu" aria-label={historyText.recentList}>{history.recent.map(entry => {
|
||||
const id = architectureHistoryOptions.recentKey?.(entry);
|
||||
const current = id === here;
|
||||
const detail = architectureEntryDetail(entry);
|
||||
return <li key={id}>
|
||||
<button type="button" disabled={current} aria-current={current ? 'true' : undefined} title={architectureEntryLabel(entry)} onClick={event => {
|
||||
const details = event.currentTarget.closest('details');
|
||||
if (details) details.open = false;
|
||||
<button type="button" disabled={current} aria-current={current ? 'true' : undefined} title={architectureEntryLabel(entry)} onClick={() => {
|
||||
menu.close();
|
||||
onJump(entry);
|
||||
}}><strong>{architectureEntryName(entry)}</strong>{detail && <span>{detail}</span>}</button>
|
||||
</li>;
|
||||
@@ -164,7 +169,7 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
|
||||
* 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
|
||||
* restore every part at once; the one Back for all of them sits before 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.
|
||||
*/
|
||||
@@ -172,6 +177,9 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
|
||||
active, escapeTaken, onRestore: (from, to) => { if (from.view !== to.view) onSelectionEvidence?.(undefined); },
|
||||
});
|
||||
const { place, navigate } = navigation;
|
||||
// The row measures itself as Galaxy's toolbar does (toolbar-fit.tsx): it counts as narrow once the tabs would scroll.
|
||||
const tabRow = useRef<HTMLDivElement>(null);
|
||||
useToolbarFit(tabRow, true);
|
||||
useEffect(() => { saveArchitectureConfig(storage, projectName, { view: place.view, filter: place.filter }); }, [place.view, place.filter, projectName, storage]);
|
||||
const setView = useCallback((view: ArchitectureView) => navigate(current => ({ ...current, view: view === 'dependencies' ? 'overview' : view })), [navigate]);
|
||||
const changeSpatial = useCallback<PlaceChange<SpatialPlace>>((change, automatic) =>
|
||||
@@ -198,11 +206,11 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
|
||||
} : undefined;
|
||||
// Every scene below draws with the same palette; the DOM reads it as CSS variables.
|
||||
return <section className="atlas-architecture" data-testid="atlas-architecture" data-system-view={systemView} aria-label={text.title} aria-busy={!systemView && loading} style={paletteStyle}>
|
||||
<div className="atlas-arch-tabrow">
|
||||
<nav className="atlas-arch-tabs" aria-label={text.navigation}>{ARCHITECTURE_VIEWS.map(view =>
|
||||
<div className="atlas-arch-tabrow" ref={tabRow}>
|
||||
<HistoryControls history={navigation.history} place={place} onGo={navigation.go} onJump={navigation.jump} />
|
||||
<nav className="atlas-arch-tabs" aria-label={text.navigation} data-fit-whole="">{ARCHITECTURE_VIEWS.map(view =>
|
||||
<button className="atlas-arch-tab" key={view} aria-pressed={place.view === view || (view === 'overview' && ['dependencies', 'entryPoints'].includes(place.view))} data-view={view}
|
||||
onClick={() => { if (view !== place.view) onSelectionEvidence?.(undefined); setView(view); }}>{text.views[view]}</button>)}</nav>
|
||||
<HistoryControls history={navigation.history} place={place} onGo={navigation.go} onJump={navigation.jump} />
|
||||
</div>
|
||||
{systemView && projectName ? <Suspense fallback={<div role="status"><Empty>Preparing system analysis…</Empty></div>}><SystemArchitecture
|
||||
project={projectName} generation={graphGeneration} view={systemView} filter={filter} active={active}
|
||||
|
||||
@@ -182,3 +182,46 @@ describe('behavior journeys', () => {
|
||||
expect(compact.edges.map(edge => edge.pathEdge!.id)).toEqual([14, 15]);
|
||||
});
|
||||
});
|
||||
|
||||
/* Hand test 2026-10-04 (A4): the "Start" list had no recognizable order, and "handle · …/loaddata.py" could not be found. */
|
||||
describe('Behavior start field', () => {
|
||||
const LOADDATA = 'django/core/management/commands/loaddata.py';
|
||||
const entries = [symbol(1), { ...symbol(21), name: 'handle', file_path: 'django/core/management/commands/makemigrations.py' },
|
||||
{ ...symbol(22), name: 'handle', file_path: LOADDATA }, { ...symbol(23), name: 'as_sql', file_path: 'django/db/models/fields/json.py' }];
|
||||
const select = () => container.querySelector<HTMLSelectElement>('select[aria-label="Behavior entry point"]')!;
|
||||
const filterField = () => container.querySelector<HTMLInputElement>('input[aria-label="Filter operations"]')!;
|
||||
const group = (index: number) => [...select().querySelectorAll('optgroup')][index];
|
||||
const options = (index: number) => [...group(index).querySelectorAll('option')].map(option => option.textContent);
|
||||
async function type(value: string) {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(filterField(), value);
|
||||
filterField().dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it('A4: offers the suggestions first, then every operation alphabetically, behind a filter field', async () => {
|
||||
await render(fixture(), { entries, suggestedEntries: [1, 22] });
|
||||
expect(filterField().placeholder).toBe('Filter operations…');
|
||||
expect(filterField().compareDocumentPosition(select()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect([...select().querySelectorAll('optgroup')].map(item => item.label)).toEqual(['Suggested', 'All operations · 4']);
|
||||
expect(options(0)).toEqual(['operation1 · src/part1.ts', `handle · ${LOADDATA}`]);
|
||||
expect(options(1)).toEqual(['as_sql · django/db/models/fields/json.py', `handle · ${LOADDATA}`,
|
||||
'handle · django/core/management/commands/makemigrations.py', 'operation1 · src/part1.ts']);
|
||||
expect(select().value).toBe('1');
|
||||
expect(select().selectedOptions[0]?.textContent).toBe('operation1 · src/part1.ts');
|
||||
});
|
||||
|
||||
it('A4: narrows the list to what the filter names, keeps the chosen start, and starts the operation picked there', async () => {
|
||||
const props = await render(fixture(), { entries, suggestedEntries: [1, 21] });
|
||||
await type('loaddata');
|
||||
expect(group(1).label).toBe('Matching operations · 1 of 4');
|
||||
expect(options(1)).toEqual([`handle · ${LOADDATA}`, 'operation1 · src/part1.ts']);
|
||||
expect(options(0)).toEqual(['operation1 · src/part1.ts']);
|
||||
expect(select().value).toBe('1');
|
||||
await act(async () => { select().value = '22'; select().dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 22, file_path: LOADDATA }), undefined, {});
|
||||
await type('no such operation');
|
||||
expect(options(1)).toEqual(['operation1 · src/part1.ts', 'No operation matches "no such operation"']);
|
||||
expect(group(1).querySelector('option:last-child')?.hasAttribute('disabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,12 @@ 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';
|
||||
import { RefreshControl, type RefreshFeedback } from './refresh-feedback';
|
||||
import { operationChoices } from './operation-choices';
|
||||
import './behavior-journey.css';
|
||||
|
||||
const startText = text.behaviorStart;
|
||||
|
||||
const Scene = lazy(() => import('./SystemArchitectureScene'));
|
||||
export interface BehaviorJourneyProps {
|
||||
project: string; generation?: string; data?: SystemProjection; entries: SystemSymbol[];
|
||||
@@ -28,6 +32,10 @@ export interface BehaviorJourneyProps {
|
||||
onClearSelection?: () => void;
|
||||
onSelectionEvidence?: SelectionEvidenceListener;
|
||||
onRefresh: () => void; onSelectSymbol: (symbol: SystemSymbol) => void;
|
||||
/** What the last Refresh did, shown beside its button (hand test 2026-10-04, A3). */
|
||||
refresh?: RefreshFeedback;
|
||||
/** The automatic start and the ranked suggestions, in their order, offered first in "Start" (hand test 2026-10-04, A4). */
|
||||
suggestedEntries?: number[];
|
||||
onNavigate: (path: string, line?: number, name?: string) => void;
|
||||
}
|
||||
|
||||
@@ -53,7 +61,7 @@ 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, place: liftedPlace, onPlace, onShownStart }: BehaviorJourneyProps) {
|
||||
onRequest, onRefresh, refresh, suggestedEntries, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart }: BehaviorJourneyProps) {
|
||||
const [place, changePlace] = useLiftedPlace<JourneyPlace>(liftedPlace, onPlace, () => ({}));
|
||||
const { path: pathIndex = 0, step = 0, page: branchPage = 0, planar = false } = place;
|
||||
const [overview, setOverview] = useState(false);
|
||||
@@ -94,7 +102,10 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
|
||||
const targetOptions = [...new Map([...targets, ...(journey?.choices ?? []).map(item => ({ ...item, distance: item.distance ?? 1 }))]
|
||||
.filter(item => item.id !== (entryId ?? journey?.entry?.id)).map(item => [item.id, item])).values()];
|
||||
const entry = journey?.entry ?? entries.find(item => item.id === entryId);
|
||||
const availableEntries = entry && !entries.some(item => item.id === entry.id) ? [entry, ...entries] : entries;
|
||||
const availableEntries = useMemo(() => entry && !entries.some(item => item.id === entry.id) ? [entry, ...entries] : entries, [entry, entries]);
|
||||
const [startQuery, setStartQuery] = useState('');
|
||||
const startChoices = useMemo(() => operationChoices(availableEntries, { suggested: suggestedEntries, query: startQuery, keep: entry?.id }),
|
||||
[availableEntries, suggestedEntries, startQuery, entry?.id]);
|
||||
const reportShown = useRef(onShownStart);
|
||||
reportShown.current = onShownStart;
|
||||
const shownStart = entryId === undefined && !pending ? entry?.name : undefined;
|
||||
@@ -160,12 +171,22 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
|
||||
<header className="behavior-heading"><div><span className="system-eyebrow">Behavior · source-guided exploration</span>
|
||||
<h2>{targetId !== undefined ? `${entry?.name ?? 'Operation'} → ${selectedTarget?.name ?? 'destination'}` : `What can ${entry?.name ?? 'this operation'} call?`}</h2>
|
||||
<p>{path ? 'Follow one recorded call chain across the parts it touches.' : 'Choose a starting operation. Explore its calls, or follow a path to a destination.'}</p></div>
|
||||
<button onClick={onRefresh}>Refresh</button></header>
|
||||
<RefreshControl labels={text.refreshFeedback.behavior} feedback={refresh} onRefresh={onRefresh} /></header>
|
||||
<div className="behavior-requests">
|
||||
{/* The field names the operation the journey shows, also when the projection chose it. */}
|
||||
<label>Start <select aria-label="Behavior entry point" value={entry?.id ?? ''} onChange={event => request(availableEntries.find(item => item.id === Number(event.target.value)))}>
|
||||
<option value="">Choose an operation…</option>{availableEntries.map(item => <option key={item.id} value={item.id}>{item.name} · {item.file_path ?? item.qualified_name}</option>)}
|
||||
</select></label>
|
||||
{/*
|
||||
* The field names the operation the journey shows, also when the projection chose it. Suggestions come
|
||||
* first, then every operation alphabetically; the filter before it narrows both (hand test 2026-10-04, A4).
|
||||
*/}
|
||||
<label className="behavior-start">{startText.label}
|
||||
<input type="search" aria-label={startText.filter} placeholder={startText.filterPlaceholder} value={startQuery} onChange={event => setStartQuery(event.target.value)} />
|
||||
<select aria-label={startText.field} value={entry?.id ?? ''} onChange={event => request(availableEntries.find(item => item.id === Number(event.target.value)))}>
|
||||
<option value="">{startText.choose}</option>
|
||||
{startChoices.suggested.length > 0 && <optgroup label={startText.suggested}>{startChoices.suggested.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}</optgroup>}
|
||||
<optgroup label={startQuery.trim() ? startText.matching(startChoices.matching, startChoices.total) : startText.all(startChoices.total)}>
|
||||
{startChoices.all.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||
{startQuery.trim() && !startChoices.matching && <option disabled value="-1">{startText.none(startQuery.trim())}</option>}
|
||||
</optgroup>
|
||||
</select></label>
|
||||
<label>Reach <select aria-label="Behavior destination" value={targetId ?? ''} disabled={!entry || pending} onChange={event => request(entry, event.target.value ? Number(event.target.value) : undefined, from)}>
|
||||
<option value="">Explore immediate calls</option>{targetOptions.map(item => <option key={item.id} value={item.id}>{item.name} · {item.file_path ?? ''}</option>)}
|
||||
</select></label>
|
||||
|
||||
@@ -75,3 +75,41 @@ describe('service map interactions', () => {
|
||||
expect(loadContainerTopology).not.toHaveBeenCalled(); expect(loadContainerInventory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/* Hand test 2026-10-04 (A3): a Refresh that reads everything again says so, and says what it found. */
|
||||
describe('service map refresh feedback', () => {
|
||||
const refresh = () => [...container.querySelectorAll('button')].find(element => /^Refresh(ing…)?$/.test(element.textContent ?? ''))!;
|
||||
const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('A3: reads the declared services again, busy meanwhile, then names the time and whether anything changed', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 9, 4, 9, 5, 7));
|
||||
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} />));
|
||||
let finish!: (value: ContainerReading) => void;
|
||||
vi.mocked(loadContainerTopology).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
await act(async () => refresh().click());
|
||||
expect(refresh().textContent).toBe('Refreshing…');
|
||||
expect(refresh().getAttribute('aria-disabled')).toBe('true');
|
||||
await act(async () => finish(sample()));
|
||||
expect(refresh().textContent).toBe('Refresh');
|
||||
expect(status()).toBe('Up to date at 09:05:07: no changes since the last load');
|
||||
const changed = sample();
|
||||
changed.topology.services.pop();
|
||||
vi.mocked(loadContainerTopology).mockResolvedValueOnce(changed);
|
||||
await act(async () => refresh().click());
|
||||
expect(status()).toBe('Service map refreshed at 09:05:07');
|
||||
});
|
||||
|
||||
it('A3: also answers for a project without deployment files', async () => {
|
||||
vi.mocked(loadContainerInventory).mockResolvedValue({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] });
|
||||
await act(async () => root.render(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} />));
|
||||
let finish!: (value: Awaited<ReturnType<typeof loadContainerInventory>>) => void;
|
||||
vi.mocked(loadContainerInventory).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
await act(async () => refresh().click());
|
||||
expect(refresh().textContent).toBe('Refreshing…');
|
||||
await act(async () => finish({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] }));
|
||||
expect(refresh().textContent).toBe('Refresh');
|
||||
expect(status()).toMatch(/^Up to date at \d\d:\d\d:\d\d: no changes since the last load$/);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { loadContainerInventory, loadContainerTopology, type ContainerReading, t
|
||||
import type { ServiceEvidence } from './container-topology';
|
||||
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
|
||||
import { architectureText as text } from './strings';
|
||||
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
|
||||
import './spatial-architecture.css';
|
||||
import './container-map.css';
|
||||
|
||||
@@ -32,6 +33,8 @@ export default function ContainerMap({ project, generation, active, filter, onNa
|
||||
const [available, setAvailable] = useState<ProjectEntry[]>([]);
|
||||
const [selections, setSelections] = useState<ContainerSelection[]>([]);
|
||||
const [discovery, setDiscovery] = useState<Discovery>('reading');
|
||||
/** The `discoveryRevision` the current `discovery` answers, so a refresh can tell its own answer from the earlier one. */
|
||||
const [discovered, setDiscovered] = useState<number>();
|
||||
const [reading, setReading] = useState<{ key: string; result?: ContainerReading; error?: string }>();
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [discoveryRevision, setDiscoveryRevision] = useState(0);
|
||||
@@ -55,12 +58,21 @@ export default function ContainerMap({ project, generation, active, filter, onNa
|
||||
const inventory = await loadContainerInventory(current, client);
|
||||
if (controller.signal.aborted) return;
|
||||
setAvailable(projects); setSelections([{ inventory, manifest: inventory.manifests[0] ?? '' }]);
|
||||
setDiscovery(inventory.manifests.length ? undefined : 'none');
|
||||
}).catch(() => { if (!controller.signal.aborted) setDiscovery('failed'); });
|
||||
setDiscovery(inventory.manifests.length ? undefined : 'none'); setDiscovered(discoveryRevision);
|
||||
}).catch(() => { if (!controller.signal.aborted) { setDiscovery('failed'); setDiscovered(discoveryRevision); } });
|
||||
return () => controller.abort();
|
||||
}, [project, active, generation, discoveryRevision]);
|
||||
const selectionKey = JSON.stringify(selections.map(item => [item.inventory.project, item.manifest]));
|
||||
const key = `${project}:${generation ?? ''}:${selectionKey}:${revision}`;
|
||||
// Settled once the inventory answered this revision and, when a Compose file is chosen, its services were read.
|
||||
const readsTopology = selections.some(item => item.manifest);
|
||||
const topologyReading = reading?.key === key ? reading : undefined;
|
||||
const shownTopology = topologyReading?.result?.topology;
|
||||
const shown = useMemo(() => [discovery, shownTopology], [discovery, shownTopology]);
|
||||
const refresh = useRefreshFeedback({ key: `${key}:${discoveryRevision}`,
|
||||
settled: discovered === discoveryRevision && discovery !== 'reading' && (!readsTopology || Boolean(topologyReading?.result || topologyReading?.error)),
|
||||
value: shown,
|
||||
error: discovery === 'failed' ? text.serviceMap.failed : topologyReading?.error });
|
||||
useEffect(() => {
|
||||
if (!active || !selections.length || selections[0].inventory.project !== project || !selections.some(item => item.manifest)) return;
|
||||
const controller = new AbortController(), client = new RpcIntelligenceClient({ signal: controller.signal });
|
||||
@@ -137,10 +149,11 @@ export default function ContainerMap({ project, generation, active, filter, onNa
|
||||
<header className="spatial-heading"><div><span className="spatial-eyebrow">Routes / Services</span><h2>How the services connect</h2>
|
||||
<p>One square per declared service. Follow a connection to see the configuration and code behind it.</p></div>
|
||||
<div className="spatial-camera-controls"><button aria-pressed={!planar} onClick={() => setPlanar(false)}>3D</button><button aria-pressed={planar} onClick={() => setPlanar(true)}>Plan</button>
|
||||
<button onClick={() => setResetKey(value => value + 1)}>Fit map</button><button onClick={() => {
|
||||
<button onClick={() => setResetKey(value => value + 1)}>Fit map</button><RefreshControl labels={text.refreshFeedback.services} feedback={refresh.feedback} onRefresh={() => {
|
||||
refresh.begin();
|
||||
setRevision(value => value + 1);
|
||||
if (!selections.length || !selections[0].inventory.manifests.length) setDiscoveryRevision(value => value + 1);
|
||||
}}>Refresh</button></div></header>
|
||||
}} /></div></header>
|
||||
<div className="container-projects" aria-label="Deployment sources">{selections.map((selection, index) => <label key={selection.inventory.project}><span>{selection.inventory.project}</span>
|
||||
<select aria-label={`Compose file for ${selection.inventory.project}`} value={selection.manifest} onChange={event => setSelections(current => current.map((item, i) => i === index ? { ...item, manifest: event.target.value } : item))}>
|
||||
<option value="">Source only</option>{selection.inventory.manifests.map(path => <option key={path}>{path}</option>)}</select>
|
||||
|
||||
@@ -198,3 +198,45 @@ it('focuses an aggregated hotspot area and restores all areas on empty backgroun
|
||||
expect(host.querySelector('[aria-label="Hotspot area"]')).toBeNull();
|
||||
expect(host.querySelector('input[type="search"]')).toBeNull();
|
||||
});
|
||||
/*
|
||||
* Hand test 2026-10-04 (A3): "Refresh connections" fetched everything again and nothing on screen changed. Now the
|
||||
* button says it runs, the map keeps the connections it shows meanwhile, and a status names the time and the result.
|
||||
*/
|
||||
it('A3: Refresh connections says that it runs and what it found, and keeps the map while it runs', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 9, 4, 13, 45, 12));
|
||||
const route: GraphNode = { id: 2, label: 'Route', name: '/api/', qualified_name: 'sample.route.2', file_path: 'app/urls.py', x: 0, y: 0, z: 0, size: 1, color: '' };
|
||||
const input: GraphData = { nodes: [node, route], edges: [], total_nodes: 2 };
|
||||
await act(async () => root.render(<SpatialArchitecture project="sample" graph={input} overview={overview} view="routes" filter="" active onNavigate={vi.fn()} onView={vi.fn()} />));
|
||||
const refresh = () => [...host.querySelectorAll('button')].find(button => /^Refresh(ing)? connections/.test(button.textContent ?? ''))!;
|
||||
const status = () => host.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
|
||||
expect(refresh().textContent).toBe('Refresh connections');
|
||||
expect(status()).toBe('');
|
||||
|
||||
let finish!: (value: Awaited<ReturnType<typeof loadRouteGraph>>) => void;
|
||||
vi.mocked(loadRouteGraph).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
await act(async () => refresh().click());
|
||||
expect(refresh().textContent).toBe('Refreshing connections…');
|
||||
expect(refresh().getAttribute('aria-disabled')).toBe('true');
|
||||
// The map keeps what it shows; it does not fall back to "reading" while the refresh runs.
|
||||
expect(host.textContent).not.toContain('Reading indexed endpoint connections…');
|
||||
expect(host.textContent).not.toContain('(checking…)');
|
||||
// A second press while it runs starts nothing.
|
||||
await act(async () => refresh().click());
|
||||
expect(loadRouteGraph).toHaveBeenCalledTimes(2);
|
||||
await act(async () => finish({ relationships: [], truncated: false, warnings: [] }));
|
||||
expect(refresh().textContent).toBe('Refresh connections');
|
||||
expect(refresh().getAttribute('aria-disabled')).toBeNull();
|
||||
expect(status()).toBe('Up to date at 13:45:12: no changes since the last load');
|
||||
|
||||
vi.setSystemTime(new Date(2026, 9, 4, 13, 46, 3));
|
||||
vi.mocked(loadRouteGraph).mockResolvedValueOnce({ relationships: [{ source: route, target: node, type: 'HANDLES', id: 7 }], truncated: false, warnings: [] });
|
||||
await act(async () => refresh().click());
|
||||
expect(status()).toBe('Connections refreshed at 13:46:03');
|
||||
|
||||
vi.mocked(loadRouteGraph).mockRejectedValueOnce(new Error('daemon unavailable'));
|
||||
await act(async () => refresh().click());
|
||||
expect(status()).toBe('Refresh failed at 13:46:03: daemon unavailable');
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import type { CoverageIndex } from '../app/tree-model';
|
||||
import { buildHotspotGraph, collectHotspots, hotspotsForNode, hotspotSignals, hotspotIdentity } from './hotspot-map';
|
||||
import { hotspotAreas } from './hotspot-areas';
|
||||
import { useViewPreferences } from '../settings/view-preferences';
|
||||
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
|
||||
import './spatial-architecture.css';
|
||||
|
||||
interface Props {
|
||||
@@ -67,29 +68,36 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
|
||||
const [inventoryLimit, setInventoryLimit] = useState(24);
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [selection, setSelection] = useState<{ scope: string; node?: string; edge?: string }>();
|
||||
const [routeReading, setRouteReading] = useState<{ key: string; snapshot?: RouteGraphSnapshot; error?: string }>();
|
||||
const [routeReading, setRouteReading] = useState<{ key: string; base?: string; snapshot?: RouteGraphSnapshot; error?: string; refreshing?: boolean }>();
|
||||
const [routeRevision, setRouteRevision] = useState(0);
|
||||
const [memberLimit, setMemberLimit] = useState(5);
|
||||
const [includeTestRoutes, setIncludeTestRoutes] = useState(false);
|
||||
// A reindex clears the hotspot area; a view that mounts again keeps the place it is handed.
|
||||
useOnIdentityChange(`${project}:${generation ?? ''}`, () => { if (place.hotspotArea) changePlace({ hotspotArea: undefined }, true); });
|
||||
const routeKey = `${project}:${generation ?? ''}:${routeRevision}`;
|
||||
const routeBase = `${project}:${generation ?? ''}`;
|
||||
const routeKey = `${routeBase}:${routeRevision}`;
|
||||
useEffect(() => {
|
||||
if (view !== 'routes' || !active) return;
|
||||
const controller = new AbortController();
|
||||
setRouteReading({ key: routeKey });
|
||||
// A refresh of the same index keeps the connections on screen until the new ones arrive (hand test 2026-10-04, A3).
|
||||
const kept = (current?: typeof routeReading) => current?.base === routeBase && current.snapshot ? { snapshot: current.snapshot } : {};
|
||||
setRouteReading(current => ({ key: routeKey, base: routeBase, ...kept(current), refreshing: true }));
|
||||
void loadRouteGraph(project, { signal: controller.signal }).then(snapshot => {
|
||||
if (!controller.signal.aborted) setRouteReading({ key: routeKey, snapshot });
|
||||
if (!controller.signal.aborted) setRouteReading({ key: routeKey, base: routeBase, snapshot });
|
||||
}).catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) setRouteReading({ key: routeKey, error: error instanceof Error ? error.message : String(error) });
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!controller.signal.aborted) setRouteReading(current => ({ key: routeKey, base: routeBase, ...kept(current), error: message }));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [project, routeKey, view, active]);
|
||||
}, [project, routeKey, routeBase, view, active]);
|
||||
const routeRefresh = useRefreshFeedback({ key: routeKey, settled: routeReading?.key === routeKey && !routeReading.refreshing,
|
||||
value: routeReading?.key === routeKey ? routeReading.snapshot : undefined, error: routeReading?.key === routeKey ? routeReading.error : undefined });
|
||||
const entries = useMemo(() => semanticEntryPoints(graph, overview.entryPoints.flatMap(entry => entry.qualifiedName ? [entry.qualifiedName] : [])), [graph, overview.entryPoints]);
|
||||
const currentEntry = entries.find(node => entryChoice?.node.qualified_name
|
||||
? node.qualified_name === entryChoice.node.qualified_name && node.file_path === entryChoice.node.file_path
|
||||
: entryChoice?.generation === generation && node.id === entryChoice?.node.id && node.file_path === entryChoice.node.file_path) ?? entries[0];
|
||||
const routeSnapshot = routeReading?.key === routeKey ? routeReading.snapshot : undefined;
|
||||
// A refresh of the same index shows the earlier connections until its own arrive.
|
||||
const routeSnapshot = routeReading?.base === routeBase ? routeReading.snapshot : undefined;
|
||||
// Which routes are test code is decided by their registration, handler and caller evidence.
|
||||
const routesChecking = view === 'routes' && !routeSnapshot && !(routeReading?.key === routeKey && routeReading.error);
|
||||
const knownFiles = useMemo(() => [...new Set([...overview.files, ...[...(coverage?.records.values() ?? [])].filter(record => record.kind === 'file').map(record => record.path)])], [overview.files, coverage]);
|
||||
@@ -188,7 +196,8 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
|
||||
{!entries.length && <option value="">No indexed entry points</option>}{entries.map(node => <option key={node.id} value={node.id}>{node.name} · {node.file_path}</option>)}
|
||||
</select></label><label>Call depth <select aria-label="Call depth" value={depth} onChange={event => setDepth(Number(event.target.value))}>{[1, 2, 3, 4].map(value => <option key={value}>{value}</option>)}</select></label></>}
|
||||
{view === 'hotspots' && hotspotArea && <nav aria-label="Hotspot area"><button onClick={clearScope}>All hotspots</button><span>/ {hotspotArea}</span></nav>}
|
||||
{view === 'routes' && <button onClick={() => setRouteRevision(value => value + 1)}>Refresh connections</button>}
|
||||
{view === 'routes' && <RefreshControl labels={text.refreshFeedback.routes} feedback={routeRefresh.feedback}
|
||||
onRefresh={() => { routeRefresh.begin(); setRouteRevision(value => value + 1); }} />}
|
||||
{view === 'routes' && <label className="spatial-gravity-toggle"><input type="checkbox" checked={includeTestRoutes} onChange={event => { setIncludeTestRoutes(event.target.checked); setSelection(undefined); }} />{routesChecking ? text.includeTestRoutesChecking : text.includeTestRoutes(model.hiddenRoutes ?? 0)}</label>}
|
||||
{filePath && (view === 'overview' || view === 'dependencies') && <button onClick={() => onNavigate(filePath, 1)}>Read this file</button>}
|
||||
{(view === 'overview' || view === 'dependencies') && <div className="spatial-relations" role="group" aria-label="Relationship types">
|
||||
|
||||
@@ -561,8 +561,10 @@ describe('system architecture workspace', () => {
|
||||
expect(flowsLoader).toHaveBeenCalledExactlyOnceWith('sample');
|
||||
expect(loader.mock.calls.at(-1)?.[0]).toEqual({ project: 'sample', entryNodeId: 36163, expectedGeneration: 'g1', includeBehaviorEvidence: true });
|
||||
const start = container.querySelector<HTMLSelectElement>('[aria-label="Behavior entry point"]')!;
|
||||
expect([...start.options].map(option => option.textContent)).toEqual(['Choose an operation…', 'run_tests · tests/runtests.py',
|
||||
'handle · django/core/management/commands/runserver.py']);
|
||||
// Alphabetical (hand test 2026-10-04, A4); with every operation suggested a Suggested group would only repeat the list.
|
||||
expect([...start.options].map(option => option.textContent)).toEqual(['Choose an operation…',
|
||||
'handle · django/core/management/commands/runserver.py', 'run_tests · tests/runtests.py']);
|
||||
expect(start.querySelectorAll('optgroup')).toHaveLength(1);
|
||||
expect(start.value).toBe('36163');
|
||||
const state = container.querySelector('.behavior-loading[role="status"]')!;
|
||||
expect(state.textContent).toContain('The analysis returned no call evidence.');
|
||||
@@ -570,7 +572,9 @@ describe('system architecture workspace', () => {
|
||||
expect(state.textContent).toContain('Optional behavior evidence was omitted entirely');
|
||||
await choose('Behavior entry point', '9');
|
||||
expect(loader.mock.calls.at(-1)?.[0].entryNodeId).toBe(9);
|
||||
expect([...start.options].map(option => option.value)).toEqual(['', '9', '36163']);
|
||||
// The chosen start keeps its place in the alphabetical list.
|
||||
expect([...start.options].map(option => option.value)).toEqual(['', '36163', '9']);
|
||||
expect(start.value).toBe('9');
|
||||
});
|
||||
it('does not ask for flows once the projection classifies enough entry points', async () => {
|
||||
const flowsLoader = vi.fn<FlowsLoader>().mockResolvedValue([]);
|
||||
@@ -586,8 +590,46 @@ describe('system architecture workspace', () => {
|
||||
await render(vi.fn<SystemArchitectureLoader>().mockResolvedValue(response()), { view: 'behavior', flowsLoader });
|
||||
expect(flowsLoader).toHaveBeenCalledExactlyOnceWith('sample');
|
||||
const start = container.querySelector<HTMLSelectElement>('[aria-label="Behavior entry point"]')!;
|
||||
// The two classified entry points come first; the flow that repeats one of them is not listed twice.
|
||||
expect([...start.options].map(option => option.value)).toEqual(['', '1', '2', '36163']);
|
||||
// Every operation alphabetically (hand test 2026-10-04, A4), the flow that repeats a classified entry point once.
|
||||
// The journey's start and the two ranked flows would suggest all three, so no Suggested group repeats them.
|
||||
expect([...start.querySelectorAll('optgroup')].map(group => [group.label, [...group.querySelectorAll('option')].map(option => option.value)]))
|
||||
.toEqual([['All operations · 3', ['36163', '2', '1']]]);
|
||||
});
|
||||
it('A4: suggests the automatic start and the top ranked flows, and names look-alike flow starts by their class', async () => {
|
||||
const MODELS = 'django/db/migrations/operations/models.py';
|
||||
const flow = (id: number, name: string, filePath: string) => ({ id, label: name, entry: { id, name, filePath }, terminal: { id: 1, name: 'x' }, steps: 3 });
|
||||
const flowsLoader = vi.fn<FlowsLoader>().mockResolvedValue([flow(41, 'database_backwards', MODELS), flow(42, 'database_backwards', MODELS),
|
||||
flow(43, 'handle', 'django/core/management/commands/loaddata.py'), flow(44, 'b4', 'b.py'), flow(45, 'b5', 'b.py'), flow(46, 'b6', 'b.py')]);
|
||||
const operationNames = vi.fn(async (_project: string, entries: SystemSymbol[]) => entries.map(entry => entry.id === 41
|
||||
? { ...entry, qualified_name: 'p.ops.models.CreateModel.database_backwards' } : entry.id === 42 ? { ...entry, qualified_name: 'p.ops.models.DeleteModel.database_backwards' } : entry));
|
||||
const data = overviewFixture();
|
||||
data.entrypoints = [{ ...data.entrypoints[0], name: 'main' }, data.entrypoints[1]];
|
||||
await render(vi.fn<SystemArchitectureLoader>().mockResolvedValue(response(data)), { view: 'behavior', flowsLoader, operationNames });
|
||||
expect(operationNames).toHaveBeenCalledOnce();
|
||||
expect(operationNames.mock.calls[0]?.[1].map(entry => entry.id)).toEqual([41, 42, 43, 44, 45, 46]);
|
||||
const start = container.querySelector<HTMLSelectElement>('[aria-label="Behavior entry point"]')!;
|
||||
const suggested = [...start.querySelectorAll('optgroup')[0]!.querySelectorAll('option')].map(option => option.textContent);
|
||||
// The automatic start, then the first five ranked flows in their order.
|
||||
expect(suggested).toEqual(['main · src/api.ts', `database_backwards (CreateModel) · ${MODELS}`, `database_backwards (DeleteModel) · ${MODELS}`,
|
||||
'handle · django/core/management/commands/loaddata.py', 'b4 · b.py', 'b5 · b.py']);
|
||||
expect(start.selectedOptions[0]?.textContent).toBe('main · src/api.ts');
|
||||
});
|
||||
it('A4: suggests the start the projection chose itself, and keeps suggesting it after another start is picked', async () => {
|
||||
// django-demo: "main · …/manage.py-tpl" is no conventional entry for the page, the projection picks it on its own.
|
||||
const flow = (id: number, name: string, filePath: string) => ({ id, label: name, entry: { id, name, filePath }, terminal: { id: 1, name: 'x' }, steps: 3 });
|
||||
const flowsLoader = vi.fn<FlowsLoader>().mockResolvedValue([flow(43, 'handle', 'loaddata.py'), flow(44, 'b4', 'b.py')]);
|
||||
const data = overviewFixture();
|
||||
data.entrypoints = [{ ...data.entrypoints[0], name: 'main', file_path: 'conf/manage.py-tpl' }, data.entrypoints[1]];
|
||||
// No operation requested: the server names none (0), and the journey starts at the first classified entry point.
|
||||
data.behavior = { ...data.behavior!, source_id: 0 };
|
||||
await render(vi.fn<SystemArchitectureLoader>().mockResolvedValue(response(data)), { view: 'behavior', flowsLoader });
|
||||
const start = container.querySelector<HTMLSelectElement>('[aria-label="Behavior entry point"]')!;
|
||||
const suggested = () => [...start.querySelectorAll('optgroup')[0]!.querySelectorAll('option')].map(option => option.value);
|
||||
expect(start.value).toBe('1');
|
||||
expect(suggested()).toEqual(['1', '43', '44']);
|
||||
await choose('Behavior entry point', '43');
|
||||
expect(start.value).toBe('43');
|
||||
expect(suggested()).toEqual(['1', '43', '44']);
|
||||
});
|
||||
it('shows failures without retrying continuously', async () => {
|
||||
vi.useFakeTimers(); const loader = vi.fn<SystemArchitectureLoader>().mockResolvedValue({ status: 'failed', generation: 'g1', error: 'Analysis budget exceeded.' });
|
||||
@@ -595,3 +637,49 @@ describe('system architecture workspace', () => {
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain('Analysis budget exceeded'); expect(loader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* Hand test 2026-10-04 (A3): "Refresh analysis" and the Behavior "Refresh" read again and said nothing. */
|
||||
describe('system architecture refresh feedback', () => {
|
||||
const refresh = (pattern: RegExp) => [...container.querySelectorAll('button')].find(element => pattern.test(element.textContent ?? ''))!;
|
||||
const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('A3: Refresh analysis is busy while it runs, then names the time and whether the index changed', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 9, 4, 18, 0, 9));
|
||||
const loader = vi.fn<SystemArchitectureLoader>().mockResolvedValue(response(overviewFixture()));
|
||||
await render(loader);
|
||||
let finish!: (value: SystemArchitectureResponse) => void;
|
||||
loader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
await act(async () => refresh(/^Refresh(ing)? analysis/).click());
|
||||
expect(refresh(/^Refresh(ing)? analysis/).textContent).toBe('Refreshing analysis…');
|
||||
expect(refresh(/^Refresh(ing)? analysis/).getAttribute('aria-disabled')).toBe('true');
|
||||
// The structure stays on screen meanwhile.
|
||||
expect(container.querySelector('[data-testid="system-scene"]')).not.toBeNull();
|
||||
await act(async () => finish(response(overviewFixture())));
|
||||
expect(refresh(/^Refresh(ing)? analysis/).textContent).toBe('Refresh analysis');
|
||||
expect(status()).toBe('Up to date at 18:00:09: no changes since the last load');
|
||||
loader.mockResolvedValueOnce({ ...response(overviewFixture()), generation: 'g2' });
|
||||
await act(async () => refresh(/^Refresh(ing)? analysis/).click());
|
||||
expect(status()).toBe('Analysis refreshed at 18:00:09');
|
||||
loader.mockRejectedValueOnce(new Error('Daemon stopped.'));
|
||||
await act(async () => refresh(/^Refresh(ing)? analysis/).click());
|
||||
expect(status()).toBe('Refresh failed at 18:00:09: Daemon stopped.');
|
||||
});
|
||||
|
||||
it('A3: the Behavior Refresh says the same beside its button', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 9, 4, 18, 1, 0));
|
||||
const loader = vi.fn<SystemArchitectureLoader>().mockResolvedValue(response(overviewFixture()));
|
||||
await render(loader, { view: 'behavior' });
|
||||
const button = () => container.querySelector<HTMLButtonElement>('.behavior-heading .atlas-arch-refresh button')!;
|
||||
expect(button()?.textContent).toBe('Refresh');
|
||||
let finish!: (value: SystemArchitectureResponse) => void;
|
||||
loader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
await act(async () => button().click());
|
||||
expect(button().textContent).toBe('Refreshing…');
|
||||
await act(async () => finish(response(overviewFixture())));
|
||||
expect(button().textContent).toBe('Refresh');
|
||||
expect(container.querySelector('.behavior-heading [role="status"]')?.textContent).toBe('Up to date at 18:01:00: no changes since the last load');
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,12 @@ import { connectionLoad } from '../graph/connection-load';
|
||||
import { AtlasApi } from '../app/atlas-api';
|
||||
import type { FlowSummary } from '../traces/trace-schemas';
|
||||
import BehaviorJourney from './BehaviorJourney';
|
||||
import { defaultJourneyStart } from './behavior-journey-model';
|
||||
import type { BehaviorPlace, JourneyPlace, PlaceChange, SystemPlace } from './architecture-history';
|
||||
import { useLiftedPlace, useOnIdentityChange } from './lifted-place';
|
||||
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
|
||||
import { nameOperations } from './operation-choices';
|
||||
import { RpcIntelligenceClient } from '../provider/rpc-client';
|
||||
import './system-architecture.css';
|
||||
|
||||
const Scene = lazy(() => import('./SystemArchitectureScene'));
|
||||
@@ -20,6 +24,8 @@ export interface SystemArchitectureProps {
|
||||
onNavigate: (path: string, line?: number, name?: string) => void;
|
||||
loader?: SystemArchitectureLoader;
|
||||
flowsLoader?: FlowsLoader;
|
||||
/** Fills in the qualified names of look-alike flow starts, so "Start" can tell them apart (hand test 2026-10-04, A4). */
|
||||
operationNames?: OperationNamer;
|
||||
/** Focus, expanded groups, the Behavior start and position, and both cameras, lifted to the workspace for Back and Forward (K27). */
|
||||
place?: SystemPlace;
|
||||
onPlace?: PlaceChange<SystemPlace>;
|
||||
@@ -27,8 +33,12 @@ export interface SystemArchitectureProps {
|
||||
/** Ranked call-graph flows (route handlers, call-graph roots) from /api/flows. */
|
||||
export type FlowsLoader = (project: string) => Promise<FlowSummary[]>;
|
||||
const loadFlows: FlowsLoader = project => new AtlasApi().flows(project);
|
||||
export type OperationNamer = (project: string, entries: SystemSymbol[]) => Promise<SystemSymbol[]>;
|
||||
const nameFlowStarts: OperationNamer = (project, entries) => nameOperations(project, entries, new RpcIntelligenceClient());
|
||||
/** Below this many classified entry points the Behavior start list also offers ranked flows. */
|
||||
const FEW_ENTRY_POINTS = 8;
|
||||
/** How many of the ranked flows "Start" suggests after the automatic start. */
|
||||
const RANKED_SUGGESTIONS = 5;
|
||||
|
||||
/** Starting operations from flows, once each. The projection supplies qualified name and component on request. */
|
||||
export function flowEntries(flows: FlowSummary[]): SystemSymbol[] {
|
||||
@@ -70,7 +80,7 @@ const connectionAllowed = (type: string, view: ConnectionView) => view === 'all'
|
||||
|| (view === 'calls' ? ['CALLS', 'IMPORTS', 'HTTP_CALLS', 'ASYNC_CALLS'] : ['INHERITS', 'IMPLEMENTS']).includes(type);
|
||||
|
||||
/** Poll only while this view is active. A new request key hides stale results before its effect runs. */
|
||||
export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, place: liftedPlace, onPlace }: SystemArchitectureProps) {
|
||||
export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, operationNames = nameFlowStarts, place: liftedPlace, onPlace }: SystemArchitectureProps) {
|
||||
const [place, changePlace] = useLiftedPlace<SystemPlace>(liftedPlace, onPlace, () => ({ expanded: [] }));
|
||||
const entryChoice = place.behavior;
|
||||
/** A start the page picks itself (the suggestion, a reset) completes the current step instead of adding one. */
|
||||
@@ -159,6 +169,9 @@ export default function SystemArchitecture({ project, generation, view, filter,
|
||||
});
|
||||
const structureData = useMemo(() => data ? { ...data, dependencies: data.dependencies.filter(edge => connectionAllowed(edge.type, connectionView)) } : undefined, [data, connectionView]);
|
||||
const error = current?.error ?? (current?.response?.status === 'failed' ? current.response.error ?? 'Architecture analysis failed.' : undefined);
|
||||
/* Refresh analysis and the Behavior Refresh say what they did (hand test 2026-10-04, A3). The same index answers with the same generation. */
|
||||
const refresh = useRefreshFeedback({ key: requestKey, settled: Boolean(current) && !queryPending, value: current?.response?.generation, error });
|
||||
const refreshAnalysis = () => { refresh.begin(); setRevision(value => value + 1); };
|
||||
const chosenEntries = entryChoices?.project === project && entryChoices.generation === generation ? entryChoices.entries : data?.entrypoints ?? [];
|
||||
// With no or only a handful of classified entry points (limited projections, Python without main, a few
|
||||
// route handlers) the ranked flows add their starts after the classified ones.
|
||||
@@ -167,9 +180,11 @@ export default function SystemArchitecture({ project, generation, view, filter,
|
||||
useEffect(() => {
|
||||
if (!needsFlows || (flows?.project === project && flows.generation === generation)) return;
|
||||
let live = true;
|
||||
void flowsLoader(project).then(flowEntries, () => []).then(entries => { if (live) setFlows({ project, generation, entries }); });
|
||||
// Look-alike flow starts get their qualified names first, so the field never relabels them later (A4).
|
||||
void flowsLoader(project).then(flowEntries, () => []).then(entries => operationNames(project, entries).catch(() => entries))
|
||||
.then(entries => { if (live) setFlows({ project, generation, entries }); });
|
||||
return () => { live = false; };
|
||||
}, [needsFlows, flows, project, generation, flowsLoader]);
|
||||
}, [needsFlows, flows, project, generation, flowsLoader, operationNames]);
|
||||
const flowStarts = useMemo(() => flows?.project === project && flows.generation === generation ? flows.entries : [], [flows, project, generation]);
|
||||
const entries = useMemo(() => {
|
||||
if (!fewEntries || !flowStarts.length) return chosenEntries;
|
||||
@@ -177,11 +192,24 @@ export default function SystemArchitecture({ project, generation, view, filter,
|
||||
for (const item of flowStarts) if (!merged.has(item.id)) merged.set(item.id, item);
|
||||
return [...merged.values()];
|
||||
}, [fewEntries, flowStarts, chosenEntries]);
|
||||
const automaticStart = useMemo(() => data ? data.entrypoints.length ? suggestedBehaviorEntry(data.entrypoints) : suggestedBehaviorEntry(flowStarts, true) : undefined, [data, flowStarts]);
|
||||
useEffect(() => {
|
||||
if (view !== 'behavior' || !data || (entryChoice?.project === project && entryChoice.generation === generation)) return;
|
||||
const suggested = data.entrypoints.length ? suggestedBehaviorEntry(data.entrypoints) : suggestedBehaviorEntry(flowStarts, true);
|
||||
if (suggested) setEntryChoice({ project, generation, expectedGeneration: current?.response?.generation ?? snapshot?.analysisGeneration, id: suggested.id, name: suggested.name }, true);
|
||||
}, [view, data, project, generation, entryChoice, current?.response?.generation, snapshot?.analysisGeneration, flowStarts]);
|
||||
if (automaticStart) setEntryChoice({ project, generation, expectedGeneration: current?.response?.generation ?? snapshot?.analysisGeneration, id: automaticStart.id, name: automaticStart.name }, true);
|
||||
}, [view, data, project, generation, entryChoice, current?.response?.generation, snapshot?.analysisGeneration, automaticStart]);
|
||||
/*
|
||||
* "Start" offers the automatic start first, then the best ranked flows in the server's order (hand test
|
||||
* 2026-10-04, A4). Without a conventional entry the journey starts where the projection points
|
||||
* (django-demo: "main · …/manage.py-tpl"); that start stays a suggestion after another one is chosen.
|
||||
*/
|
||||
const [projectionStart, setProjectionStart] = useState<{ project: string; generation?: string; id: number }>();
|
||||
const pickedByProjection = requestedEntry === undefined && view === 'behavior' && queryData ? defaultJourneyStart(queryData) : undefined;
|
||||
useEffect(() => {
|
||||
if (pickedByProjection !== undefined) setProjectionStart({ project, generation, id: pickedByProjection });
|
||||
}, [pickedByProjection, project, generation]);
|
||||
const shownByProjection = projectionStart?.project === project && projectionStart.generation === generation ? projectionStart.id : undefined;
|
||||
const suggestedStarts = useMemo(() => [...new Set([...automaticStart ? [automaticStart.id] : shownByProjection !== undefined ? [shownByProjection] : [],
|
||||
...flowStarts.slice(0, RANKED_SUGGESTIONS).map(item => item.id)])], [automaticStart, shownByProjection, flowStarts]);
|
||||
const returnedPaths = useMemo(() => {
|
||||
const valid = queryData?.paths.filter(path => isContiguousPath(path)) ?? [];
|
||||
if (queryData?.behavior && requestedTarget === undefined) return [];
|
||||
@@ -318,7 +346,7 @@ export default function SystemArchitecture({ project, generation, view, filter,
|
||||
}, automatic);
|
||||
const behaviorPage = view === 'behavior' ? <BehaviorJourney project={project} generation={projectionGeneration} data={queryData}
|
||||
entries={entries} targets={targets} entryId={requestedEntry} targetId={requestedTarget} active={active} pending={queryPending}
|
||||
error={error} filter={filter} onRefresh={() => setRevision(value => value + 1)} onNavigate={onNavigate} onSelectSymbol={selectSymbol} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence}
|
||||
error={error} filter={filter} onRefresh={refreshAnalysis} refresh={refresh.feedback} suggestedEntries={suggestedStarts} onNavigate={onNavigate} onSelectSymbol={selectSymbol} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence}
|
||||
from={requestedEntry !== undefined ? entryChoice?.from : undefined} place={journeyPlace} onPlace={changeJourney}
|
||||
onShownStart={name => { if (name !== place.shown) changePlace({ shown: name }, true); }}
|
||||
onRequest={(entry, targetId, detail) => {
|
||||
@@ -332,7 +360,8 @@ export default function SystemArchitecture({ project, generation, view, filter,
|
||||
return <section className="system-architecture" data-testid="system-architecture" aria-label={view === 'structure' ? 'System structure' : 'Behavior'} aria-busy={active && pending}>
|
||||
<div className="system-toolbar">
|
||||
<p>{view === 'structure' ? 'Inspect the parts and the code that connects them.' : 'Follow a question through the same system map.'}</p>
|
||||
<button className="atlas-arch-action system-refresh" onClick={() => { setRevision(value => value + 1); setResetKey(value => value + 1); }}>Refresh analysis</button>
|
||||
<RefreshControl className="atlas-arch-action system-refresh" labels={text.refreshFeedback.structure} feedback={refresh.feedback}
|
||||
onRefresh={() => { refreshAnalysis(); setResetKey(value => value + 1); }} />
|
||||
</div><div className="system-controls">
|
||||
<div className="system-scope-actions"><button disabled={!focusId} onClick={clearSelection}>Whole system</button></div>
|
||||
{view === 'structure' && <label>Connections <select aria-label="Connection view" value={connectionView} onChange={event => { setConnectionView(event.target.value as ConnectionView); setCyclesOnly(false); }}>
|
||||
|
||||
@@ -45,21 +45,30 @@
|
||||
}
|
||||
.atlas-arch-tab[aria-pressed="true"] { color: var(--atlas-fg); border-bottom-color: var(--atlas-phosphor); }
|
||||
.atlas-arch-tab:hover { color: var(--atlas-fg); background: var(--atlas-panel); }
|
||||
/* K27: the tabs and one Back, Forward and Recent for the whole workspace share a row. The menu sits outside the scrolling tab strip. */
|
||||
.atlas-arch-tabrow { display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--atlas-line); margin-bottom: 16px; }
|
||||
/*
|
||||
* K27: one Back, Forward and Recent for the whole workspace and the tabs share a row. The menu sits outside the
|
||||
* scrolling tab strip. Hand test 2026-10-04 (A1): the controls come first and read as in Galaxy, "← Back" and
|
||||
* "Forward →" while the tabs fit, the glyphs alone once they would scroll (`data-fit`, galaxy/toolbar-fit.tsx).
|
||||
*/
|
||||
.atlas-arch-tabrow { display: flex; align-items: center; gap: 14px; border-bottom: 1px solid var(--atlas-line); margin-bottom: 16px; }
|
||||
.atlas-arch-tabrow > .atlas-arch-tabs { flex: 1 1 auto; min-width: 0; border-bottom: 0; margin-bottom: 0; }
|
||||
.atlas-arch-history { display: flex; align-items: center; gap: 4px; flex: none; position: relative; }
|
||||
.atlas-arch-history > button, .atlas-arch-recent > summary {
|
||||
border: 1px solid var(--atlas-line); border-radius: 7px; background: var(--atlas-panel); color: var(--atlas-fg);
|
||||
min-width: 32px; min-height: 30px; padding: 3px 8px; line-height: 1.4;
|
||||
border: 1px solid var(--atlas-line); border-radius: 6px; background: var(--atlas-panel); color: var(--atlas-fg);
|
||||
min-width: 30px; min-height: 30px; padding: 3px 10px; line-height: 1.4; white-space: nowrap;
|
||||
}
|
||||
.atlas-arch-history > button:hover:not(:disabled), .atlas-arch-recent > summary:hover { background: var(--atlas-panel-raised); border-color: var(--atlas-dim); }
|
||||
.atlas-arch-history > button:disabled { opacity: 0.45; }
|
||||
.atlas-arch-history > button:disabled { opacity: 0.4; }
|
||||
.atlas-arch-history .atlas-fit-narrow { display: none; }
|
||||
.atlas-arch-history .atlas-fit-narrow::before { content: attr(data-label); }
|
||||
.atlas-arch-tabrow:is([data-fit=compact], [data-fit=tight], [data-fit=wrap]) .atlas-arch-history .atlas-fit-wide { display: none; }
|
||||
.atlas-arch-tabrow:is([data-fit=compact], [data-fit=tight], [data-fit=wrap]) .atlas-arch-history .atlas-fit-narrow { display: inline; }
|
||||
.atlas-arch-tabrow:is([data-fit=compact], [data-fit=tight], [data-fit=wrap]) .atlas-arch-history > button { padding-inline: 8px; }
|
||||
.atlas-arch-recent { position: relative; }
|
||||
.atlas-arch-recent > summary { display: block; cursor: pointer; list-style: none; text-align: center; white-space: nowrap; color: var(--atlas-dim); }
|
||||
.atlas-arch-recent > summary { display: block; cursor: pointer; list-style: none; text-align: center; color: var(--atlas-dim); padding-inline: 8px; }
|
||||
.atlas-arch-recent > summary::-webkit-details-marker { display: none; }
|
||||
.atlas-arch-recent-menu {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; width: min(24rem, calc(100vw - 3rem)); max-height: 20rem; overflow: auto;
|
||||
position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; width: min(24rem, calc(100vw - 3rem)); max-height: 20rem; overflow: auto;
|
||||
margin: 0; padding: 6px; list-style: none; border: 1px solid var(--atlas-line); border-radius: 8px; background: var(--atlas-panel); box-shadow: 0 8px 24px #0006;
|
||||
}
|
||||
.atlas-arch-recent-menu button {
|
||||
@@ -69,6 +78,12 @@
|
||||
.atlas-arch-recent-menu button:hover:not(:disabled) { background: var(--atlas-panel-raised); }
|
||||
.atlas-arch-recent-menu button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--atlas-dim); }
|
||||
.atlas-arch-recent-menu button[aria-current="true"] { opacity: 0.6; }
|
||||
/* Hand test 2026-10-04 (A3): a Refresh says that it runs and, beside it, what it found. The live region stays in the tree. */
|
||||
.atlas-arch-refresh { display: inline-flex; align-items: center; flex-wrap: wrap; min-width: 0; }
|
||||
.atlas-arch-refresh > button[aria-disabled="true"] { opacity: 0.6; cursor: progress; }
|
||||
.atlas-arch-refresh-status { color: var(--atlas-dim); font-size: 11px; line-height: 1.4; }
|
||||
.atlas-arch-refresh-status:not(:empty) { margin-left: 8px; }
|
||||
.atlas-arch-refresh-status[data-phase="failed"] { color: var(--atlas-alarm); }
|
||||
.atlas-arch-toolbar { margin-bottom: 24px; gap: 10px; }
|
||||
.atlas-arch-filter { position: relative; flex: 1; max-width: 370px; }
|
||||
.atlas-arch-filter svg { color: var(--atlas-dim); left: 11px; position: absolute; top: 10px; pointer-events: none; }
|
||||
|
||||
@@ -106,11 +106,18 @@ function corridorPath(data: SystemProjection, source: number, target: number, hi
|
||||
return { entrypoint_id: source, nodes: nodes.reverse(), edges: edges.reverse() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The operation a journey starts from when none was requested: the server's pick, else the first classified entry
|
||||
* point, else the first connected path. The server reports source_id 0 when no operation was requested; node IDs
|
||||
* start at 1.
|
||||
*/
|
||||
export function defaultJourneyStart(data: SystemProjection): number | undefined {
|
||||
return (data.behavior?.source_id || undefined) ?? data.entrypoints[0]?.id ?? data.paths.find(validPath)?.entrypoint_id;
|
||||
}
|
||||
|
||||
/** A focused static journey: positions describe hops, never catalog distance or runtime order. */
|
||||
export function behaviorJourney(data: SystemProjection, options: BehaviorJourneyOptions = {}): BehaviorJourney {
|
||||
// The server reports source_id 0 when no operation was requested; node IDs start at 1.
|
||||
const entryId = options.entryId ?? (data.behavior?.source_id || undefined) ?? data.entrypoints[0]?.id
|
||||
?? data.paths.find(validPath)?.entrypoint_id;
|
||||
const entryId = options.entryId ?? defaultJourneyStart(data);
|
||||
const scopedBehavior = data.behavior?.source_id === entryId ? data.behavior : undefined;
|
||||
const dependencies = [...data.dependencies, ...(data.overview?.connections ?? [])];
|
||||
const symbols = new Map<number, SystemSymbol>();
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
.behavior-heading h2 { font-size: 25px; font-weight: 500; letter-spacing: -.6px; margin: 7px 0; overflow-wrap: anywhere; }
|
||||
.behavior-heading p { color: var(--atlas-dim); font-size: 12px; margin: 0; }
|
||||
.behavior-journey button { cursor: pointer; }
|
||||
.behavior-heading > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; }
|
||||
.behavior-heading > button, .behavior-heading > .atlas-arch-refresh > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; }
|
||||
.behavior-journey button:disabled { opacity: .35; cursor: default; }
|
||||
.behavior-journey button:focus-visible { outline: 2px solid var(--arch-label-focus); outline-offset: 3px; }
|
||||
.behavior-requests { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px; border: 1px solid var(--journey-line); border-radius: 10px; background: var(--journey-tint); }
|
||||
.behavior-requests label { display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--atlas-dim); flex: 1 1 270px; min-width: 0; }
|
||||
.behavior-requests select { min-width: 0; width: 100%; max-width: 100%; }
|
||||
.behavior-requests input { flex: 1 1 150px; min-width: 120px; background: transparent; color: var(--atlas-fg); border: 1px solid var(--journey-line); border-radius: 5px; padding: 6px 9px; font: inherit; font-size: 11px; }
|
||||
/* Hand test 2026-10-04 (A4): "Start" holds the filter and the field; it takes the larger share of the row. */
|
||||
.behavior-requests .behavior-start { flex: 2 1 460px; }
|
||||
.behavior-requests .behavior-start input { flex: 0 1 190px; }
|
||||
.behavior-requests .behavior-start select { flex: 1 1 240px; width: auto; }
|
||||
.behavior-navigation { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; margin: 12px 0; font-size: 11px; color: var(--atlas-dim); }
|
||||
.behavior-navigation > span:first-of-type { margin-left: 4px; }
|
||||
.behavior-static-badge { margin-left: auto; border: 1px solid var(--journey-line); border-radius: 20px; padding: 4px 9px; font-size: 10px; }
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { nameOperations, operationChoices, operationLabels } from './operation-choices';
|
||||
import type { SystemSymbol } from './system-architecture-source';
|
||||
|
||||
/*
|
||||
* Hand test 2026-10-04 (A4): the Behavior "Start" list of django-demo held 62 operations in the order the
|
||||
* server returned them, with look-alikes ("database_backwards · django/db/migrations/operations/models.py" six
|
||||
* times), and "handle · …/loaddata.py" could not be found.
|
||||
*/
|
||||
const operation = (id: number, name: string, file_path: string, more: Partial<SystemSymbol> = {}): SystemSymbol =>
|
||||
({ id, name, qualified_name: '', label: '', file_path, component_id: '', ...more });
|
||||
const MODELS = 'django/db/migrations/operations/models.py';
|
||||
const scouted = [
|
||||
operation(1, 'main', 'django/conf/project_template/manage.py-tpl', { qualified_name: 'django-demo.django.conf.project_template.manage.main', start_line: 7 }),
|
||||
operation(2, 'delete_selected', 'django/contrib/admin/actions.py'),
|
||||
operation(3, 'handle', 'django/core/management/commands/makemigrations.py'),
|
||||
operation(4, 'handle', 'django/core/management/commands/loaddata.py'),
|
||||
operation(5, 'handle', 'django/core/management/commands/migrate.py'),
|
||||
operation(6, 'database_backwards', MODELS),
|
||||
operation(7, 'database_backwards', MODELS),
|
||||
operation(8, 'clean', 'django/forms/models.py'),
|
||||
operation(9, 'clean', 'django/forms/models.py'),
|
||||
operation(10, 'as_sql', 'django/db/models/fields/json.py'),
|
||||
];
|
||||
const labels = (list: { label: string }[]) => list.map(item => item.label);
|
||||
|
||||
describe('Behavior start choices (A4)', () => {
|
||||
it('lists every operation alphabetically by name, then by path', () => {
|
||||
const { all, total } = operationChoices(scouted, {});
|
||||
expect(total).toBe(10);
|
||||
expect(all.map(item => item.id)).toEqual([10, 8, 9, 6, 7, 2, 4, 3, 5, 1]);
|
||||
expect(labels(all).slice(5, 9)).toEqual(['delete_selected · django/contrib/admin/actions.py', 'handle · django/core/management/commands/loaddata.py',
|
||||
'handle · django/core/management/commands/makemigrations.py', 'handle · django/core/management/commands/migrate.py']);
|
||||
});
|
||||
|
||||
it('puts the automatic start and the ranked suggestions first, in their order, and keeps them in the full list', () => {
|
||||
const { suggested, all } = operationChoices(scouted, { suggested: [1, 3, 4, 99] });
|
||||
expect(labels(suggested)).toEqual(['main · django/conf/project_template/manage.py-tpl', 'handle · django/core/management/commands/makemigrations.py',
|
||||
'handle · django/core/management/commands/loaddata.py']);
|
||||
expect(all).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('tells look-alikes apart by their class, by their line when the class is the same, and by position otherwise', () => {
|
||||
const named = scouted.map(item => item.id === 6 ? { ...item, qualified_name: `django-demo.django.db.migrations.operations.models.CreateModel.database_backwards`, start_line: 110 }
|
||||
: item.id === 7 ? { ...item, qualified_name: `django-demo.django.db.migrations.operations.models.DeleteModel.database_backwards`, start_line: 444 }
|
||||
: item.id === 8 ? { ...item, qualified_name: 'django-demo.django.forms.models.BaseModelForm.clean', start_line: 437 }
|
||||
: item.id === 9 ? { ...item, qualified_name: 'django-demo.django.forms.models.BaseModelForm.clean', start_line: 800 } : item);
|
||||
const text = operationLabels(named);
|
||||
// The name stays first, so the list still reads alphabetically.
|
||||
expect(text.get(6)).toBe(`database_backwards (CreateModel) · ${MODELS}`);
|
||||
expect(text.get(7)).toBe(`database_backwards (DeleteModel) · ${MODELS}`);
|
||||
expect(text.get(8)).toBe('clean · django/forms/models.py:437');
|
||||
expect(text.get(9)).toBe('clean · django/forms/models.py:800');
|
||||
// Unique names keep their plain label.
|
||||
expect(text.get(4)).toBe('handle · django/core/management/commands/loaddata.py');
|
||||
// Without a qualified name or line the position still tells them apart.
|
||||
const bare = operationLabels(scouted);
|
||||
expect([bare.get(6), bare.get(7)]).toEqual([`database_backwards · ${MODELS} (1 of 2)`, `database_backwards · ${MODELS} (2 of 2)`]);
|
||||
expect(new Set(bare.values()).size).toBe(bare.size);
|
||||
// Sorted by name first, the class does not tear the look-alikes apart.
|
||||
expect(operationChoices(named, {}).all.map(item => item.id).slice(3, 5)).toEqual([6, 7]);
|
||||
});
|
||||
|
||||
it('narrows to the operations that match every word of the filter, and keeps the chosen one', () => {
|
||||
expect(labels(operationChoices(scouted, { query: 'loaddata' }).all)).toEqual(['handle · django/core/management/commands/loaddata.py']);
|
||||
expect(operationChoices(scouted, { query: 'handle MIGRATE' }).all.map(item => item.id)).toEqual([5]);
|
||||
const kept = operationChoices(scouted, { query: 'loaddata', keep: 1, suggested: [1, 3] });
|
||||
expect(kept.all.map(item => item.id)).toEqual([4, 1]);
|
||||
expect(kept.suggested.map(item => item.id)).toEqual([1]);
|
||||
expect(kept.matching).toBe(1);
|
||||
// A chosen start that matches counts as a match (measured in the browser: "0 of 61" after picking loaddata).
|
||||
expect(operationChoices(scouted, { query: 'loaddata', keep: 4 }).matching).toBe(1);
|
||||
expect(operationChoices(scouted, { query: 'nothing like this' }).all).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualified names for look-alike flow starts (A4)', () => {
|
||||
it('asks the index once for the look-alikes only and fills in their qualified name and line', async () => {
|
||||
const queryRows = vi.fn(async () => [
|
||||
{ id: '6', qualified_name: 'django-demo.django.db.migrations.operations.models.CreateModel.database_backwards', start_line: '110' },
|
||||
{ id: '7', qualified_name: 'django-demo.django.db.migrations.operations.models.DeleteModel.database_backwards', start_line: '444' },
|
||||
{ id: '70', qualified_name: 'django-demo.django.db.migrations.operations.models.RenameModel.database_backwards', start_line: '538' },
|
||||
{ id: '8', qualified_name: 'django-demo.django.forms.models.BaseModelForm.clean', start_line: '437' },
|
||||
]);
|
||||
const named = await nameOperations('django-demo', scouted, { queryRows });
|
||||
expect(queryRows).toHaveBeenCalledOnce();
|
||||
const [project, query] = queryRows.mock.calls[0] as unknown as [string, string];
|
||||
expect(project).toBe('django-demo');
|
||||
expect(query).toContain('n.name IN ["database_backwards", "clean"]');
|
||||
expect(query).toContain(`n.file_path IN ["${MODELS}", "django/forms/models.py"]`);
|
||||
expect(query).not.toContain('handle');
|
||||
expect(named.find(item => item.id === 6)).toMatchObject({ qualified_name: 'django-demo.django.db.migrations.operations.models.CreateModel.database_backwards', start_line: 110 });
|
||||
expect(named.find(item => item.id === 9)?.qualified_name).toBe('');
|
||||
expect(named.find(item => item.id === 4)).toBe(scouted[3]);
|
||||
});
|
||||
|
||||
it('needs no query without look-alikes, never puts quotes or backslashes into one, and keeps the list when it fails', async () => {
|
||||
const queryRows = vi.fn(async () => { throw new Error('index busy'); });
|
||||
expect(await nameOperations('p', scouted.slice(0, 5), { queryRows })).toEqual(scouted.slice(0, 5));
|
||||
expect(queryRows).not.toHaveBeenCalled();
|
||||
const odd = [operation(1, 'run"x', 'a.py'), operation(2, 'run"x', 'a.py')];
|
||||
expect(await nameOperations('p', odd, { queryRows })).toEqual(odd);
|
||||
expect(queryRows).not.toHaveBeenCalled();
|
||||
expect(await nameOperations('p', scouted, { queryRows })).toEqual(scouted);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The operations the Behavior "Start" field offers (hand test 2026-10-04,
|
||||
* A4). For django-demo that were 62 entries in the order the server sent
|
||||
* them, with look-alikes ("database_backwards · …/models.py" six times), and
|
||||
* "handle · …/loaddata.py" could not be found. Now:
|
||||
*
|
||||
* - the automatic start and the ranked suggestions come first, in a group
|
||||
* of their own and in their order;
|
||||
* - then every operation, alphabetically by name and then by path;
|
||||
* - look-alikes carry their class ("database_backwards (CreateModel) · …",
|
||||
* the name first so the list still reads alphabetically), their line when
|
||||
* the class is the same too, and their position when the index names
|
||||
* neither;
|
||||
* - a filter keeps the operations whose name, class or path contain every
|
||||
* word typed, and always the chosen one, so the field can still show it.
|
||||
*/
|
||||
import type { SystemSymbol } from './system-architecture-source';
|
||||
import { architectureText } from './strings';
|
||||
|
||||
const text = architectureText.behaviorStart;
|
||||
|
||||
export interface OperationChoice { id: number; label: string; symbol: SystemSymbol }
|
||||
export interface OperationChoices {
|
||||
suggested: OperationChoice[];
|
||||
all: OperationChoice[];
|
||||
/** How many operations there are, and how many of them match the filter (the kept one only when it matches). */
|
||||
total: number;
|
||||
matching: number;
|
||||
}
|
||||
|
||||
const place = (symbol: SystemSymbol) => symbol.file_path ?? symbol.qualified_name;
|
||||
const lookAlike = (symbol: SystemSymbol) => `${symbol.name}\u0000${place(symbol) ?? ''}`;
|
||||
|
||||
/** The segment before the name in the qualified name: the class of a method, the module of a function. */
|
||||
export function operationQualifier(symbol: SystemSymbol): string | undefined {
|
||||
const name = symbol.qualified_name;
|
||||
if (!name || !name.endsWith(`.${symbol.name}`)) return undefined;
|
||||
return name.slice(0, -symbol.name.length - 1).split('.').at(-1) || undefined;
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
|
||||
function compare(a: SystemSymbol, b: SystemSymbol): number {
|
||||
return collator.compare(a.name, b.name) || collator.compare(place(a) ?? '', place(b) ?? '')
|
||||
|| collator.compare(operationQualifier(a) ?? '', operationQualifier(b) ?? '') || (a.start_line ?? 0) - (b.start_line ?? 0) || a.id - b.id;
|
||||
}
|
||||
|
||||
/** One label per operation, the same with or without a filter, so the field never renames what it shows. */
|
||||
export function operationLabels(entries: SystemSymbol[]): Map<number, string> {
|
||||
const groups = new Map<string, SystemSymbol[]>();
|
||||
for (const entry of entries) groups.set(lookAlike(entry), [...groups.get(lookAlike(entry)) ?? [], entry]);
|
||||
const labels = new Map<number, string>();
|
||||
for (const group of groups.values()) {
|
||||
const ordered = [...group].sort((a, b) => a.id - b.id);
|
||||
const qualifiers = ordered.map(operationQualifier);
|
||||
const distinct = (values: unknown[]) => values.every(value => value !== undefined) && new Set(values).size === values.length;
|
||||
const byQualifier = group.length > 1 && distinct(qualifiers);
|
||||
const byLine = group.length > 1 && !byQualifier && distinct(ordered.map(item => item.start_line));
|
||||
ordered.forEach((entry, index) => {
|
||||
const where = place(entry) ?? '';
|
||||
labels.set(entry.id, group.length === 1 ? text.option(entry.name, where)
|
||||
: byQualifier ? text.option(text.qualified(qualifiers[index]!, entry.name), where)
|
||||
: byLine ? text.option(entry.name, text.line(where, entry.start_line!))
|
||||
: `${text.option(entry.name, where)}${text.ordinal(index + 1, group.length)}`);
|
||||
});
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function operationChoices(entries: SystemSymbol[], { suggested = [], query = '', keep }: { suggested?: number[]; query?: string; keep?: number }): OperationChoices {
|
||||
const labels = operationLabels(entries);
|
||||
const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
||||
const matches = (entry: SystemSymbol) => words.every(word => `${labels.get(entry.id)} ${entry.qualified_name}`.toLocaleLowerCase().includes(word));
|
||||
const choice = (symbol: SystemSymbol): OperationChoice => ({ id: symbol.id, label: labels.get(symbol.id)!, symbol });
|
||||
const shown = (entry: SystemSymbol) => entry.id === keep || matches(entry);
|
||||
const byId = new Map(entries.map(entry => [entry.id, entry]));
|
||||
const offered = [...new Set(suggested)].filter(id => byId.has(id));
|
||||
return {
|
||||
// Suggestions that name every operation would only repeat the list.
|
||||
suggested: offered.length >= entries.length ? [] : offered.flatMap(id => shown(byId.get(id)!) ? [choice(byId.get(id)!)] : []),
|
||||
all: entries.filter(shown).sort(compare).map(choice),
|
||||
total: entries.length,
|
||||
matching: entries.filter(matches).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** What `nameOperations` needs of the RPC client. */
|
||||
export interface OperationNameClient { queryRows: (project: string, query: string) => Promise<Record<string, string>[]> }
|
||||
|
||||
/* Only plain identifiers and paths enter the query text; anything with a quote or a backslash stays unnamed. */
|
||||
const PLAIN = /^[\w$<>@.\/\- ]+$/;
|
||||
|
||||
/**
|
||||
* Ranked flow starts carry no qualified name or line (/api/flows names only
|
||||
* the node, its file and its id). For the look-alikes among them the index is
|
||||
* asked once, by name and file, and the answer is matched by node id. Without
|
||||
* an answer the list stays as it was: the position still tells them apart.
|
||||
*/
|
||||
export async function nameOperations(project: string, entries: SystemSymbol[], client: OperationNameClient): Promise<SystemSymbol[]> {
|
||||
const groups = new Map<string, SystemSymbol[]>();
|
||||
for (const entry of entries) groups.set(lookAlike(entry), [...groups.get(lookAlike(entry)) ?? [], entry]);
|
||||
const unnamed = [...groups.values()].filter(group => group.length > 1).flat()
|
||||
.filter(entry => !entry.qualified_name && entry.file_path && PLAIN.test(entry.name) && PLAIN.test(entry.file_path));
|
||||
if (!unnamed.length) return entries;
|
||||
const list = (values: string[]) => `[${[...new Set(values)].map(value => `"${value}"`).join(', ')}]`;
|
||||
const query = `MATCH (n) WHERE n.name IN ${list(unnamed.map(entry => entry.name))} AND n.file_path IN ${list(unnamed.map(entry => entry.file_path!))} `
|
||||
+ 'RETURN id(n) AS id, n.qualified_name AS qualified_name, n.start_line AS start_line LIMIT 1000';
|
||||
let rows: Record<string, string>[];
|
||||
try { rows = await client.queryRows(project, query); } catch { return entries; }
|
||||
const found = new Map(rows.map(row => [Number(row.id), row]));
|
||||
const wanted = new Set(unnamed.map(entry => entry.id));
|
||||
return entries.map(entry => {
|
||||
const row = wanted.has(entry.id) ? found.get(entry.id) : undefined;
|
||||
if (!row?.qualified_name) return entry;
|
||||
const line = Number(row.start_line);
|
||||
return { ...entry, qualified_name: row.qualified_name, ...(Number.isSafeInteger(line) && line > 0 ? { start_line: line } : {}) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||
import { clockTime, RefreshControl, useRefreshFeedback, type RefreshReading } from './refresh-feedback';
|
||||
|
||||
let host: HTMLDivElement, root: Root;
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
|
||||
|
||||
const labels = { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Refreshed at ${time}` };
|
||||
let begin: () => void = () => {};
|
||||
function Fixture({ reading }: { reading: RefreshReading }) {
|
||||
const refresh = useRefreshFeedback(reading, () => new Date(2026, 9, 4, 7, 3, 9).getTime());
|
||||
begin = refresh.begin;
|
||||
return <RefreshControl labels={labels} feedback={refresh.feedback} onRefresh={refresh.begin} />;
|
||||
}
|
||||
const show = (reading: RefreshReading) => act(async () => root.render(<Fixture reading={reading} />));
|
||||
const status = () => host.querySelector('[role="status"]')?.textContent;
|
||||
|
||||
it('names the wall clock with two digits each', () => {
|
||||
expect(clockTime(new Date(2026, 9, 4, 7, 3, 9).getTime())).toBe('07:03:09');
|
||||
});
|
||||
|
||||
it('waits for the reading the refresh started, not the one shown when it was pressed', async () => {
|
||||
await show({ key: 'a', settled: true, value: { routes: 1 } });
|
||||
await act(async () => begin());
|
||||
// Still the earlier reading: nothing is decided yet.
|
||||
await show({ key: 'a', settled: true, value: { routes: 1 } });
|
||||
expect(host.querySelector('button')?.textContent).toBe('Refreshing…');
|
||||
await show({ key: 'b', settled: false });
|
||||
expect(status()).toBe('');
|
||||
await show({ key: 'b', settled: true, value: { routes: 1 } });
|
||||
expect(status()).toBe('Up to date at 07:03:09: no changes since the last load');
|
||||
});
|
||||
|
||||
it('drops the status once another reading than the refreshed one is shown', async () => {
|
||||
await show({ key: 'a', settled: true, value: 1 });
|
||||
await act(async () => begin());
|
||||
await show({ key: 'b', settled: true, value: 2 });
|
||||
expect(status()).toBe('Refreshed at 07:03:09');
|
||||
// Another start or a reindex: the status would speak about data no longer shown.
|
||||
await show({ key: 'c', settled: true, value: 3 });
|
||||
expect(status()).toBe('');
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useRef, useState, type JSX } from 'react';
|
||||
import { architectureText } from './strings';
|
||||
|
||||
const text = architectureText.refreshFeedback;
|
||||
|
||||
/**
|
||||
* What a Refresh button of Architecture says it did (hand test 2026-10-04, A3:
|
||||
* "Refresh connections" fetched everything again and nothing on screen
|
||||
* changed). While the reading runs the button reads "Refreshing …"; then a
|
||||
* status beside it names the time and whether anything changed, or the error.
|
||||
*/
|
||||
export type RefreshFeedback =
|
||||
| { phase: 'running' }
|
||||
| { phase: 'done'; at: number; changed: boolean }
|
||||
| { phase: 'failed'; at: number; error: string };
|
||||
|
||||
/**
|
||||
* The reading a view shows: its key (a refresh always leads to a new one),
|
||||
* whether it has settled, and what it holds. `value` is compared as JSON
|
||||
* before and after, so a view hands something small: the route snapshot, the
|
||||
* service topology, the analysis generation.
|
||||
*/
|
||||
export interface RefreshReading { key: string; settled: boolean; value?: unknown; error?: string }
|
||||
|
||||
const signature = (value: unknown) => JSON.stringify(value) ?? '';
|
||||
|
||||
export function useRefreshFeedback(reading: RefreshReading, now: () => number = Date.now): { feedback?: RefreshFeedback; begin: () => void } {
|
||||
const [feedback, setFeedback] = useState<RefreshFeedback>();
|
||||
const started = useRef<{ key: string; before: string } | undefined>(undefined);
|
||||
const described = useRef(reading.key);
|
||||
const latest = useRef(reading);
|
||||
latest.current = reading;
|
||||
const { key, settled, value, error } = reading;
|
||||
useEffect(() => {
|
||||
const start = started.current;
|
||||
if (start) {
|
||||
if (key === start.key || !settled) return;
|
||||
started.current = undefined;
|
||||
described.current = key;
|
||||
setFeedback(error !== undefined ? { phase: 'failed', at: now(), error } : { phase: 'done', at: now(), changed: signature(value) !== start.before });
|
||||
return;
|
||||
}
|
||||
// Another reading than the refreshed one (another start, a reindex): the status no longer speaks about what is shown.
|
||||
if (key !== described.current) { described.current = key; setFeedback(undefined); }
|
||||
}, [key, settled, value, error, now]);
|
||||
const begin = useCallback(() => {
|
||||
const current = latest.current;
|
||||
started.current = { key: current.key, before: signature(current.value) };
|
||||
setFeedback({ phase: 'running' });
|
||||
}, []);
|
||||
return { feedback, begin };
|
||||
}
|
||||
|
||||
const two = (value: number) => String(value).padStart(2, '0');
|
||||
/** The wall clock as the status names it: 13:45:12. */
|
||||
export function clockTime(at: number): string {
|
||||
const date = new Date(at);
|
||||
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function refreshStatus(feedback: RefreshFeedback | undefined, done: (time: string) => string): string {
|
||||
if (!feedback || feedback.phase === 'running') return '';
|
||||
const time = clockTime(feedback.at);
|
||||
return feedback.phase === 'failed' ? text.failed(time, feedback.error) : feedback.changed ? done(time) : text.unchanged(time);
|
||||
}
|
||||
|
||||
/**
|
||||
* The button and its status. A running refresh keeps the button in the focus
|
||||
* order (`aria-disabled`, not `disabled`), so a keyboard reader stays where
|
||||
* they were; a second press while it runs does nothing.
|
||||
*/
|
||||
export function RefreshControl({ labels, feedback, onRefresh, className }: {
|
||||
labels: { idle: string; busy: string; done: (time: string) => string };
|
||||
feedback?: RefreshFeedback; onRefresh: () => void; className?: string;
|
||||
}): JSX.Element {
|
||||
const running = feedback?.phase === 'running';
|
||||
return <span className="atlas-arch-refresh" data-phase={feedback?.phase}>
|
||||
<button type="button" className={className} aria-disabled={running || undefined} aria-busy={running || undefined}
|
||||
onClick={() => { if (!running) onRefresh(); }}>{running ? labels.busy : labels.idle}</button>
|
||||
<span className="atlas-arch-refresh-status" role="status" data-phase={feedback?.phase}>{refreshStatus(feedback, labels.done)}</span>
|
||||
</span>;
|
||||
}
|
||||
@@ -100,6 +100,32 @@ export const architectureText = {
|
||||
openedFile: 'Opened file',
|
||||
scopeMeasure: (files: number, lines?: number) => `${files.toLocaleString()} files · ${lines === undefined ? 'unknown' : lines.toLocaleString()} indexed lines`,
|
||||
openedScopeHint: 'Select a part or connection to inspect it. The location above leads back to the repository.',
|
||||
/** Behavior "Start": suggestions first, then every operation alphabetically, with a filter (hand test 2026-10-04, A4). */
|
||||
behaviorStart: {
|
||||
label: 'Start',
|
||||
field: 'Behavior entry point',
|
||||
choose: 'Choose an operation…',
|
||||
filter: 'Filter operations',
|
||||
filterPlaceholder: 'Filter operations…',
|
||||
suggested: 'Suggested',
|
||||
all: (total: number) => `All operations · ${total.toLocaleString()}`,
|
||||
matching: (shown: number, total: number) => `Matching operations · ${shown.toLocaleString()} of ${total.toLocaleString()}`,
|
||||
none: (query: string) => `No operation matches "${query}"`,
|
||||
option: (name: string, place: string) => `${name} · ${place}`,
|
||||
/** The name stays first, so the list still reads alphabetically: "database_backwards (CreateModel)". */
|
||||
qualified: (qualifier: string, name: string) => `${name} (${qualifier})`,
|
||||
line: (place: string, line: number) => `${place}:${line}`,
|
||||
ordinal: (index: number, count: number) => ` (${index} of ${count})`,
|
||||
},
|
||||
/** Each Refresh button says what it did (hand test 2026-10-04, A3): busy while it runs, then when, and whether anything changed. */
|
||||
refreshFeedback: {
|
||||
routes: { idle: 'Refresh connections', busy: 'Refreshing connections…', done: (time: string) => `Connections refreshed at ${time}` },
|
||||
services: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Service map refreshed at ${time}` },
|
||||
structure: { idle: 'Refresh analysis', busy: 'Refreshing analysis…', done: (time: string) => `Analysis refreshed at ${time}` },
|
||||
behavior: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Journey refreshed at ${time}` },
|
||||
unchanged: (time: string) => `Up to date at ${time}: no changes since the last load`,
|
||||
failed: (time: string, error: string) => `Refresh failed at ${time}: ${error}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -114,6 +140,9 @@ export const architectureHistoryText = {
|
||||
forward: 'Forward',
|
||||
backGlyph: '←',
|
||||
forwardGlyph: '→',
|
||||
/** The words while the subtab row has room, as in Galaxy; the glyphs alone once the tabs would scroll. */
|
||||
backWide: '← Back',
|
||||
forwardWide: 'Forward →',
|
||||
backTo: (label: string) => `Back to ${label} (Alt+Left)`,
|
||||
forwardTo: (label: string) => `Forward to ${label} (Alt+Right)`,
|
||||
noBack: 'Nothing to go back to yet',
|
||||
|
||||
@@ -207,6 +207,27 @@ it('K2: the recent list jumps straight to an earlier root with its last depth',
|
||||
expect(named('Back')?.title).toBe('Back to n5 · 1 layer (Alt+Left)');
|
||||
});
|
||||
|
||||
/* Handtest 2026-10-04 (A2): die Liste blieb ueber der Szene offen, Schritt um Schritt. */
|
||||
it('A2: the recent list closes on a press elsewhere, on Escape without leaving the scope, and when Back changes the root', async () => {
|
||||
await openScope();
|
||||
await act(async () => { seam().clickNode('sample.n2'); });
|
||||
await settle(() => expect(scopeName()).toBe('n2'));
|
||||
const recent = host.querySelector<HTMLDetailsElement>('details.atlas-graph-recent')!;
|
||||
await act(async () => { recent.open = true; });
|
||||
await act(async () => { button('Empty canvas')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); });
|
||||
expect(recent.open).toBe(false);
|
||||
|
||||
await act(async () => { recent.open = true; });
|
||||
expect(key({ key: 'Escape' }, recent.querySelector('summary')!).defaultPrevented).toBe(true);
|
||||
expect(recent.open).toBe(false);
|
||||
expect(scopeName()).toBe('n2');
|
||||
|
||||
await act(async () => { recent.open = true; });
|
||||
await act(async () => named('Back')!.click());
|
||||
await settle(() => expect(scopeName()).toBe('n1'));
|
||||
expect(recent.open).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
* Wie App.tsx: die Auswahl lebt ausserhalb des Panels, "Selection details"
|
||||
* steht nur, solange sie besteht, und `onClearSelection` loescht sie.
|
||||
|
||||
@@ -143,6 +143,7 @@ import { galaxyHierarchyText, galaxyHistoryText, galaxyLayerText, galaxyPathText
|
||||
import { HierarchyBandLabel, HierarchyEdgeLabels } from './HierarchyEdgeLabels';
|
||||
import { FitLabel, useToolbarFit } from './toolbar-fit';
|
||||
import { emptyNavigationHistory, moveNavigation, peekNavigation, pushNavigation } from '../graph/navigation-history';
|
||||
import { useDismissibleMenu } from '../graph/use-dismissible-menu';
|
||||
import { galaxyHistoryOptions, historyEntryDetail, historyEntryLabel, scopeIdentity, type GalaxyHistoryEntry, type ScopeTrail } from './scope-history';
|
||||
import { useOrganicLayout } from './use-organic-layout';
|
||||
import RenderProgress from './RenderProgress';
|
||||
@@ -2033,6 +2034,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [props.workspaceExpanded, scope.scope, trailView, escapeTaken, leaveScope]);
|
||||
/* Handtest 2026-10-04 (A2): die Liste schliesst bei einem Klick daneben, mit Escape und mit jedem neuen Ort. */
|
||||
const recentMenu = useDismissibleMenu(historyKey);
|
||||
const historyControls = props.workspaceExpanded ? <span className="atlas-graph-history" role="group" aria-label={galaxyHistoryText.group}>
|
||||
<button type="button" aria-label={galaxyHistoryText.back} disabled={!historyBack} onClick={() => goHistory(-1)}
|
||||
title={historyBack ? galaxyHistoryText.backTo(historyEntryLabel(historyBack)) : galaxyHistoryText.noBack}>
|
||||
@@ -2040,16 +2043,13 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
|
||||
<button type="button" aria-label={galaxyHistoryText.forward} disabled={!historyForward} onClick={() => goHistory(1)}
|
||||
title={historyForward ? galaxyHistoryText.forwardTo(historyEntryLabel(historyForward)) : galaxyHistoryText.noForward}>
|
||||
<FitLabel wide={galaxyHistoryText.forwardWide} narrow={galaxyHistoryText.forwardGlyph} /></button>
|
||||
{history.recent.length > 1 && <details className="atlas-graph-recent" onKeyDown={event => {
|
||||
if (event.key === 'Escape') { event.stopPropagation(); event.currentTarget.open = false; }
|
||||
}}>
|
||||
{history.recent.length > 1 && <details className="atlas-graph-recent" ref={recentMenu.ref}>
|
||||
<summary title={galaxyHistoryText.recentTitle} aria-label={galaxyHistoryText.recent}>{galaxyHistoryText.recentGlyph}</summary>
|
||||
<ul className="atlas-graph-recent-menu" aria-label={galaxyHistoryText.recentList}>{history.recent.map((entry) => {
|
||||
const current = galaxyHistoryOptions.recentKey?.(entry) === (scope.scope ? scopeIdentity(scope.scope) : undefined);
|
||||
return <li key={galaxyHistoryOptions.recentKey?.(entry)}>
|
||||
<button type="button" disabled={current} aria-current={current ? 'true' : undefined} onClick={(event) => {
|
||||
const details = event.currentTarget.closest('details');
|
||||
if (details) details.open = false;
|
||||
<button type="button" disabled={current} aria-current={current ? 'true' : undefined} onClick={() => {
|
||||
recentMenu.close();
|
||||
jumpToRecent(entry);
|
||||
}}><strong>{entry.scope?.name}</strong><span>{historyEntryDetail(entry)}</span></button>
|
||||
</li>;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { useDismissibleMenu } from './use-dismissible-menu';
|
||||
|
||||
/*
|
||||
* Hand test 2026-10-04 (A2): the Recent menus of Galaxy and Architecture stayed open over the content
|
||||
* through several steps and while scrolling. Both use this hook.
|
||||
*/
|
||||
let host: HTMLDivElement, root: Root;
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
|
||||
|
||||
function Fixture({ place, onPick }: { place: string; onPick: () => void }) {
|
||||
const menu = useDismissibleMenu(place);
|
||||
return <div>
|
||||
<details ref={menu.ref}><summary>Recent</summary>
|
||||
<ul><li><button type="button" onClick={() => { menu.close(); onPick(); }}>Overview</button></li></ul></details>
|
||||
<p>Content</p><input aria-label="Elsewhere" />
|
||||
</div>;
|
||||
}
|
||||
async function setup(place = 'a') {
|
||||
const pick = vi.fn();
|
||||
await act(async () => root.render(<Fixture place={place} onPick={pick} />));
|
||||
const details = host.querySelector('details')!;
|
||||
await act(async () => { details.open = true; });
|
||||
return { pick, details };
|
||||
}
|
||||
const fire = (target: EventTarget, type: string) => act(async () => { target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); });
|
||||
|
||||
it('closes on a pointer press outside the menu and stays open for one inside it', async () => {
|
||||
const { details } = await setup();
|
||||
await fire(details.querySelector('summary')!, 'pointerdown');
|
||||
await fire(details.querySelector('li button')!, 'pointerdown');
|
||||
expect(details.open).toBe(true);
|
||||
await fire(host.querySelector('p')!, 'pointerdown');
|
||||
expect(details.open).toBe(false);
|
||||
});
|
||||
|
||||
it('closes when the page scrolls under it or focus moves elsewhere', async () => {
|
||||
const { details } = await setup();
|
||||
await act(async () => { host.querySelector('p')!.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 })); });
|
||||
expect(details.open).toBe(false);
|
||||
await act(async () => { details.open = true; });
|
||||
// The list scrolls itself without closing.
|
||||
await act(async () => { details.querySelector('ul')!.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 })); });
|
||||
expect(details.open).toBe(true);
|
||||
await act(async () => { host.querySelector('input')!.focus(); });
|
||||
expect(details.open).toBe(false);
|
||||
});
|
||||
|
||||
it('closes on Escape, takes the key from the page and returns focus to its summary', async () => {
|
||||
const { details } = await setup();
|
||||
const entry = details.querySelector<HTMLButtonElement>('li button')!;
|
||||
entry.focus();
|
||||
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||||
const page = vi.fn();
|
||||
window.addEventListener('keydown', page);
|
||||
await act(async () => { entry.dispatchEvent(event); });
|
||||
window.removeEventListener('keydown', page);
|
||||
expect(details.open).toBe(false);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(page).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(details.querySelector('summary'));
|
||||
// Closed, Escape belongs to the page again.
|
||||
const later = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||||
await act(async () => { document.body.dispatchEvent(later); });
|
||||
expect(later.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('closes when an entry is chosen and whenever the place changes', async () => {
|
||||
const { pick, details } = await setup();
|
||||
await act(async () => details.querySelector<HTMLButtonElement>('li button')!.click());
|
||||
expect(pick).toHaveBeenCalledOnce();
|
||||
expect(details.open).toBe(false);
|
||||
await act(async () => { details.open = true; });
|
||||
await act(async () => root.render(<Fixture place="a" onPick={pick} />));
|
||||
expect(details.open).toBe(true);
|
||||
// Back, Forward, Alt+Left or any other step leads to another place.
|
||||
await act(async () => root.render(<Fixture place="b" onPick={pick} />));
|
||||
expect(details.open).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* A `<details>` menu that closes once the reader is done with it, as the
|
||||
* Recent menus of Galaxy and Architecture do (hand test 2026-10-04, A2: the
|
||||
* menu stayed open over the content through several steps and while
|
||||
* scrolling).
|
||||
*
|
||||
* It closes on a pointer press, a wheel turn or a focus outside it, on Escape
|
||||
* (which then belongs to the menu alone: the page neither leaves its scope nor
|
||||
* clears its selection), when the caller closes it after an entry was chosen,
|
||||
* and whenever `place` changes: Back, Forward, Alt+Left and every other step
|
||||
* lead somewhere else, and the list would name the wrong current place.
|
||||
*
|
||||
* The listeners stay on the document for the life of the menu and do nothing
|
||||
* while it is closed, so opening it needs no React state and no render.
|
||||
*/
|
||||
export function useDismissibleMenu(place: string) {
|
||||
const ref = useRef<HTMLDetailsElement>(null);
|
||||
const close = useCallback(() => {
|
||||
const menu = ref.current;
|
||||
if (menu?.open) menu.open = false;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const outside = (event: Event) => {
|
||||
const menu = ref.current;
|
||||
if (menu?.open && !(event.target instanceof Node && menu.contains(event.target))) menu.open = false;
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
const menu = ref.current;
|
||||
if (event.key !== 'Escape' || !menu?.open) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const inside = document.activeElement instanceof Node && menu.contains(document.activeElement);
|
||||
menu.open = false;
|
||||
// An entry that had focus is hidden now; the summary keeps the reader in place.
|
||||
if (inside) menu.querySelector('summary')?.focus();
|
||||
};
|
||||
document.addEventListener('pointerdown', outside, true);
|
||||
document.addEventListener('wheel', outside, { capture: true, passive: true });
|
||||
document.addEventListener('focusin', outside, true);
|
||||
document.addEventListener('keydown', escape, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', outside, true);
|
||||
document.removeEventListener('wheel', outside, { capture: true });
|
||||
document.removeEventListener('focusin', outside, true);
|
||||
document.removeEventListener('keydown', escape, true);
|
||||
};
|
||||
}, []);
|
||||
const shown = useRef(place);
|
||||
useEffect(() => {
|
||||
if (shown.current === place) return;
|
||||
shown.current = place;
|
||||
close();
|
||||
}, [place, close]);
|
||||
return { ref, close };
|
||||
}
|
||||
@@ -100,8 +100,11 @@ async function state(page) {
|
||||
behaviorCamera: text(root.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')),
|
||||
chain: text(root.querySelector('[aria-label="Walk the call chain"] span')),
|
||||
path: paths.length ? paths.findIndex((element) => element.getAttribute('aria-pressed') === 'true') + 1 : null,
|
||||
/* Das "← Back", das System structure und Behavior frueher in der eigenen Leiste hatten. */
|
||||
inViewBack: [...root.querySelectorAll('button')].filter((element) => element.textContent?.trim() === '← Back').length,
|
||||
/*
|
||||
* Das "← Back", das System structure und Behavior frueher in der eigenen Leiste hatten. Das gemeinsame
|
||||
* Zurueck traegt seit dem Handtest vom 2026-10-04 (A1) dieselben Worte wie in Galaxy und zaehlt nicht mit.
|
||||
*/
|
||||
inViewBack: [...root.querySelectorAll('button')].filter((element) => element.textContent?.trim() === '← Back' && !history?.contains(element)).length,
|
||||
back: button('Back'), forward: button('Forward'),
|
||||
position: history?.getAttribute('data-position') ?? null,
|
||||
recent: [...(history?.querySelectorAll('[aria-label="Recently visited places"] li button') ?? [])].map((element) => ({
|
||||
|
||||
Reference in new issue
Block a user