@principal-ai/subsystems-react 0.38.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/graphify/consolidated.d.ts +8 -0
- package/dist/graphify/consolidated.d.ts.map +1 -1
- package/dist/graphify/construct.d.ts +18 -0
- package/dist/graphify/construct.d.ts.map +1 -1
- package/dist/graphify/construct.js +23 -0
- package/dist/graphify/construct.js.map +1 -1
- package/dist/graphify/index.d.ts +2 -2
- package/dist/graphify/index.d.ts.map +1 -1
- package/dist/graphify/index.js +1 -1
- package/dist/graphify/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +49 -3
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +180 -79
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +9 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +461 -12
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/formatDeclaration.js +19 -5
- package/dist/subsystem/formatDeclaration.js.map +1 -1
- package/dist/subsystem/model.d.ts +38 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +124 -67
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/symbolRefs.d.ts.map +1 -1
- package/dist/subsystem/symbolRefs.js +2 -0
- package/dist/subsystem/symbolRefs.js.map +1 -1
- package/package.json +3 -3
- package/src/graphify/consolidated.ts +8 -0
- package/src/graphify/construct.test.ts +61 -1
- package/src/graphify/construct.ts +33 -0
- package/src/graphify/index.ts +6 -1
- package/src/index.ts +1 -0
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +625 -0
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +7 -1
- package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +40 -73
- package/src/stories/Subsystem/ComponentGraph/StoreValueType.stories.tsx +261 -0
- package/src/stories/Subsystem/StoreValueTypeDeclaration.stories.tsx +182 -0
- package/src/subsystem/IssueList.focus.test.tsx +113 -0
- package/src/subsystem/IssueList.icon.test.tsx +95 -0
- package/src/subsystem/IssueList.tsx +267 -111
- package/src/subsystem/SubsystemComponentGraph.tsx +530 -12
- package/src/subsystem/formatDeclaration.test.ts +64 -0
- package/src/subsystem/formatDeclaration.ts +20 -5
- package/src/subsystem/model.ts +46 -0
- package/src/subsystem/nodes.group.test.tsx +52 -0
- package/src/subsystem/nodes.tsx +102 -2
- package/src/subsystem/symbolRefs.ts +2 -0
|
@@ -20,11 +20,11 @@ import { ReactFlow, ReactFlowProvider, useReactFlow, useViewport, applyNodeChang
|
|
|
20
20
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
21
21
|
import { FileText, X } from 'lucide-react';
|
|
22
22
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
23
|
-
import { buildSubsystemGraph, deriveGraphEdges, isConstructsOnlyModel, isRelationMechanism, isWalkthroughMechanism, edgeColor, MECHANISM_DESCRIPTIONS, subsystemGraphLayoutKey, walkthroughStepGraphEdgeId, } from './model';
|
|
23
|
+
import { buildSubsystemGraph, deriveGraphEdges, isConstructsOnlyModel, moduleGroupNodeId, isRelationMechanism, isWalkthroughMechanism, edgeColor, MECHANISM_DESCRIPTIONS, subsystemGraphLayoutKey, walkthroughStepGraphEdgeId, } from './model';
|
|
24
24
|
import { ConstructsCatalog } from './ConstructsCatalog';
|
|
25
25
|
import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
|
|
26
26
|
import { SubsystemDiagnosticToggle } from './DiagnosticToggle';
|
|
27
|
-
import { SubsystemIssueList, } from './IssueList';
|
|
27
|
+
import { SubsystemIssueList, issueCategory, issueKindOrder, issueRung, ISSUE_KIND_ICON, ISSUE_RUNG_ORDER, } from './IssueList';
|
|
28
28
|
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
29
29
|
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
30
30
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
@@ -161,13 +161,19 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
161
161
|
const [hoveredWalkthroughStep, setHoveredWalkthroughStep] = useState(null);
|
|
162
162
|
// Sidebar bottom half: which panel is shown when walkthroughs exist.
|
|
163
163
|
const [sidebarView, setSidebarView] = useState(() => walkthroughs?.length ? 'walkthroughs' : 'files');
|
|
164
|
+
// Temporary edge-vocabulary override. Focusing a relation finding whose edge
|
|
165
|
+
// belongs to the *other* vocabulary flips the canvas so the edge is actually
|
|
166
|
+
// visible; it reverts when the focus is undone (see `unfocusIssueTarget`) or
|
|
167
|
+
// when the diagnostics list closes. Null = use the host-pinned / tab view.
|
|
168
|
+
const [edgeViewOverride, setEdgeViewOverride] = useState(null);
|
|
164
169
|
// One edge vocabulary at a time. When the caller doesn't pick, the sidebar's
|
|
165
170
|
// Files / Walkthroughs tab picks: Files draws topology relation edges,
|
|
166
171
|
// Walkthroughs draws runtime hop edges. Without visible tabs (no walkthroughs,
|
|
167
172
|
// or a sidebar-less embed) fall back to the model: a flow-only graph defaults
|
|
168
173
|
// to walkthrough edges rather than empty, everything else to relations.
|
|
169
174
|
const sidebarTabsVisible = !hideSidebar && (walkthroughs?.length ?? 0) > 0;
|
|
170
|
-
const resolvedEdgeView =
|
|
175
|
+
const resolvedEdgeView = edgeViewOverride ??
|
|
176
|
+
edgeView ??
|
|
171
177
|
(sidebarTabsVisible
|
|
172
178
|
? (sidebarView === 'walkthroughs' ? 'walkthroughs' : 'relations')
|
|
173
179
|
: relations.length === 0 && (walkthroughs?.length ?? 0) > 0
|
|
@@ -249,6 +255,11 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
249
255
|
// closure over the effect deps) can toggle without a stale value.
|
|
250
256
|
const selectedRef = useRef(null);
|
|
251
257
|
selectedRef.current = selected;
|
|
258
|
+
// The module frame an issue card framed. A module target selects no node and
|
|
259
|
+
// no edge, so nothing else records that focus — without this the canvas has
|
|
260
|
+
// no way to tell a module focus it still owns from one a later click took
|
|
261
|
+
// over, and the camera would stay parked on the region forever.
|
|
262
|
+
const issueModuleFocusRef = useRef(null);
|
|
252
263
|
// Ref mirror of the open file drawer target for tree-click toggle.
|
|
253
264
|
const openFileRef = useRef(null);
|
|
254
265
|
const openFile = fileOverlay?.file ??
|
|
@@ -532,12 +543,239 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
532
543
|
const { nodes, edges: convertedEdges } = built;
|
|
533
544
|
const xyflowNodesBase = nodes;
|
|
534
545
|
const baseEdges = convertedEdges;
|
|
546
|
+
// Resolve an issue's component target to the matching node. `id` is the
|
|
547
|
+
// stable alias; `label` may be an alias, name, or symbol. Non-component
|
|
548
|
+
// targets (relation / module / flow / repo) have no single node → null.
|
|
549
|
+
const issueComponent = useCallback((issue) => {
|
|
550
|
+
const target = issue.target;
|
|
551
|
+
if (target?.kind !== 'component')
|
|
552
|
+
return null;
|
|
553
|
+
return (components.find((c) => (target.id != null && c.alias === target.id) ||
|
|
554
|
+
c.alias === target.label ||
|
|
555
|
+
c.name === target.label ||
|
|
556
|
+
c.symbol === target.label) ?? null);
|
|
557
|
+
}, [components]);
|
|
558
|
+
// Resolve a `relation` issue target to the display edge it flags. The target
|
|
559
|
+
// names the two endpoints (`from → to`); match an edge joining both, in
|
|
560
|
+
// either direction, preferring the target's `detail` mechanism when it names
|
|
561
|
+
// one. Null when no display edge matches.
|
|
562
|
+
const issueEdge = useCallback((issue) => {
|
|
563
|
+
const target = issue.target;
|
|
564
|
+
if (target?.kind !== 'relation')
|
|
565
|
+
return null;
|
|
566
|
+
const parts = `${target.id ?? ''} → ${target.label}`
|
|
567
|
+
.split('→')
|
|
568
|
+
.map((p) => p.replace(/\(\)$/, '').trim())
|
|
569
|
+
.filter(Boolean);
|
|
570
|
+
const joins = (e) => parts.includes(e.source) && parts.includes(e.target);
|
|
571
|
+
const matches = baseEdges.filter(joins);
|
|
572
|
+
const byMechanism = target.detail
|
|
573
|
+
? matches.filter((e) => e.data?.mechanism ===
|
|
574
|
+
target.detail)
|
|
575
|
+
: matches;
|
|
576
|
+
return byMechanism[0] ?? matches[0] ?? null;
|
|
577
|
+
}, [baseEdges]);
|
|
578
|
+
// A `module` issue target names a boundary frame, so focusing the card frames
|
|
579
|
+
// that region on the canvas. Null when the target names no frame (a singleton
|
|
580
|
+
// dropped from the layout, or a region key nothing declares).
|
|
581
|
+
const issueModuleNodeId = useCallback((issue) => {
|
|
582
|
+
const target = issue.target;
|
|
583
|
+
if (target?.kind !== 'module')
|
|
584
|
+
return null;
|
|
585
|
+
const key = target.id ?? target.label;
|
|
586
|
+
if (!components.some((c) => c.module?.trim() === key))
|
|
587
|
+
return null;
|
|
588
|
+
return moduleGroupNodeId(key);
|
|
589
|
+
}, [components]);
|
|
590
|
+
// A `step` issue target names ONE step of a walkthrough — the walkthrough id in
|
|
591
|
+
// `id`, the 0-based step position in `stepIndex`. Validated against the loaded
|
|
592
|
+
// walkthroughs so a finding left over from an edited flow resolves to null
|
|
593
|
+
// rather than framing whatever now happens to sit at that index.
|
|
594
|
+
const issueStep = useCallback((issue) => {
|
|
595
|
+
const target = issue.target;
|
|
596
|
+
if (target?.kind !== 'step')
|
|
597
|
+
return null;
|
|
598
|
+
if (target.id == null || target.stepIndex == null)
|
|
599
|
+
return null;
|
|
600
|
+
const walkthrough = walkthroughs?.find((t) => t.id === target.id);
|
|
601
|
+
if (!walkthrough?.steps[target.stepIndex])
|
|
602
|
+
return null;
|
|
603
|
+
return { walkthrough, stepIndex: target.stepIndex };
|
|
604
|
+
}, [walkthroughs]);
|
|
605
|
+
// Per-node diagnostics badge: fold each component-targeted finding that maps
|
|
606
|
+
// to a verification rung into one badge per node — worst severity wins, the
|
|
607
|
+
// chip shows the earliest failing rung, and the count tallies the findings.
|
|
608
|
+
const issueBadgeByAlias = useMemo(() => {
|
|
609
|
+
const map = new Map();
|
|
610
|
+
if (!issues?.length)
|
|
611
|
+
return map;
|
|
612
|
+
for (const issue of issues) {
|
|
613
|
+
const rung = issueRung(issue.kind);
|
|
614
|
+
if (!rung)
|
|
615
|
+
continue;
|
|
616
|
+
const comp = issueComponent(issue);
|
|
617
|
+
if (!comp)
|
|
618
|
+
continue;
|
|
619
|
+
const prev = map.get(comp.alias);
|
|
620
|
+
map.set(comp.alias, {
|
|
621
|
+
severity: prev?.severity === 'error' || issue.severity === 'error'
|
|
622
|
+
? 'error'
|
|
623
|
+
: 'info',
|
|
624
|
+
rung: prev != null && ISSUE_RUNG_ORDER[prev.rung] <= ISSUE_RUNG_ORDER[rung]
|
|
625
|
+
? prev.rung
|
|
626
|
+
: rung,
|
|
627
|
+
count: (prev?.count ?? 0) + 1,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
return map;
|
|
631
|
+
}, [issues, issueComponent]);
|
|
632
|
+
// Per-frame diagnostics badge: the same fold, but for findings about a
|
|
633
|
+
// BOUNDARY rather than a construct. Keyed by the region's React Flow node id.
|
|
634
|
+
// Only kinds with a dedicated frame icon qualify (`ISSUE_KIND_ICON`) — a
|
|
635
|
+
// finding with no icon of its own has nothing to badge the frame with, and
|
|
636
|
+
// borrowing its layer's icon would imply the frame is at fault for it.
|
|
637
|
+
const regionIssueByNodeId = useMemo(() => {
|
|
638
|
+
const map = new Map();
|
|
639
|
+
if (!issues?.length)
|
|
640
|
+
return map;
|
|
641
|
+
for (const issue of issues) {
|
|
642
|
+
const target = issue.target;
|
|
643
|
+
if (target?.kind !== 'module' || !ISSUE_KIND_ICON[issue.kind])
|
|
644
|
+
continue;
|
|
645
|
+
const id = moduleGroupNodeId(target.id ?? target.label);
|
|
646
|
+
const prev = map.get(id);
|
|
647
|
+
map.set(id, {
|
|
648
|
+
severity: prev?.severity === 'error' || issue.severity === 'error' ? 'error' : 'info',
|
|
649
|
+
kind: prev != null && issueKindOrder(prev.kind) <= issueKindOrder(issue.kind)
|
|
650
|
+
? prev.kind
|
|
651
|
+
: issue.kind,
|
|
652
|
+
count: (prev?.count ?? 0) + 1,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return map;
|
|
656
|
+
}, [issues]);
|
|
657
|
+
// Expanded verification layers in the diagnostics list. When any layer with
|
|
658
|
+
// findings is expanded, the canvas dims everything that layer does not
|
|
659
|
+
// implicate (see `issueFocus`). The list publishes this through
|
|
660
|
+
// `onExpandedCategoriesChange`; empty while every layer is collapsed.
|
|
661
|
+
const [expandedIssueCategories, setExpandedIssueCategories] = useState([]);
|
|
662
|
+
// Stable setter — the list re-emits on mount and on every toggle, so compare
|
|
663
|
+
// before storing to avoid churn (and to keep the list effect's dep stable).
|
|
664
|
+
const handleExpandedCategoriesChange = useCallback((cats) => {
|
|
665
|
+
setExpandedIssueCategories((prev) => prev.length === cats.length && prev.every((c, i) => c === cats[i])
|
|
666
|
+
? prev
|
|
667
|
+
: cats);
|
|
668
|
+
}, []);
|
|
669
|
+
// Resolve a component name / symbol / alias reference to a component alias.
|
|
670
|
+
const resolveAlias = useCallback((ref) => {
|
|
671
|
+
const clean = ref.replace(/\(\)$/, '').trim();
|
|
672
|
+
const c = components.find((x) => x.alias === clean ||
|
|
673
|
+
x.name === clean ||
|
|
674
|
+
(x.symbol != null && x.symbol.replace(/\(\)$/, '') === clean));
|
|
675
|
+
return c?.alias ?? null;
|
|
676
|
+
}, [components]);
|
|
677
|
+
// Graph elements implicated by the expanded layers' findings — the bright set
|
|
678
|
+
// the canvas keeps while everything else dims. Derived from each finding's
|
|
679
|
+
// target:
|
|
680
|
+
// component → that node
|
|
681
|
+
// relation → the edge it flags (+ its two endpoint nodes)
|
|
682
|
+
// module → every component in that module
|
|
683
|
+
// walkthrough → the flow's nodes + its hop edges
|
|
684
|
+
// repo → every component of that repo
|
|
685
|
+
// graph → whole-graph finding; implicates nothing specific
|
|
686
|
+
// Returns null when nothing is expanded, no expanded layer has findings, or
|
|
687
|
+
// the findings implicate nothing — so no dimming is applied.
|
|
688
|
+
const issueFocus = useMemo(() => {
|
|
689
|
+
if (!issues?.length || expandedIssueCategories.length === 0)
|
|
690
|
+
return null;
|
|
691
|
+
const active = new Set(expandedIssueCategories);
|
|
692
|
+
const activeIssues = issues.filter((i) => active.has(issueCategory(i)));
|
|
693
|
+
if (activeIssues.length === 0)
|
|
694
|
+
return null;
|
|
695
|
+
const nodeIds = new Set();
|
|
696
|
+
const edgeIds = new Set();
|
|
697
|
+
for (const issue of activeIssues) {
|
|
698
|
+
const t = issue.target;
|
|
699
|
+
if (!t)
|
|
700
|
+
continue; // whole-graph finding — no specific element
|
|
701
|
+
if (t.kind === 'component') {
|
|
702
|
+
const a = resolveAlias(t.id ?? t.label);
|
|
703
|
+
if (a)
|
|
704
|
+
nodeIds.add(a);
|
|
705
|
+
}
|
|
706
|
+
else if (t.kind === 'relation') {
|
|
707
|
+
// The target names the two endpoints (`from → to`); light the edge and
|
|
708
|
+
// its endpoints. Falls back to endpoints alone if no edge matches.
|
|
709
|
+
const edge = issueEdge(issue);
|
|
710
|
+
if (edge) {
|
|
711
|
+
edgeIds.add(edge.id);
|
|
712
|
+
nodeIds.add(edge.source);
|
|
713
|
+
nodeIds.add(edge.target);
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
for (const part of `${t.id ?? ''} → ${t.label}`.split('→')) {
|
|
717
|
+
const a = resolveAlias(part);
|
|
718
|
+
if (a)
|
|
719
|
+
nodeIds.add(a);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
else if (t.kind === 'module') {
|
|
724
|
+
const key = t.id ?? t.label;
|
|
725
|
+
const prefix = `${key.replace(/\/$/, '')}/`;
|
|
726
|
+
for (const c of components) {
|
|
727
|
+
if (c.module === key ||
|
|
728
|
+
c.file === key ||
|
|
729
|
+
(c.file !== '' && c.file.endsWith(key)) ||
|
|
730
|
+
c.file.startsWith(prefix)) {
|
|
731
|
+
nodeIds.add(c.alias);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
else if (t.kind === 'walkthrough') {
|
|
736
|
+
const wt = (walkthroughs ?? []).find((w) => w.id === t.id || w.title === t.label);
|
|
737
|
+
if (wt) {
|
|
738
|
+
for (const s of wt.steps) {
|
|
739
|
+
nodeIds.add(s.from);
|
|
740
|
+
nodeIds.add(s.to);
|
|
741
|
+
edgeIds.add(walkthroughStepGraphEdgeId(s));
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
else if (t.kind === 'repo') {
|
|
746
|
+
const key = t.id ?? t.label;
|
|
747
|
+
for (const c of components) {
|
|
748
|
+
if (c.purl === key)
|
|
749
|
+
nodeIds.add(c.alias);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (nodeIds.size === 0 && edgeIds.size === 0)
|
|
754
|
+
return null;
|
|
755
|
+
// An edge whose endpoints both sit in the bright set stays lit too, so a
|
|
756
|
+
// focused cluster doesn't read as isolated nodes.
|
|
757
|
+
for (const e of baseEdges) {
|
|
758
|
+
if (nodeIds.has(e.source) && nodeIds.has(e.target))
|
|
759
|
+
edgeIds.add(e.id);
|
|
760
|
+
}
|
|
761
|
+
return { nodeIds, edgeIds };
|
|
762
|
+
}, [issues, expandedIssueCategories, components, walkthroughs, baseEdges, resolveAlias, issueEdge]);
|
|
763
|
+
const issueFocusNodeIds = issueFocus?.nodeIds ?? null;
|
|
764
|
+
const issueFocusEdgeIds = issueFocus?.edgeIds ?? null;
|
|
535
765
|
// When an edge is selected, dim every other edge + its label to focus it.
|
|
536
766
|
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
|
|
537
767
|
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
538
768
|
// without a stale closure value.
|
|
539
769
|
const selectedEdgeIdRef = useRef(null);
|
|
540
770
|
selectedEdgeIdRef.current = selectedEdgeId;
|
|
771
|
+
// Ref mirrors of the focused step, so `unfocusIssueTarget` (a useCallback
|
|
772
|
+
// over early deps) can tell a step focus it still owns from one the user has
|
|
773
|
+
// since moved elsewhere in the walkthrough panel.
|
|
774
|
+
const focusedStepRef = useRef(null);
|
|
775
|
+
focusedStepRef.current =
|
|
776
|
+
focusedWalkthroughId != null && focusedStepIndex != null
|
|
777
|
+
? { walkthroughId: focusedWalkthroughId, stepIndex: focusedStepIndex }
|
|
778
|
+
: null;
|
|
541
779
|
// Edge ids in walkthrough focus (an active flow's edge set, or a single
|
|
542
780
|
// step's edge). Used to frame the camera. `null` = no walkthrough focus.
|
|
543
781
|
const focusEdgeIds = useMemo(() => {
|
|
@@ -682,12 +920,22 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
682
920
|
anyOpened: openedNodeIds != null,
|
|
683
921
|
anySelected: brightNodeIds != null || previewNodeIds != null,
|
|
684
922
|
});
|
|
685
|
-
|
|
686
|
-
|
|
923
|
+
// `brightNodeIds` is the focused step's endpoints. It has to be a dim
|
|
924
|
+
// source, not just a bright set: without an expanded flow,
|
|
925
|
+
// `flowElementVisibility` reports non-participants as *hidden*, and the
|
|
926
|
+
// noOpenedFlow escape below discards that — leaving them neither hidden
|
|
927
|
+
// nor dimmed. Naming the participants as the dim source dims everything
|
|
928
|
+
// outside the focused step however the flow got focused.
|
|
929
|
+
const dimSource = previewNodeIds ?? brightNodeIds ?? issueFocusNodeIds;
|
|
930
|
+
const dimmed = dimSource
|
|
931
|
+
? vis.hidden || !memberAliases.some((alias) => dimSource.has(alias))
|
|
687
932
|
: vis.dimmed;
|
|
688
933
|
const hidden = noOpenedFlow ? false : vis.hidden;
|
|
689
934
|
// Host override wins over the library's derived frame color.
|
|
690
935
|
const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
|
|
936
|
+
// Boundary findings badge the FRAME, not a member — a region's shape is
|
|
937
|
+
// the fault, so no leaf construct carries it.
|
|
938
|
+
const regionIssue = regionIssueByNodeId.get(n.id);
|
|
691
939
|
return {
|
|
692
940
|
...n,
|
|
693
941
|
hidden,
|
|
@@ -696,6 +944,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
696
944
|
...n.data,
|
|
697
945
|
...(color != null && { color }),
|
|
698
946
|
...(dimmed && { dimmed: true }),
|
|
947
|
+
...(regionIssue && { issue: regionIssue }),
|
|
699
948
|
},
|
|
700
949
|
};
|
|
701
950
|
}
|
|
@@ -712,9 +961,16 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
712
961
|
anyOpened: openedNodeIds != null,
|
|
713
962
|
anySelected: brightNodeIds != null || previewNodeIds != null,
|
|
714
963
|
});
|
|
715
|
-
|
|
964
|
+
// `brightNodeIds` (the focused step's endpoints) is a dim source as well
|
|
965
|
+
// as a bright set — see the group branch above.
|
|
966
|
+
const dimSource = previewNodeIds ?? brightNodeIds ?? issueFocusNodeIds;
|
|
967
|
+
const dimmed = dimSource ? (vis.hidden || !dimSource.has(n.id)) : vis.dimmed;
|
|
716
968
|
const hidden = noOpenedFlow ? false : vis.hidden;
|
|
717
|
-
|
|
969
|
+
// Component findings key by alias, boundary findings by region node id.
|
|
970
|
+
// The two id spaces are disjoint (`module:` / `process:` are prefixed), so
|
|
971
|
+
// one lookup covers both node kinds.
|
|
972
|
+
const issueBadge = issueBadgeByAlias.get(n.id) ?? regionIssueByNodeId.get(n.id);
|
|
973
|
+
if (fileMatch === undefined && !isSelected && !dimmed && !issueBadge) {
|
|
718
974
|
const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data;
|
|
719
975
|
return { ...n, hidden, data: rest };
|
|
720
976
|
}
|
|
@@ -726,10 +982,11 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
726
982
|
...(fileMatch !== undefined && { fileMatch }),
|
|
727
983
|
...(isSelected && { isSelected }),
|
|
728
984
|
...(dimmed && { dimmed: true }),
|
|
985
|
+
...(issueBadge && { issue: issueBadge }),
|
|
729
986
|
},
|
|
730
987
|
};
|
|
731
988
|
});
|
|
732
|
-
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, boundaryColors]);
|
|
989
|
+
}, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, issueFocusNodeIds, issueBadgeByAlias, regionIssueByNodeId, boundaryColors]);
|
|
733
990
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
734
991
|
const baseEdgesKey = useMemo(() => convertedEdges.map((e) => e.id).sort().join(','), [convertedEdges]);
|
|
735
992
|
const dispEdges = useMemo(() => {
|
|
@@ -755,7 +1012,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
755
1012
|
}
|
|
756
1013
|
return isWalkthroughMechanism(mechanism);
|
|
757
1014
|
};
|
|
758
|
-
if (openedEdgeIds || focusEdgeIds || previewEdgeIds) {
|
|
1015
|
+
if (openedEdgeIds || focusEdgeIds || previewEdgeIds || issueFocusEdgeIds) {
|
|
759
1016
|
return baseEdges.map((e) => {
|
|
760
1017
|
const vis = flowElementVisibility({
|
|
761
1018
|
inOpened: openedEdgeIds?.has(e.id) === true,
|
|
@@ -763,7 +1020,8 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
763
1020
|
anyOpened: openedEdgeIds != null,
|
|
764
1021
|
anySelected: focusEdgeIds != null,
|
|
765
1022
|
});
|
|
766
|
-
const
|
|
1023
|
+
const dimSource = previewEdgeIds ?? issueFocusEdgeIds;
|
|
1024
|
+
const dimmed = dimSource ? vis.hidden || !dimSource.has(e.id) : vis.dimmed;
|
|
767
1025
|
const hidden = vis.hidden || !edgeInView(e);
|
|
768
1026
|
return { ...paint(e, dimmed), hidden };
|
|
769
1027
|
});
|
|
@@ -775,7 +1033,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
775
1033
|
}));
|
|
776
1034
|
}
|
|
777
1035
|
return baseEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
|
|
778
|
-
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, resolvedEdgeView]);
|
|
1036
|
+
}, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
|
|
779
1037
|
const onNodesChange = useCallback((changes) => {
|
|
780
1038
|
// Capture dimension changes (React Flow's measurement callback).
|
|
781
1039
|
// Group parents are ELK-sized — ignore their measurements.
|
|
@@ -817,6 +1075,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
817
1075
|
return;
|
|
818
1076
|
}
|
|
819
1077
|
setSelected(comp);
|
|
1078
|
+
issueModuleFocusRef.current = null;
|
|
820
1079
|
if (comp.alias)
|
|
821
1080
|
onSelect?.(comp.alias);
|
|
822
1081
|
}
|
|
@@ -830,6 +1089,8 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
830
1089
|
setSelectedEdgeId(null);
|
|
831
1090
|
setFocusedWalkthroughId(null);
|
|
832
1091
|
setFocusedStepIndex(null);
|
|
1092
|
+
setEdgeViewOverride(null);
|
|
1093
|
+
issueModuleFocusRef.current = null;
|
|
833
1094
|
}, []);
|
|
834
1095
|
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
835
1096
|
// own owner-avatar header. Clicking a header collapses that repo's tree.
|
|
@@ -843,6 +1104,14 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
843
1104
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(showIssues ?? (issues?.length ?? 0) > 0);
|
|
844
1105
|
const controlledIssues = showIssues !== undefined;
|
|
845
1106
|
const issuesActive = controlledIssues ? showIssues : diagnosticsOpen;
|
|
1107
|
+
// Layer focus only applies while the diagnostics list is on screen — closing
|
|
1108
|
+
// it clears any dimming the expanded layers were driving.
|
|
1109
|
+
useEffect(() => {
|
|
1110
|
+
if (!issuesActive) {
|
|
1111
|
+
setExpandedIssueCategories([]);
|
|
1112
|
+
setEdgeViewOverride(null);
|
|
1113
|
+
}
|
|
1114
|
+
}, [issuesActive]);
|
|
846
1115
|
const toggleIssues = () => {
|
|
847
1116
|
if (!controlledIssues)
|
|
848
1117
|
setDiagnosticsOpen((v) => !v);
|
|
@@ -1023,6 +1292,52 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
1023
1292
|
zoomOnWalkthroughFocus,
|
|
1024
1293
|
walkthroughFocusDurationMs,
|
|
1025
1294
|
]);
|
|
1295
|
+
// A node's laid-out rect in absolute flow coords. `absoluteRects` is the
|
|
1296
|
+
// authority; the fallback covers an unparented group node, whose `position` is
|
|
1297
|
+
// already absolute. Nested groups are parent-relative, so they are skipped
|
|
1298
|
+
// rather than mis-boxed.
|
|
1299
|
+
const rectForNode = useCallback((id) => {
|
|
1300
|
+
const r = built.absoluteRects.get(id);
|
|
1301
|
+
if (r)
|
|
1302
|
+
return r;
|
|
1303
|
+
const n = built.nodes.find((x) => x.id === id);
|
|
1304
|
+
if (!n?.position || n.parentId)
|
|
1305
|
+
return undefined;
|
|
1306
|
+
return {
|
|
1307
|
+
x: n.position.x,
|
|
1308
|
+
y: n.position.y,
|
|
1309
|
+
width: n.width ?? 0,
|
|
1310
|
+
height: n.height ?? 0,
|
|
1311
|
+
};
|
|
1312
|
+
}, [built.absoluteRects, built.nodes]);
|
|
1313
|
+
// Frame a set of nodes by their laid-out rects. Unlike `fitFocusBounds` this
|
|
1314
|
+
// takes node ids, so it works for boundary frames too — React Flow's own
|
|
1315
|
+
// `fitView({ nodes })` can't be relied on for group nodes here, and the rects
|
|
1316
|
+
// are already in absolute flow coords (grouped child `position`s are
|
|
1317
|
+
// parent-relative and would skew the box).
|
|
1318
|
+
const fitNodeRects = useCallback((ids, padding) => {
|
|
1319
|
+
let minX = Infinity;
|
|
1320
|
+
let minY = Infinity;
|
|
1321
|
+
let maxX = -Infinity;
|
|
1322
|
+
let maxY = -Infinity;
|
|
1323
|
+
for (const id of ids) {
|
|
1324
|
+
const r = rectForNode(id);
|
|
1325
|
+
if (!r)
|
|
1326
|
+
continue;
|
|
1327
|
+
minX = Math.min(minX, r.x);
|
|
1328
|
+
minY = Math.min(minY, r.y);
|
|
1329
|
+
maxX = Math.max(maxX, r.x + r.width);
|
|
1330
|
+
maxY = Math.max(maxY, r.y + r.height);
|
|
1331
|
+
}
|
|
1332
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY))
|
|
1333
|
+
return;
|
|
1334
|
+
fitBounds({
|
|
1335
|
+
x: minX,
|
|
1336
|
+
y: minY,
|
|
1337
|
+
width: Math.max(1, maxX - minX),
|
|
1338
|
+
height: Math.max(1, maxY - minY),
|
|
1339
|
+
}, { padding, duration: walkthroughFocusDurationMs });
|
|
1340
|
+
}, [rectForNode, fitBounds, walkthroughFocusDurationMs]);
|
|
1026
1341
|
// Zoom back out to the full diagram after the last expanded walkthrough
|
|
1027
1342
|
// closes (visibility restores non-flow nodes that were hidden).
|
|
1028
1343
|
const fitOverview = useCallback(() => {
|
|
@@ -1376,6 +1691,140 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
1376
1691
|
setFocusedStepIndex(null);
|
|
1377
1692
|
onSelect?.(comp.alias);
|
|
1378
1693
|
}, [components, onSelect]);
|
|
1694
|
+
// Issue click → select + frame the target on the canvas. Component targets
|
|
1695
|
+
// frame their node; relation targets frame their edge (endpoints + the routed
|
|
1696
|
+
// line); other kinds (module / flow / repo) have no single element and just
|
|
1697
|
+
// forward. Selections / focus that would hide the target are cleared first,
|
|
1698
|
+
// then the camera flies to it on the next frame. The host's `onSelectIssue`
|
|
1699
|
+
// still fires afterwards for its own detail.
|
|
1700
|
+
const focusIssueTarget = useCallback((issue) => {
|
|
1701
|
+
const comp = issueComponent(issue);
|
|
1702
|
+
const edge = comp ? null : issueEdge(issue);
|
|
1703
|
+
const step = comp || edge ? null : issueStep(issue);
|
|
1704
|
+
if (comp) {
|
|
1705
|
+
setSelected(comp);
|
|
1706
|
+
setSelectedEdgeId(null);
|
|
1707
|
+
setFocusedWalkthroughId(null);
|
|
1708
|
+
setFocusedStepIndex(null);
|
|
1709
|
+
setHoveredWalkthroughStep(null);
|
|
1710
|
+
issueModuleFocusRef.current = null;
|
|
1711
|
+
onSelect?.(comp.alias);
|
|
1712
|
+
requestAnimationFrame(() => {
|
|
1713
|
+
fitView({
|
|
1714
|
+
nodes: [{ id: comp.alias }],
|
|
1715
|
+
padding: 0.4,
|
|
1716
|
+
duration: 300,
|
|
1717
|
+
minZoom: 0.05,
|
|
1718
|
+
maxZoom: 1.5,
|
|
1719
|
+
});
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
else if (edge) {
|
|
1723
|
+
setSelected(null);
|
|
1724
|
+
setFocusedWalkthroughId(null);
|
|
1725
|
+
setFocusedStepIndex(null);
|
|
1726
|
+
setHoveredWalkthroughStep(null);
|
|
1727
|
+
setSelectedEdgeId(edge.id);
|
|
1728
|
+
issueModuleFocusRef.current = null;
|
|
1729
|
+
// Flip to the vocabulary this edge belongs to, so a hop flagged in the
|
|
1730
|
+
// relations view (or vice versa) is actually drawn while focused.
|
|
1731
|
+
const d = edge.data;
|
|
1732
|
+
const mechanism = d?.mechanism ?? 'uses';
|
|
1733
|
+
setEdgeViewOverride(d?.provenance === 'graphify' || isRelationMechanism(mechanism)
|
|
1734
|
+
? 'relations'
|
|
1735
|
+
: 'walkthroughs');
|
|
1736
|
+
requestAnimationFrame(() => {
|
|
1737
|
+
fitFocusBounds(new Set([edge.id]));
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
else if (step) {
|
|
1741
|
+
// A step finding focuses the step itself, through the same entry point
|
|
1742
|
+
// the walkthrough panel uses — so the drawer, the step numbering, the
|
|
1743
|
+
// dim-mode hover preview, and the camera all behave identically whether
|
|
1744
|
+
// the step was reached from the panel or from a diagnostics card.
|
|
1745
|
+
issueModuleFocusRef.current = null;
|
|
1746
|
+
focusWalkthroughStep(step.walkthrough, step.stepIndex);
|
|
1747
|
+
}
|
|
1748
|
+
else {
|
|
1749
|
+
// Nothing selectable — but a module target names a boundary frame, so
|
|
1750
|
+
// frame that region.
|
|
1751
|
+
const moduleNodeId = issueModuleNodeId(issue);
|
|
1752
|
+
if (moduleNodeId) {
|
|
1753
|
+
setSelected(null);
|
|
1754
|
+
setSelectedEdgeId(null);
|
|
1755
|
+
setFocusedWalkthroughId(null);
|
|
1756
|
+
setFocusedStepIndex(null);
|
|
1757
|
+
setHoveredWalkthroughStep(null);
|
|
1758
|
+
issueModuleFocusRef.current = moduleNodeId;
|
|
1759
|
+
requestAnimationFrame(() => {
|
|
1760
|
+
fitNodeRects(new Set([moduleNodeId]), 0.4);
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
onSelectIssue?.(issue);
|
|
1765
|
+
}, [
|
|
1766
|
+
issueComponent,
|
|
1767
|
+
issueEdge,
|
|
1768
|
+
issueStep,
|
|
1769
|
+
issueModuleNodeId,
|
|
1770
|
+
focusWalkthroughStep,
|
|
1771
|
+
onSelect,
|
|
1772
|
+
onSelectIssue,
|
|
1773
|
+
fitView,
|
|
1774
|
+
fitFocusBounds,
|
|
1775
|
+
fitNodeRects,
|
|
1776
|
+
]);
|
|
1777
|
+
// Collapsing the issue card again undoes the focus: drop the node / edge
|
|
1778
|
+
// selection (only when it is still this issue's target — another card may
|
|
1779
|
+
// have taken it over) and zoom back out to the whole graph. A module target
|
|
1780
|
+
// frames a region rather than selecting anything, so its ownership lives in
|
|
1781
|
+
// `issueModuleFocusRef`; without this branch a module focus never unwinds and
|
|
1782
|
+
// the camera never zooms back out.
|
|
1783
|
+
const unfocusIssueTarget = useCallback((issue) => {
|
|
1784
|
+
const comp = issueComponent(issue);
|
|
1785
|
+
if (comp) {
|
|
1786
|
+
if (selectedRef.current?.alias !== comp.alias)
|
|
1787
|
+
return;
|
|
1788
|
+
setSelected(null);
|
|
1789
|
+
setSelectedEdgeId(null);
|
|
1790
|
+
}
|
|
1791
|
+
else {
|
|
1792
|
+
const edge = issueEdge(issue);
|
|
1793
|
+
const step = edge ? null : issueStep(issue);
|
|
1794
|
+
const moduleNodeId = edge || step ? null : issueModuleNodeId(issue);
|
|
1795
|
+
if (edge) {
|
|
1796
|
+
if (selectedEdgeIdRef.current !== edge.id)
|
|
1797
|
+
return;
|
|
1798
|
+
setSelectedEdgeId(null);
|
|
1799
|
+
setEdgeViewOverride(null);
|
|
1800
|
+
}
|
|
1801
|
+
else if (step) {
|
|
1802
|
+
// Only unwind a step focus this card still owns. A dim-mode graph
|
|
1803
|
+
// keeps its step focus in hover state instead (owned by the pointer,
|
|
1804
|
+
// not the card), and correctly leaves that alone here.
|
|
1805
|
+
const owned = focusedStepRef.current;
|
|
1806
|
+
if (owned?.walkthroughId !== step.walkthrough.id ||
|
|
1807
|
+
owned?.stepIndex !== step.stepIndex) {
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
setFocusedWalkthroughId(null);
|
|
1811
|
+
setFocusedStepIndex(null);
|
|
1812
|
+
setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
|
|
1813
|
+
}
|
|
1814
|
+
else if (moduleNodeId) {
|
|
1815
|
+
// Only unwind framing this card still owns.
|
|
1816
|
+
if (issueModuleFocusRef.current !== moduleNodeId)
|
|
1817
|
+
return;
|
|
1818
|
+
issueModuleFocusRef.current = null;
|
|
1819
|
+
}
|
|
1820
|
+
else {
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
requestAnimationFrame(() => {
|
|
1825
|
+
fitView({ padding: 0.1, duration: 300, minZoom: 0.05, maxZoom: 2 });
|
|
1826
|
+
});
|
|
1827
|
+
}, [issueComponent, issueEdge, issueStep, issueModuleNodeId, fitView]);
|
|
1379
1828
|
// Construct tokens inside a walkthrough snippet. A step's line is an edge
|
|
1380
1829
|
// between its `from`/`to` components, so a token naming either of those
|
|
1381
1830
|
// constructs should navigate to that construct's declaration. Index the
|
|
@@ -1527,7 +1976,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
|
|
|
1527
1976
|
display: 'flex',
|
|
1528
1977
|
flexDirection: 'column',
|
|
1529
1978
|
overflow: 'hidden',
|
|
1530
|
-
}, children: issuesActive ? (_jsx(SubsystemIssueList, { issues: issues ?? [], focusCategory: focusIssueCategory, onSelectIssue:
|
|
1979
|
+
}, children: issuesActive ? (_jsx(SubsystemIssueList, { issues: issues ?? [], focusCategory: focusIssueCategory, onSelectIssue: focusIssueTarget, onDeselectIssue: unfocusIssueTarget, onApplyFix: onApplyIssueFix, onHoverIssue: onHoverIssue, onExpandedCategoriesChange: handleExpandedCategoriesChange })) : (_jsxs(_Fragment, { children: [hasWalkthroughs && (_jsx("div", { role: "tablist", "aria-label": "Sidebar view", style: {
|
|
1531
1980
|
display: 'flex',
|
|
1532
1981
|
width: '100%',
|
|
1533
1982
|
flexShrink: 0,
|