diff --git a/graph-ui/src/galaxy/GalaxyPanel.background.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.background.test.tsx index 92cbb4e43..55d878cca 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.background.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.background.test.tsx @@ -51,7 +51,7 @@ it('returns a standalone hierarchy selection to the overall Galaxy without stale await act(async () => select.click()); const evidence = JSON.parse(onSelectionEvidence.mock.lastCall![0].text).evidence; expect(evidence.selected.scope).toMatchObject({ kind: 'node', id: 1, name: 'node1' }); - expect(evidence.scope).toMatchObject({ depth: 0, direction: 'both', edgeTypes: 'all' }); + expect(evidence.scope).toMatchObject({ depth: 1, direction: 'both', edgeTypes: 'all' }); expect(evidence.limitations.state).not.toBe('complete-indexed-scope'); await act(async () => host.querySelector('[data-mode="hierarchy"]')!.click()); expect(globalThis.__atlasGalaxy?.mode).toBe('hierarchy'); diff --git a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx new file mode 100644 index 000000000..39966510b --- /dev/null +++ b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx @@ -0,0 +1,274 @@ +// @vitest-environment jsdom +/* + * Review-Befund G1: der Canvas wurde bei jedem Scope-Schritt neu aufgebaut und + * die Kamera bei jedem Bild neu eingepasst. Hier steht die Szene als Attrappe, + * die mitzaehlt, wie oft sie auf- und abgebaut wird; die Beziehungen kommen + * ueber dieselbe RPC-Strecke wie im Betrieb. + */ +import { act, useEffect, useLayoutEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import GalaxyPanel from './GalaxyPanel'; +import type { GraphData, GraphEdge, GraphNode } from './types'; +import type { ScenePath } from './PathLayer'; +import { GALAXY_LEGEND_KEY } from './galaxy-legend'; + +const scene = vi.hoisted(() => ({ mounts: 0, unmounts: 0, renders: 0, roots: undefined as ReadonlySet | undefined, + highlighted: null as Set | null, path: undefined as ScenePath | undefined, + /** Every committed picture: what the scene draws and what the edge type filter lists beside it. */ + frames: [] as { nodes: number; types: string[]; traceRows: number }[] })); +vi.mock('./GraphScene', async importOriginal => ({ + ...await importOriginal(), + GraphScene: ({ data, rootIds, highlightedIds, path }: { data: GraphData; rootIds?: ReadonlySet; highlightedIds: Set | null; path?: ScenePath }) => { + scene.renders += 1; scene.roots = rootIds; scene.highlighted = highlightedIds; scene.path = path; + useEffect(() => { scene.mounts += 1; return () => { scene.unmounts += 1; }; }, []); + useLayoutEffect(() => { + scene.frames.push({ nodes: data.nodes.length, types: [...new Set(data.edges.map(edge => edge.type))].sort(), + traceRows: document.querySelectorAll('.atlas-trace-edge-row').length }); + }); + return {data.nodes.length}; + }, +})); + +const node = (id: number): GraphNode => ({ id, name: `n${id}`, qualified_name: `sample.n${id}`, label: 'Function', + file_path: `src/n${id}.ts`, start_line: 1, end_line: 5, x: id * 10, y: 0, z: 0, size: 2, color: '#999999' }); +const nodes = [1, 2, 3, 4, 5].map(node); +const edges: GraphEdge[] = [ + { source: 1, target: 2, type: 'CALLS', line: 3 }, { source: 3, target: 1, type: 'CALLS', line: 4 }, + { source: 2, target: 4, type: 'CALLS', line: 2 }, { source: 4, target: 5, type: 'IMPORTS' }, + { source: 1, target: 5, type: 'CALLS', line: 1 }, +]; + +/** /api/layout plus the two query_graph shapes the scope loader sends. */ +function graphFetch() { + return vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + if (String(url).includes('/api/layout')) return new Response(JSON.stringify({ nodes, edges, total_nodes: nodes.length })); + const request = JSON.parse(String(init?.body)) as { params: { name: string; arguments: { query: string } } }; + if (request.params.name === 'index_status') return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { content: [{ type: 'text', text: JSON.stringify({ indexed_at: 'generation-1' }) }] } })); + const query = request.params.arguments.query; + const columns = (prefix: string) => ['id', 'label', 'name', 'qn', 'file', 'start_line', 'end_line'].map(key => prefix + key); + const values = (entry: GraphNode) => [entry.id, entry.label, entry.name, entry.qualified_name ?? '', entry.file_path ?? '', entry.start_line ?? '', entry.end_line ?? ''].map(String); + const names = [...query.matchAll(/qualified_name = "([^"]+)"/g)].map(match => match[1]); + let cols: string[], rows: string[][]; + if (query.startsWith('MATCH (n)')) { + cols = columns(''); rows = nodes.filter(entry => names.includes(entry.qualified_name ?? '')).map(values); + } else { + cols = ['edge_id', 'edge_type', 'edge_line', ...columns('a_'), ...columns('b_')]; + rows = edges.flatMap((edge, i) => { + const a = nodes.find(entry => entry.id === edge.source)!, b = nodes.find(entry => entry.id === edge.target)!; + return names.includes(a.qualified_name ?? '') || names.includes(b.qualified_name ?? '') + ? [[String(i + 1), edge.type, String(edge.line ?? ''), ...values(a), ...values(b)]] : []; + }); + } + const text = `rows: ${rows.length} (cols: ${cols.join(' ')})\n${rows.map(row => ' ' + row.map(value => JSON.stringify(value || '-')).join(' ')).join('\n')}\ntotal: ${rows.length}\nhas_more: false\ntruncated: false`; + return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { content: [{ type: 'text', text }] } })); + }); +} + +let host: HTMLDivElement, root: Root; +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + scene.mounts = 0; scene.unmounts = 0; scene.renders = 0; scene.frames = []; + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; }); + +const seam = () => globalThis.__atlasGalaxy!; +const settle = (check: () => void) => vi.waitFor(async () => { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + check(); +}); + +it('keeps one scene mounted through selection and expansion and fits once per selected root', async () => { + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(5)); + expect(scene.mounts).toBe(1); + const fitsBefore = seam().fits; + // The whole graph keeps its render limits in the toolbar row. + expect(host.querySelector('.atlas-graph-limits')).toBeNull(); + expect(host.querySelector('select[aria-label="Rendered node limit"]')).not.toBeNull(); + + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => { + expect(seam().nodes).toBe(4); + expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe('4 nodes · 3 edges'); + }); + expect(scene.mounts).toBe(1); expect(scene.unmounts).toBe(0); + expect(seam().fits).toBe(fitsBefore + 1); + // In a scope the limits step back into a compact menu so the row does not wrap. + expect(host.querySelector('details.atlas-graph-limits select[aria-label="Rendered node limit"]')).not.toBeNull(); + expect(host.querySelector('details.atlas-graph-limits select[aria-label="Rendered edge limit"]')).not.toBeNull(); + // The root is marked, sits at the origin and the fit is centred on it. + expect([...scene.roots ?? []]).toEqual([1]); + expect(seam().lastFit?.center?.map(value => value + 0)).toEqual([0, 0, 0]); + + const expand = [...host.querySelectorAll('button')].find(button => button.textContent === 'Expand +1')!; + await act(async () => expand.click()); + await settle(() => expect(seam().nodes).toBe(5)); + expect(scene.mounts).toBe(1); expect(scene.unmounts).toBe(0); + // An expansion is the same scope: no new fit, the scene keeps the reader's camera. + expect(seam().fits).toBe(fitsBefore + 1); + + await act(async () => { seam().clickNode('sample.n4'); }); + await settle(() => expect(seam().lastFit?.nodes).toBe(3)); + expect(scene.mounts).toBe(1); + expect(seam().fits).toBe(fitsBefore + 2); +}); + +const button = (label: string) => [...host.querySelectorAll('button')].find(entry => entry.textContent === label)!; +const steps = () => [...host.querySelectorAll('[data-testid="atlas-galaxy-path-panel"] li code')].map(entry => entry.textContent); +const heading = () => host.querySelector('[data-testid="atlas-galaxy-path-panel"] strong')?.textContent; + +it('highlights a path and the call order of the root inside the loaded scope, and clears with Escape or a new trace', async () => { + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await act(async () => button('Expand +1').click()); + await settle(() => expect(seam().nodes).toBe(5)); + + await act(async () => button('Call order').click()); + expect(heading()).toBe('Calls of n1 · 2 calls'); + expect(steps()).toEqual(['n1 --CALLS--> n5', 'n1 --CALLS--> n2']); + expect([...scene.highlighted ?? []].sort()).toEqual([1, 2, 5]); + expect(scene.path).toMatchObject({ active: 0, labels: 'active' }); + await act(async () => button('Next').click()); + expect(scene.path?.active).toBe(1); + + host.querySelector('.atlas-graph-path-picker')!.open = true; + const target = [...host.querySelectorAll('.atlas-graph-path-menu li button')] + .find(entry => entry.querySelector('strong')?.textContent === 'n4')!; + await act(async () => target.click()); + expect(heading()).toBe('Path to n4 · 2 hops'); + expect(steps()).toEqual(['n1 --CALLS--> n2', 'n2 --CALLS--> n4']); + expect([...scene.highlighted ?? []].sort()).toEqual([1, 2, 4]); + expect(scene.path).toMatchObject({ active: 0, labels: 'all' }); + expect(seam().nodes).toBe(5); + + await act(async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }); + expect(host.querySelector('[data-testid="atlas-galaxy-path-panel"]')).toBeNull(); + expect(scene.path).toBeUndefined(); + + // Incoming: the path walks the CALLS edge backwards and says so. + const direction = (value: string) => act(async () => { + const select = host.querySelector('select[aria-label="Trace direction"]')!; + select.value = value; select.dispatchEvent(new Event('change', { bubbles: true })); + }); + await direction('inbound'); + await settle(() => expect(seam().nodes).toBe(2)); + host.querySelector('.atlas-graph-path-picker')!.open = true; + const caller = [...host.querySelectorAll('.atlas-graph-path-menu li button')] + .find(entry => entry.querySelector('strong')?.textContent === 'n3')!; + await act(async () => caller.click()); + expect(heading()).toBe('Path to n3 · 1 hop'); + expect(steps()).toEqual(['n1 <--CALLS-- n3']); + // A new trace direction is a new scope: the path view returns to the normal picture. + await direction('outbound'); + expect(host.querySelector('[data-testid="atlas-galaxy-path-panel"]')).toBeNull(); +}); + +const panel = () => host.querySelector('[data-testid="atlas-galaxy-path-panel"]'); +const traceDirection = (value: string) => act(async () => { + const select = host.querySelector('select[aria-label="Trace direction"]')!; + select.value = value; select.dispatchEvent(new Event('change', { bubbles: true })); +}); +const pickPath = (name: string) => act(async () => { + host.querySelector('.atlas-graph-path-picker')!.open = true; + [...host.querySelectorAll('.atlas-graph-path-menu li button')] + .find(entry => entry.querySelector('strong')?.textContent === name)!.click(); +}); + +it('drops a path on a new scope key, so returning to the old key does not bring it back', async () => { + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await pickPath('n2'); + expect(heading()).toBe('Path to n2 · 1 hop'); + + // Incoming and back to both directions: the same key as before, but the path is gone. + await traceDirection('inbound'); + await settle(() => expect(seam().nodes).toBe(2)); + expect(panel()).toBeNull(); + await traceDirection('both'); + await settle(() => expect(seam().nodes).toBe(4)); + expect(panel()).toBeNull(); + expect(scene.path).toBeUndefined(); + + // The whole graph and the same root again: a fresh picture without the old path. + await act(async () => button('Call order').click()); + expect(heading()).toBe('Calls of n1 · 2 calls'); + await act(async () => button('All graph').click()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + expect(panel()).toBeNull(); + expect(button('Call order').getAttribute('aria-pressed')).toBe('false'); +}); + +it('leaves Escape to an overlay above the galaxy and to a field being typed in', async () => { + const fetch = graphFetch(); + const panelWith = (escapeTaken: boolean) => ; + await act(async () => root.render(panelWith(false))); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await pickPath('n2'); + expect(panel()).not.toBeNull(); + const escape = (target: EventTarget) => { + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + act(() => { target.dispatchEvent(event); }); + return event; + }; + + // Help, settings or projects lie above: the key is theirs. + await act(async () => root.render(panelWith(true))); + expect(escape(window).defaultPrevented).toBe(false); + expect(panel()).not.toBeNull(); + + // Typing in a field: Escape leaves the field, not the path. + await act(async () => root.render(panelWith(false))); + const field = host.querySelector('input[aria-label="Find a graph node"]')!; + expect(escape(field).defaultPrevented).toBe(false); + expect(panel()).not.toBeNull(); + + expect(escape(window).defaultPrevented).toBe(true); + expect(panel()).toBeNull(); +}); + +it('keeps the picked name when a removed layer takes the path target out of the picture', async () => { + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await act(async () => button('Expand +1').click()); + await settle(() => expect(seam().nodes).toBe(5)); + await pickPath('n4'); + expect(heading()).toBe('Path to n4 · 2 hops'); + + await act(async () => host.querySelector('button[aria-label="Remove graph layer"]')!.click()); + await settle(() => expect(seam().nodes).toBe(4)); + expect(heading()).toBe('Path to n4 · 0 hops'); + expect(panel()?.textContent).toContain('No path to n4 over the loaded relationships'); + expect(panel()?.textContent).not.toContain('#4'); +}); + +it('shows the whole graph with its hidden edge kinds until the first scoped picture, and lists only current kinds', async () => { + await act(async () => root.render( (key === GALAXY_LEGEND_KEY ? 'open' : null), setItem: vi.fn() } as unknown as Storage} />)); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => host.querySelector('[data-testid="atlas-galaxy-legend-swatch"][data-type="CALLS"]')!.click()); + expect(seam().hiddenKinds).toEqual(['CALLS']); + expect(seam().drawnEdges).toBe(1); + + scene.frames = []; + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + const placeholder = scene.frames.filter(frame => frame.nodes === 5); + expect(placeholder.length).toBeGreaterThan(0); + // The whole graph that stays on screen keeps the reader's choice ... + expect(placeholder.every(frame => frame.types.join() === 'IMPORTS')).toBe(true); + // ... and the trace filter beside it never offers the whole graph's kinds as the scope's. + expect(placeholder.every(frame => frame.traceRows === 0)).toBe(true); + expect(scene.frames.at(-1)).toMatchObject({ nodes: 4, types: ['CALLS'], traceRows: 1 }); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 252030257..67276925c 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -137,6 +137,9 @@ import type { LabelBox } from './NodeLabels'; import { NodeTooltipCard } from './NodeTooltipCard'; import GalaxyNavigator from './GalaxyNavigator'; import { TraceEdgeFilter } from './TraceEdgeFilter'; +import { PathPicker, PathSteps } from './ScopePathControls'; +import { callOrder, pathNodes, shortestScopePath, type ScopePathStep } from './scope-path'; +import { galaxyPathText, galaxyToolbarText } from './galaxy-strings'; import { useOrganicLayout } from './use-organic-layout'; import RenderProgress from './RenderProgress'; import { useGraphScope } from './use-graph-scope'; @@ -212,6 +215,7 @@ import type { AgentsRuntime } from '../agents/agent-source'; import { loadAgentsPreference, saveAgentsPreference } from '../agents/agent-preference'; import type { AgentsPreference } from '../agents/agent-preference'; import { fullscreenIsolationRequired, isolateFullscreenBackground } from './fullscreen-isolation'; +import { isTypingTarget } from '../app/keyboard'; /** * Wie lange die Ereigniszeile des FOLLOW-Modus stehen bleibt. @@ -424,6 +428,8 @@ export interface AtlasGalaxySeam { depth: number; normal: [number, number, number]; up: [number, number, number]; + /** Worauf die Kamera sieht: in einem Scope die Wurzel. */ + center?: [number, number, number]; } | undefined; /** Genau der Satz, der im Kopf steht. */ headline: string; @@ -774,6 +780,18 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { if (!scope.scope) organicHistory.current = undefined; }, [scope.complete, scope.scope, organic, organicKey, scope.depth]); const data = scope.scope ? organic?.data : layout; + /* + * Was die Szene zeigt, solange das naechste Bild noch angeordnet wird. + * + * Bis hierher war `data` waehrend jeder Anordnung leer, die Szene wurde + * ausgehaengt, und jeder Schritt (Auswahl, Expand, Vorschau zu Antwort) + * baute den Canvas neu auf (Review-Befund G1). Jetzt bleibt das letzte Bild + * stehen, bis das neue fertig ist; vor dem ersten Bild eines Scopes ist das + * der ganze Graph, der gerade noch zu sehen war. `data` bleibt dabei das + * AKTUELLE Bild: Einpassung, Legende und Zaehler rechnen nie mit dem alten. + */ + const sceneData = scope.scope ? (organic ?? organicTask.stale)?.data ?? layout : layout; + const sceneScoped = Boolean(scope.scope) && sceneData !== layout; const scopedRoot = scope.result?.roots.size === 1 ? scope.result.data.nodes.find(node => scope.result!.roots.has(node.id)) : undefined; const notifiedScopedSymbol = useRef(''); @@ -1121,17 +1139,27 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const hiddenHere = kinds.filter((kind) => hiddenKinds.has(kind.type)).length; const kindNote = edgeKindNote(kinds.length, hiddenHere); - const shown = useMemo( + /* + * Die Szene bekommt in der Galaxie das stehende Bild (siehe `sceneData`). + * Steht dort noch der ganze Graph, gelten auch seine ausgeblendeten + * Kantenarten weiter; erst ein Scope-Bild filtert ueber `traceTypes`. + * `shown` ist nur das AKTUELLE Bild: Kantenfilter, Zaehler und Auskunft an + * den Chat rechnen nie mit dem Platzhalter. + */ + const scenePicture = mode === 'hierarchy' ? picture : sceneData; + const sceneShown = useMemo( () => { - if (!picture) return undefined; - const filtered = props.workspaceExpanded && scope.scope ? picture : withoutEdgeKinds(picture, hiddenKinds); + if (!scenePicture) return undefined; + const traced = props.workspaceExpanded && scope.scope && (mode === 'hierarchy' || sceneScoped); + const filtered = traced ? scenePicture : withoutEdgeKinds(scenePicture, hiddenKinds); const requiredNames = new Set(data?.nodes.filter(node => scope.result?.roots.has(node.id)).map(node => node.qualified_name)); return props.workspaceExpanded ? limitGraphRender(filtered, nodeBudget, edgeBudget, mode === 'hierarchy' ? new Set(filtered.nodes.filter(node => requiredNames.has(node.qualified_name)).map(node => node.id)) : scope.result?.roots) : filtered; }, - [picture, hiddenKinds, props.workspaceExpanded, nodeBudget, edgeBudget, scope.result?.roots, scope.scope, mode, data], + [scenePicture, hiddenKinds, props.workspaceExpanded, nodeBudget, edgeBudget, scope.result?.roots, scope.scope, mode, data, sceneScoped], ); + const shown = mode === 'hierarchy' || sceneData === data ? sceneShown : undefined; const traceKinds = useMemo(() => edgeKinds(shown), [shown]); const agentEvidence = useMemo(() => { @@ -1169,6 +1197,76 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { }); }, [props.workspaceExpanded, scope.scope, traceTypes, kinds, changeTraceTypes]); + const renderLimits = <> + + + ; + + /* + * Der Pfad und die Aufrufreihe (Review-Befund G5, nach dem Vorbild von + * Graphify). + * + * Beide rechnen nur auf dem, was dieser Scope schon geladen hat, und in der + * Richtung, in der er verfolgt wird: der Server wird nicht gefragt, und ein + * fehlender Pfad heisst "nicht in diesen Beziehungen", nicht "gibt es + * nicht". Der Abstand ist hier die Zahl der Hops und nur hier (G6); die + * Wolke selbst behauptet keinen. Die Wahl haengt am Scope (`organicKey`): + * eine neue Wurzel, Richtung oder Kantenart verwirft sie, ein Expand + * rechnet sie auf dem groesseren Bild neu. Verworfen heisst verworfen: wer + * die Richtung zurueckstellt oder dieselbe Wurzel neu waehlt, bekommt den + * alten Pfad nicht ungefragt wieder. Das Ziel behaelt seinen Namen, auch + * wenn ein entfernter Layer es aus dem Bild nimmt. + */ + const [trail, setTrail] = useState<{ key: string; kind: 'path'; target: number; name: string } | { key: string; kind: 'calls' }>(); + const [trailStep, setTrailStep] = useState(0); + useEffect(() => { + setTrail(current => (current && current.key !== organicKey ? undefined : current)); + setTrailStep(0); + }, [organicKey]); + const trailRoot = scope.result?.roots.size === 1 ? [...scope.result.roots][0] : undefined; + const rootCalls = useMemo(() => (data && trailRoot !== undefined ? callOrder(data.edges, trailRoot) : []), [data, trailRoot]); + const pathCandidates = useMemo(() => data?.nodes.filter(node => !scope.result?.roots.has(node.id)) ?? [], [data, scope.result]); + const trailView = useMemo(() => { + if (!props.workspaceExpanded || mode !== 'galaxy' || trail?.key !== organicKey || !data || !scope.result) return undefined; + const names = new Map(data.nodes.map(node => [node.id, node.name])); + const nameOf = (id: number) => names.get(id) ?? `#${id}`; + if (trail.kind === 'calls') { + return { nameOf, lines: true, labels: 'active' as const, steps: rootCalls, + heading: galaxyPathText.callsHeading(trailRoot === undefined ? '' : nameOf(trailRoot), rootCalls.length) }; + } + const steps: ScopePathStep[] | undefined = shortestScopePath(data.edges, scope.result.roots, trail.target, scope.direction); + const target = names.get(trail.target) ?? trail.name; + return { nameOf, lines: false, labels: 'all' as const, steps: steps ?? [], + heading: galaxyPathText.pathHeading(target, steps?.length ?? 0), + note: steps === undefined ? galaxyPathText.noPath(target) : steps.length === 0 ? galaxyPathText.isRoot(target) : undefined }; + }, [props.workspaceExpanded, mode, trail, organicKey, data, scope.result, scope.direction, rootCalls, trailRoot]); + const trailActive = trailView ? Math.min(trailStep, Math.max(0, trailView.steps.length - 1)) : 0; + const trailIds = useMemo(() => trailView?.steps.length && scope.result ? pathNodes(trailView.steps, scope.result.roots) : undefined, + [trailView, scope.result]); + const clearTrail = useCallback(() => { setTrail(undefined); setTrailStep(0); }, []); + /* + * Escape gibt den Pfad frei, aber erst nach allen, die vorgehen: liegt eine + * Flaeche darueber (`escapeTaken`, dieselbe Reihenfolge wie beim + * Vollbild), gehoert die Taste ihr, und wer in einem Feld oder im Editor + * tippt, verlaesst mit Escape das Feld und nicht den Pfad. + */ + const escapeTaken = props.escapeTaken === true; + useEffect(() => { + if (!trailView || escapeTaken) return; + const onKey = (event: globalThis.KeyboardEvent): void => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + if (isTypingTarget(event.target instanceof Element ? event.target : null)) return; + event.preventDefault(); + clearTrail(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [trailView, escapeTaken, clearTrail]); + const index = useMemo( () => (picture === undefined ? new Map() : nodesByQualifiedName(picture.nodes)), [picture], @@ -1439,8 +1537,23 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const refitNow = useCallback(() => setOwnFit((count) => count + 1), []); const coverageShadow = useMemo(() => showCoverage && props.workspaceExpanded && mode === 'galaxy' && data ? buildCoverageShadow(data) : null, [data, props.workspaceExpanded, mode, showCoverage]); - const fitRequest = useMemo(() => ({ picture, mode, projection, requestedFit, ownFit, coverageShadow }), - [picture, mode, projection, requestedFit, ownFit, coverageShadow]); + /* + * Was eine neue Einpassung verlangt. + * + * In der gewaehlten Galaxie ist das der Scope selbst (Wurzel, Richtung, + * Kantenarten) und nicht sein Bild: ein Expand, eine Vorschau und die + * vollstaendige Antwort danach sind derselbe Ausschnitt, und eine Kamera, + * die bei jedem davon neu einpasst, stellt den Leser jedes Mal anders hin + * (Review-Befund G1). Wie weit die Kamera fuer das neue Bild stehen muss, + * stellt die Szene selbst nach, entlang derselben Blickrichtung und nur, + * solange der Leser die Kamera nicht bewegt hat (GraphScene, + * `FitContainment`). + */ + const fitScope = mode === 'galaxy' && props.workspaceExpanded && scope.scope ? organicKey : undefined; + const fitPicture = fitScope === undefined ? picture : undefined; + const fitProjection = fitScope === undefined ? projection : undefined; + const fitRequest = useMemo(() => ({ scope: fitScope, picture: fitPicture, projection: fitProjection, mode, requestedFit, ownFit, coverageShadow }), + [fitScope, fitPicture, fitProjection, mode, requestedFit, ownFit, coverageShadow]); const lastFitRequest = useRef(undefined); useEffect(() => { @@ -1454,10 +1567,12 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { skipNextFit.current = false; return; } - lastFitRequest.current = fitRequest; if (mode === 'hierarchy' && projection !== undefined) { const box = hierarchyFrame(projection); const target = computeFrameTarget(box, aspect); + // Erst eine Einpassung, die wirklich lief, verbraucht die Anfrage: + // ein Scope, dessen Bild noch angeordnet wird, passt danach ein. + lastFitRequest.current = fitRequest; setCameraTarget(target); targetChanges.current += 1; fitCount.current += 1; @@ -1480,10 +1595,22 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { if (nodes.length === 0) { return; } - const target = computeFitTarget(nodes, aspect); + /* + * Die Wurzel steht in der Mitte (Review-Befund G4). Gerahmt wird um sie + * herum, weit genug, dass auch der fernste Knoten passt; ohne das stand + * die Mitte der Wolke im Bild und die Wurzel irgendwo darin. + */ + const roots = fitScope === undefined ? [] : nodes.filter((node) => scope.result?.roots.has(node.id)); + const center = roots.length === 0 ? undefined : { + x: roots.reduce((sum, node) => sum + node.x, 0) / roots.length, + y: roots.reduce((sum, node) => sum + node.y, 0) / roots.length, + z: roots.reduce((sum, node) => sum + node.z, 0) / roots.length, + }; + const target = computeFitTarget(nodes, aspect, center); if (target === null) { return; } + lastFitRequest.current = fitRequest; setCameraTarget(target); targetChanges.current += 1; fitCount.current += 1; @@ -1499,8 +1626,11 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { depth: fit.depth, normal: [fit.normal.x, fit.normal.y, fit.normal.z], up: [fit.up.x, fit.up.y, fit.up.z], + center: [fit.center.x, fit.center.y, fit.center.z], }; - }, [visible, mode, projection, picture, aspect, requestedFit, ownFit, coverageShadow, fitRequest, props.workspaceExpanded]); + // `scope.result` folgt dem Bild und loest selbst keine Einpassung aus. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, mode, projection, picture, aspect, requestedFit, ownFit, coverageShadow, fitRequest, props.workspaceExpanded, fitScope]); const [backgroundCleared, setBackgroundCleared] = useState(false); useEffect(() => { @@ -1621,9 +1751,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { * die Oberflaeche legt, und zwar ZULETZT: liegt eine andere Flaeche darueber * (Hilfe, Einstiegsdialog, Suchfenster, Einstellungen), gehoert die Taste * ihr. Der Vollbildmodus reiht sich in die bestehende Reihenfolge ein, er - * draengt sich nicht vor. + * draengt sich nicht vor (`escapeTaken` steht beim Pfad weiter oben). */ - const escapeTaken = props.escapeTaken === true; useEffect(() => { const node = panel.current; if (node === null || !fullscreenIsolationRequired(fullscreen, escapeTaken)) { @@ -1704,6 +1833,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const selected = layoutNodeForSelection(layout, node) ?? layoutNodeForSelection(data, node) ?? node; props.onSelectNode?.(selected); if (props.workspaceExpanded) { + clearTrail(); scope.select({ kind: 'node', id: selected.id, name: selected.name, qualifiedName: selected.qualified_name }); setNote(''); return; @@ -1715,19 +1845,20 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { setNote(''); onOpenNode(selected); }, - [layout, data, picture, mode, flyTo, onOpenNode, props.onSelectNode, props.workspaceExpanded, scope.select], + [layout, data, picture, mode, flyTo, onOpenNode, props.onSelectNode, props.workspaceExpanded, scope.select, clearTrail], ); const handleBackgroundClick = useCallback(() => { setBackgroundCleared(true); if (props.workspaceExpanded) setChosenMode('galaxy'); + clearTrail(); scope.reset(); changeTraceTypes(undefined); setHighlighted(null); setNote(props.workspaceExpanded ? '' : mode === 'hierarchy' ? readerProjection?.message ?? HIERARCHY_NO_FOCUS_NOTE : GALAXY_NO_FOCUS_NOTE); refitNow(); props.onClearSelection?.(); - }, [mode, refitNow, props.onClearSelection, readerProjection, scope.reset, props.workspaceExpanded, changeTraceTypes]); + }, [mode, refitNow, props.onClearSelection, readerProjection, scope.reset, props.workspaceExpanded, changeTraceTypes, clearTrail]); /* * Die Vorgabe der Ansicht, mit der Wahl des Lesers darauf. @@ -1742,7 +1873,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { mode === 'hierarchy' ? HIERARCHY_DISPLAY : DEFAULT_DISPLAY_SETTINGS, choice, ); - const layoutState = error.length > 0 ? 'failed' : data === undefined ? 'loading' : 'ready'; + // Ein stehendes Bild ist kein Ladezustand: die Werkzeugleiste sagt, dass angeordnet wird. + const layoutState = error.length > 0 ? 'failed' : sceneData === undefined ? 'loading' : 'ready'; // Die Hierarchie braucht das Layout nicht: sie faerbt sich damit, sie lebt // nicht davon. Ein Walk, dessen Bild dasteht, ist fertig. const state = mode === 'hierarchy' ? projection ? 'ready' @@ -1759,7 +1891,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { ? `layout unavailable: ${error}` : layoutState === 'loading' ? `loading the layout of ${project.length > 0 ? project : 'no project'} ...` - : layoutSummary(data as GraphData, LAYOUT_NODE_BUDGET); + : layoutSummary((data ?? sceneData) as GraphData, LAYOUT_NODE_BUDGET); /* * Die zweite Zeile des Kopfes: woraus die Linien bestehen (W9). @@ -1944,7 +2076,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { pulsedQn: pulsedNode?.qualified_name ?? pulsedNode?.name ?? '', edgeKinds: kinds.map((kind) => ({ ...kind, hidden: hiddenKinds.has(kind.type) })), hiddenKinds: [...hiddenKinds].sort(), - drawnEdges: shown?.edges.length ?? 0, + drawnEdges: sceneShown?.edges.length ?? 0, edgeNote, hierarchy: projection === undefined @@ -2205,7 +2337,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { )} {(props.workspaceExpanded || scope.scope) &&
- {props.workspaceExpanded && { props.onClearSelection?.(); setBackgroundCleared(false); scope.select(next); }} />} + {props.workspaceExpanded && { props.onClearSelection?.(); setBackgroundCleared(false); clearTrail(); scope.select(next); }} />} {scope.scope ? <> {props.workspaceExpanded && } {scope.scope.name} @@ -2215,25 +2347,39 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { } {props.workspaceExpanded && kind.type)} selected={traceTypes} onChange={changeTraceTypes} />} - {scope.depth} {scope.depth === 1 ? 'layer' : 'layers'} + {props.workspaceExpanded && mode === 'galaxy' && <> + { setTrail({ key: organicKey, kind: 'path', target: node.id, name: node.name }); setTrailStep(0); }} /> + + } {scope.validating ? 'Checking index…' : scope.loading ? 'Loading relationships…' : organicTask.loading ? 'Arranging nodes…' : scope.complete ? `${data?.nodes.length ?? 0} ${data?.nodes.length === 1 ? 'node' : 'nodes'} · ${data?.edges.length ?? 0} ${data?.edges.length === 1 ? 'edge' : 'edges'}${scope.result?.exhausted ? ' · end of trace' : ''}` : 'Partial preview'} {scope.error && Some relationships could not be loaded. } {!props.workspaceExpanded && scope.complete && All indexed direct dependencies included.} - {mode === 'galaxy' && organic && organic.groups.length > 1 && {organic.groups.length} connection groups} + {mode === 'galaxy' && organic && organic.groups.length > 1 && {galaxyToolbarText.groups(organic.groups.length)}} : null} {props.workspaceExpanded && <> - - + {/* + * Im Scope tritt der Deckel in ein Aufklappfeld zurueck + * (Review-Befund G7): die Leiste traegt dort Pfad, + * Aufrufreihe und Zaehler, und bei 1600 Pixeln brach die + * Zeile sonst um. Ein Scope liegt fast immer unter dem + * Deckel; im ganzen Graphen bleibt er in der Zeile. + */} + {scope.scope ?
{ + if (event.key === 'Escape') { event.stopPropagation(); event.currentTarget.open = false; } + }}> + {galaxyToolbarText.limits} +
{renderLimits}
+
: renderLimits} {mode === 'galaxy' && !scope.scope &&