@principal-ai/subsystems-react 0.43.0 → 0.43.2
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/subsystem/SubsystemComponentGraph.d.ts +7 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +72 -6
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SubsystemFileTree.d.ts +5 -5
- package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
- package/dist/subsystem/SubsystemFileTree.js +13 -49
- package/dist/subsystem/SubsystemFileTree.js.map +1 -1
- package/dist/subsystem/model.d.ts +50 -3
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +333 -7
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +11 -4
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +7 -0
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/stories/Subsystem/ComponentGraph/ModulePathNesting.stories.tsx +116 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +81 -5
- package/src/subsystem/SubsystemFileTree.tsx +17 -50
- package/src/subsystem/model.test.ts +148 -0
- package/src/subsystem/model.ts +362 -8
- package/src/subsystem/nodes.tsx +13 -3
- package/src/utils/elkLayout.ts +10 -0
package/src/subsystem/model.ts
CHANGED
|
@@ -477,15 +477,23 @@ export function deriveGraphEdges(doc: {
|
|
|
477
477
|
}
|
|
478
478
|
|
|
479
479
|
/**
|
|
480
|
-
* True when the model has components but no
|
|
481
|
-
*
|
|
480
|
+
* True when the model has components but no graph structure at all: no
|
|
481
|
+
* walkthrough/graphify edges AND no boundary containment (`module` / `process`)
|
|
482
|
+
* to frame. Those snapshots are a catalog of declarations, not a graph.
|
|
483
|
+
*
|
|
484
|
+
* A model that carries `module` / `process` membership still draws frames even
|
|
485
|
+
* with zero edges — that is static topology, so it is not constructs-only.
|
|
482
486
|
*/
|
|
483
487
|
export function isConstructsOnlyModel(doc: {
|
|
484
|
-
components: readonly { alias: string }[];
|
|
488
|
+
components: readonly { alias: string; module?: string; process?: string }[];
|
|
485
489
|
walkthroughs?: readonly SubsystemWalkthrough[];
|
|
486
490
|
graphifyRelations?: readonly SubsystemGraphifyRelation[];
|
|
487
491
|
}): boolean {
|
|
488
492
|
if (doc.components.length === 0) return false;
|
|
493
|
+
const hasBoundary = doc.components.some(
|
|
494
|
+
(c) => (c.module?.trim() ?? '') !== '' || (c.process?.trim() ?? '') !== '',
|
|
495
|
+
);
|
|
496
|
+
if (hasBoundary) return false;
|
|
489
497
|
return deriveGraphEdges({
|
|
490
498
|
walkthroughs: doc.walkthroughs,
|
|
491
499
|
graphifyRelations: doc.graphifyRelations,
|
|
@@ -608,7 +616,7 @@ export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
|
|
|
608
616
|
*/
|
|
609
617
|
export interface SubsystemProcessRegion {
|
|
610
618
|
/** Discriminator — which field / identity produced this region. */
|
|
611
|
-
kind: 'process' | 'module' | 'package';
|
|
619
|
+
kind: 'process' | 'module' | 'directory' | 'package';
|
|
612
620
|
/** The `process` / `module` value, or purl repo key for packages. */
|
|
613
621
|
key: string;
|
|
614
622
|
/** Display label for the boundary frame. */
|
|
@@ -631,6 +639,15 @@ export interface BoundaryFrameOptions {
|
|
|
631
639
|
* - `never`: no package frames
|
|
632
640
|
*/
|
|
633
641
|
packageFrames?: 'multi-repo' | 'always' | 'never';
|
|
642
|
+
/**
|
|
643
|
+
* How module frames group.
|
|
644
|
+
* - `exact` (default): one frame per distinct `module` string.
|
|
645
|
+
* - `path`: derive directory frames from the path segments of each
|
|
646
|
+
* component's `module`, and nest module frames under the directories that
|
|
647
|
+
* contain them. Directory frames are interposed between process/package
|
|
648
|
+
* and module frames.
|
|
649
|
+
*/
|
|
650
|
+
moduleNesting?: 'exact' | 'path';
|
|
634
651
|
}
|
|
635
652
|
|
|
636
653
|
/** React Flow id for a process boundary group node. */
|
|
@@ -643,6 +660,23 @@ export function moduleGroupNodeId(moduleKey: string): string {
|
|
|
643
660
|
return `module:${moduleKey}`;
|
|
644
661
|
}
|
|
645
662
|
|
|
663
|
+
/** React Flow id for a directory boundary group node (path-derived nesting). */
|
|
664
|
+
export function directoryGroupNodeId(dirKey: string): string {
|
|
665
|
+
return `directory:${dirKey}`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Normalize a module path to forward slashes, no trailing slash or `./`. */
|
|
669
|
+
function normalizeModulePath(p: string): string {
|
|
670
|
+
return p.trim().replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\.\//, '');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Parent directory of a path-like key, or `''` when it has no slash. */
|
|
674
|
+
function parentDirectory(path: string): string {
|
|
675
|
+
const norm = normalizeModulePath(path);
|
|
676
|
+
const idx = norm.lastIndexOf('/');
|
|
677
|
+
return idx <= 0 ? '' : norm.slice(0, idx);
|
|
678
|
+
}
|
|
679
|
+
|
|
646
680
|
export const MODULE_BADGE_INSET = 12;
|
|
647
681
|
const MODULE_BADGE_CHAR_WIDTH = 12.5;
|
|
648
682
|
const MODULE_BADGE_CHROME = 16;
|
|
@@ -718,6 +752,7 @@ export function packageGroupNodeId(packageKey: string): string {
|
|
|
718
752
|
/** React Flow id for a boundary group of any kind. */
|
|
719
753
|
export function boundaryGroupNodeId(region: Pick<SubsystemProcessRegion, 'kind' | 'key'>): string {
|
|
720
754
|
if (region.kind === 'module') return moduleGroupNodeId(region.key);
|
|
755
|
+
if (region.kind === 'directory') return directoryGroupNodeId(region.key);
|
|
721
756
|
if (region.kind === 'package') return packageGroupNodeId(region.key);
|
|
722
757
|
return processGroupNodeId(region.key);
|
|
723
758
|
}
|
|
@@ -840,6 +875,9 @@ export function buildBoundaryLayoutGroups(
|
|
|
840
875
|
doc: Pick<SubsystemModelDocument, 'components'>,
|
|
841
876
|
opts: BoundaryFrameOptions = {},
|
|
842
877
|
): BoundaryLayoutGroup[] {
|
|
878
|
+
if (opts.moduleNesting === 'path') {
|
|
879
|
+
return buildBoundaryLayoutGroupsByPath(doc, opts);
|
|
880
|
+
}
|
|
843
881
|
const showSingletons = opts.showSingletonFrames === true;
|
|
844
882
|
const packageMode = opts.packageFrames ?? 'multi-repo';
|
|
845
883
|
const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
|
|
@@ -983,6 +1021,288 @@ export function buildBoundaryLayoutGroups(
|
|
|
983
1021
|
return [...moduleGroups, ...processGroups, ...packageGroups];
|
|
984
1022
|
}
|
|
985
1023
|
|
|
1024
|
+
/**
|
|
1025
|
+
* Path-nesting variant of {@link buildBoundaryLayoutGroups}.
|
|
1026
|
+
*
|
|
1027
|
+
* Adds directory frames derived from each component's `module` path segments
|
|
1028
|
+
* (`src/session/transcript.ts` → `src` → `src/session` → module frame) and
|
|
1029
|
+
* nests module frames under the deepest directory that contains them. Directory
|
|
1030
|
+
* frames are kept when they hold 2+ leaves (or `showSingletonFrames`). Process
|
|
1031
|
+
* and package frames nest above directories exactly as before.
|
|
1032
|
+
*
|
|
1033
|
+
* Module frames are unchanged (one per exact `module` string); leaves still
|
|
1034
|
+
* parent to their module frame in `convertSubsystemToNodes`.
|
|
1035
|
+
*/
|
|
1036
|
+
export function buildBoundaryLayoutGroupsByPath(
|
|
1037
|
+
doc: Pick<SubsystemModelDocument, 'components'>,
|
|
1038
|
+
opts: BoundaryFrameOptions = {},
|
|
1039
|
+
): BoundaryLayoutGroup[] {
|
|
1040
|
+
const showSingletons = opts.showSingletonFrames === true;
|
|
1041
|
+
const packageMode = opts.packageFrames ?? 'multi-repo';
|
|
1042
|
+
const byAlias = new Map(doc.components.map((c) => [c.alias, c]));
|
|
1043
|
+
|
|
1044
|
+
const allPackages = getSubsystemPackageRegions(doc);
|
|
1045
|
+
const packageEligible =
|
|
1046
|
+
packageMode === 'always'
|
|
1047
|
+
? true
|
|
1048
|
+
: packageMode === 'never'
|
|
1049
|
+
? false
|
|
1050
|
+
: allPackages.length >= 2;
|
|
1051
|
+
const packages = packageEligible
|
|
1052
|
+
? allPackages.filter((r) => keepRegion(r, showSingletons))
|
|
1053
|
+
: [];
|
|
1054
|
+
const keptPackageKeys = new Set(packages.map((r) => r.key));
|
|
1055
|
+
|
|
1056
|
+
const processes = getSubsystemRegions(doc).filter((r) =>
|
|
1057
|
+
keepRegion(r, showSingletons),
|
|
1058
|
+
);
|
|
1059
|
+
const keptProcessKeys = new Set(processes.map((r) => r.key));
|
|
1060
|
+
|
|
1061
|
+
const modules = getSubsystemModuleRegions(doc).filter((r) =>
|
|
1062
|
+
keepRegion(r, showSingletons),
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
// Directory nesting is SCOPED to the enclosing process (else package, else
|
|
1066
|
+
// root), so module grouping stays subordinate to process boundaries. A
|
|
1067
|
+
// folder that spans two processes is split per process rather than becoming
|
|
1068
|
+
// a frame that belongs to neither — which would leave the process frames
|
|
1069
|
+
// childless and drop them. Composite key = `${scopeKey}\0${path}`.
|
|
1070
|
+
const scopeKeyFor = (c: SubsystemComponent): string => {
|
|
1071
|
+
const p = c.process?.trim();
|
|
1072
|
+
if (p && keptProcessKeys.has(p)) return `p:${p}`;
|
|
1073
|
+
const pkg = componentPackageKey(c);
|
|
1074
|
+
if (pkg && keptPackageKeys.has(pkg)) return `k:${pkg}`;
|
|
1075
|
+
return 'root';
|
|
1076
|
+
};
|
|
1077
|
+
const scopeParentId = (scopeKey: string): string | undefined => {
|
|
1078
|
+
if (scopeKey.startsWith('p:')) return processGroupNodeId(scopeKey.slice(2));
|
|
1079
|
+
if (scopeKey.startsWith('k:')) return packageGroupNodeId(scopeKey.slice(2));
|
|
1080
|
+
return undefined;
|
|
1081
|
+
};
|
|
1082
|
+
const dirGroupId = (scopeKey: string, path: string): string =>
|
|
1083
|
+
scopeKey === 'root'
|
|
1084
|
+
? directoryGroupNodeId(path)
|
|
1085
|
+
: `directory:${scopeKey}::${path}`;
|
|
1086
|
+
|
|
1087
|
+
const dirMembers = new Map<string, Set<string>>();
|
|
1088
|
+
const dirPathOf = new Map<string, string>();
|
|
1089
|
+
const dirScopeOf = new Map<string, string>();
|
|
1090
|
+
for (const c of doc.components) {
|
|
1091
|
+
const m = c.module?.trim();
|
|
1092
|
+
if (!m) continue;
|
|
1093
|
+
const scope = scopeKeyFor(c);
|
|
1094
|
+
let d = parentDirectory(m);
|
|
1095
|
+
while (d) {
|
|
1096
|
+
const ck = `${scope}\0${d}`;
|
|
1097
|
+
const set = dirMembers.get(ck) ?? new Set<string>();
|
|
1098
|
+
set.add(c.alias);
|
|
1099
|
+
dirMembers.set(ck, set);
|
|
1100
|
+
dirPathOf.set(ck, d);
|
|
1101
|
+
dirScopeOf.set(ck, scope);
|
|
1102
|
+
d = parentDirectory(d);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const keptDirs = new Set<string>();
|
|
1106
|
+
for (const [ck, members] of dirMembers) {
|
|
1107
|
+
if (members.size >= 2 || showSingletons) keptDirs.add(ck);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Files sitting directly in each directory (drives chain compaction below).
|
|
1111
|
+
const directFileCount = new Map<string, number>();
|
|
1112
|
+
for (const c of doc.components) {
|
|
1113
|
+
const m = c.module?.trim();
|
|
1114
|
+
if (!m) continue;
|
|
1115
|
+
const d = parentDirectory(m);
|
|
1116
|
+
if (!d) continue;
|
|
1117
|
+
const ck = `${scopeKeyFor(c)}\0${d}`;
|
|
1118
|
+
directFileCount.set(ck, (directFileCount.get(ck) ?? 0) + 1);
|
|
1119
|
+
}
|
|
1120
|
+
const nearestKeptDirCk = (ck: string): string | undefined => {
|
|
1121
|
+
const scope = dirScopeOf.get(ck)!;
|
|
1122
|
+
let a = parentDirectory(dirPathOf.get(ck)!);
|
|
1123
|
+
while (a) {
|
|
1124
|
+
const cand = `${scope}\0${a}`;
|
|
1125
|
+
if (keptDirs.has(cand)) return cand;
|
|
1126
|
+
a = parentDirectory(a);
|
|
1127
|
+
}
|
|
1128
|
+
return undefined;
|
|
1129
|
+
};
|
|
1130
|
+
// Compact single-child directory chains within a scope, mirroring the file
|
|
1131
|
+
// tree: `app` → `app/book` renders one `app/book` frame, not `app` wrapping
|
|
1132
|
+
// `book`.
|
|
1133
|
+
let compacting = true;
|
|
1134
|
+
while (compacting) {
|
|
1135
|
+
compacting = false;
|
|
1136
|
+
const childDirsOf = new Map<string, string[]>();
|
|
1137
|
+
for (const ck of keptDirs) {
|
|
1138
|
+
const a = nearestKeptDirCk(ck);
|
|
1139
|
+
if (!a) continue;
|
|
1140
|
+
const list = childDirsOf.get(a) ?? [];
|
|
1141
|
+
list.push(ck);
|
|
1142
|
+
childDirsOf.set(a, list);
|
|
1143
|
+
}
|
|
1144
|
+
for (const ck of [...keptDirs]) {
|
|
1145
|
+
if (
|
|
1146
|
+
(childDirsOf.get(ck) ?? []).length === 1 &&
|
|
1147
|
+
(directFileCount.get(ck) ?? 0) === 0
|
|
1148
|
+
) {
|
|
1149
|
+
keptDirs.delete(ck);
|
|
1150
|
+
compacting = true;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
const dirLabel = (ck: string): string => {
|
|
1155
|
+
const path = dirPathOf.get(ck)!;
|
|
1156
|
+
const ancestor = nearestKeptDirCk(ck);
|
|
1157
|
+
return ancestor ? path.slice(dirPathOf.get(ancestor)!.length + 1) : path;
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
const moduleRegionByKey = new Map(modules.map((r) => [r.key, r]));
|
|
1161
|
+
// A module's scope is uniform across its members (else undefined → root), so
|
|
1162
|
+
// a module straddling processes is never forced into one.
|
|
1163
|
+
const moduleScope = new Map<string, string | undefined>();
|
|
1164
|
+
for (const r of modules) {
|
|
1165
|
+
const scopes = new Set<string>();
|
|
1166
|
+
for (const a of r.memberAliases) {
|
|
1167
|
+
const c = byAlias.get(a);
|
|
1168
|
+
if (c) scopes.add(scopeKeyFor(c));
|
|
1169
|
+
}
|
|
1170
|
+
moduleScope.set(r.key, scopes.size === 1 ? [...scopes][0] : undefined);
|
|
1171
|
+
}
|
|
1172
|
+
const moduleDirCk = (moduleKey: string): string | undefined => {
|
|
1173
|
+
const scope = moduleScope.get(moduleKey);
|
|
1174
|
+
if (scope === undefined) return undefined;
|
|
1175
|
+
let d = parentDirectory(moduleKey);
|
|
1176
|
+
while (d) {
|
|
1177
|
+
const cand = `${scope}\0${d}`;
|
|
1178
|
+
if (keptDirs.has(cand)) return cand;
|
|
1179
|
+
d = parentDirectory(d);
|
|
1180
|
+
}
|
|
1181
|
+
return undefined;
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
const sharedPackageParent = (
|
|
1185
|
+
memberAliases: readonly string[],
|
|
1186
|
+
): string | undefined => {
|
|
1187
|
+
const seen = new Set<string>();
|
|
1188
|
+
for (const alias of memberAliases) {
|
|
1189
|
+
const c = byAlias.get(alias);
|
|
1190
|
+
const pkg = c ? componentPackageKey(c) : undefined;
|
|
1191
|
+
if (pkg) seen.add(pkg);
|
|
1192
|
+
}
|
|
1193
|
+
if (seen.size !== 1) return undefined;
|
|
1194
|
+
const pkg = [...seen][0]!;
|
|
1195
|
+
return keptPackageKeys.has(pkg) ? packageGroupNodeId(pkg) : undefined;
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
const moduleGroups: BoundaryLayoutGroup[] = modules.map((r) => {
|
|
1199
|
+
const ck = moduleDirCk(r.key);
|
|
1200
|
+
const parentId = ck
|
|
1201
|
+
? dirGroupId(dirScopeOf.get(ck)!, dirPathOf.get(ck)!)
|
|
1202
|
+
: scopeParentId(moduleScope.get(r.key) ?? 'root');
|
|
1203
|
+
// Relative label when nested: `app/book/actions.ts` under `app/book`
|
|
1204
|
+
// reads as `actions.ts`. At the root the full module path stays.
|
|
1205
|
+
const label = ck ? r.key.slice(dirPathOf.get(ck)!.length + 1) : r.label;
|
|
1206
|
+
return {
|
|
1207
|
+
id: moduleGroupNodeId(r.key),
|
|
1208
|
+
memberAliases: [...r.memberAliases],
|
|
1209
|
+
parentId,
|
|
1210
|
+
region: { ...r, label },
|
|
1211
|
+
};
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
const dirGroups: BoundaryLayoutGroup[] = [...keptDirs].map((ck) => {
|
|
1215
|
+
const scope = dirScopeOf.get(ck)!;
|
|
1216
|
+
const path = dirPathOf.get(ck)!;
|
|
1217
|
+
const ancestor = nearestKeptDirCk(ck);
|
|
1218
|
+
const parentId = ancestor
|
|
1219
|
+
? dirGroupId(dirScopeOf.get(ancestor)!, dirPathOf.get(ancestor)!)
|
|
1220
|
+
: scopeParentId(scope);
|
|
1221
|
+
// Immediate children: subdirectories and module frames parented here.
|
|
1222
|
+
// Only child *group ids* go in the layout member list — a group's
|
|
1223
|
+
// descendant leaves belong to its child groups alone, or ELK sees the
|
|
1224
|
+
// same leaf in two compound parents and rejects the layout.
|
|
1225
|
+
const childDirCks = [...keptDirs].filter((d) => nearestKeptDirCk(d) === ck);
|
|
1226
|
+
const childModuleKeys = modules
|
|
1227
|
+
.filter((m) => moduleDirCk(m.key) === ck)
|
|
1228
|
+
.map((m) => m.key);
|
|
1229
|
+
const claimed = new Set<string>();
|
|
1230
|
+
for (const d of childDirCks) {
|
|
1231
|
+
for (const a of dirMembers.get(d) ?? []) claimed.add(a);
|
|
1232
|
+
}
|
|
1233
|
+
for (const mk of childModuleKeys) {
|
|
1234
|
+
for (const a of moduleRegionByKey.get(mk)?.memberAliases ?? []) claimed.add(a);
|
|
1235
|
+
}
|
|
1236
|
+
const directLeaves = [...(dirMembers.get(ck) ?? [])].filter(
|
|
1237
|
+
(a) => !claimed.has(a),
|
|
1238
|
+
);
|
|
1239
|
+
const region: SubsystemProcessRegion = {
|
|
1240
|
+
kind: 'directory',
|
|
1241
|
+
key: path,
|
|
1242
|
+
label: dirLabel(ck),
|
|
1243
|
+
memberAliases: [...(dirMembers.get(ck) ?? [])],
|
|
1244
|
+
};
|
|
1245
|
+
return {
|
|
1246
|
+
id: dirGroupId(scope, path),
|
|
1247
|
+
memberAliases: [
|
|
1248
|
+
...childDirCks.map((d) => dirGroupId(dirScopeOf.get(d)!, dirPathOf.get(d)!)),
|
|
1249
|
+
...childModuleKeys.map(moduleGroupNodeId),
|
|
1250
|
+
...directLeaves,
|
|
1251
|
+
],
|
|
1252
|
+
parentId,
|
|
1253
|
+
region,
|
|
1254
|
+
};
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
const frameGroups = [...dirGroups, ...moduleGroups];
|
|
1258
|
+
|
|
1259
|
+
// Aliases owned by a module frame (singleton or not) must not become direct
|
|
1260
|
+
// leaves of an enclosing process/package frame.
|
|
1261
|
+
const moduleMemberAliases = new Set<string>();
|
|
1262
|
+
for (const r of modules) for (const a of r.memberAliases) moduleMemberAliases.add(a);
|
|
1263
|
+
|
|
1264
|
+
const processGroups: BoundaryLayoutGroup[] = processes.map((r) => {
|
|
1265
|
+
const processId = processGroupNodeId(r.key);
|
|
1266
|
+
const nested = frameGroups.filter((g) => g.parentId === processId);
|
|
1267
|
+
const claimed = new Set<string>();
|
|
1268
|
+
for (const g of nested) for (const a of g.region.memberAliases) claimed.add(a);
|
|
1269
|
+
const directLeaves = r.memberAliases.filter(
|
|
1270
|
+
(alias) => !claimed.has(alias) && !moduleMemberAliases.has(alias),
|
|
1271
|
+
);
|
|
1272
|
+
return {
|
|
1273
|
+
id: processId,
|
|
1274
|
+
memberAliases: [...nested.map((g) => g.id), ...directLeaves],
|
|
1275
|
+
parentId: sharedPackageParent(r.memberAliases),
|
|
1276
|
+
region: r,
|
|
1277
|
+
};
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
const processMemberAliases = new Set<string>();
|
|
1281
|
+
for (const r of processes) for (const a of r.memberAliases) processMemberAliases.add(a);
|
|
1282
|
+
|
|
1283
|
+
const packageGroups: BoundaryLayoutGroup[] = packages.map((r) => {
|
|
1284
|
+
const packageId = packageGroupNodeId(r.key);
|
|
1285
|
+
const nested = [...processGroups, ...frameGroups].filter(
|
|
1286
|
+
(g) => g.parentId === packageId,
|
|
1287
|
+
);
|
|
1288
|
+
const claimed = new Set<string>();
|
|
1289
|
+
for (const g of nested) for (const a of g.region.memberAliases) claimed.add(a);
|
|
1290
|
+
const directLeaves = r.memberAliases.filter(
|
|
1291
|
+
(alias) =>
|
|
1292
|
+
!claimed.has(alias) &&
|
|
1293
|
+
!moduleMemberAliases.has(alias) &&
|
|
1294
|
+
!processMemberAliases.has(alias),
|
|
1295
|
+
);
|
|
1296
|
+
return {
|
|
1297
|
+
id: packageId,
|
|
1298
|
+
memberAliases: [...nested.map((g) => g.id), ...directLeaves],
|
|
1299
|
+
region: r,
|
|
1300
|
+
};
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
return [...frameGroups, ...processGroups, ...packageGroups];
|
|
1304
|
+
}
|
|
1305
|
+
|
|
986
1306
|
export interface SubsystemGraphNodeData extends Record<string, unknown> {
|
|
987
1307
|
component: SubsystemComponent;
|
|
988
1308
|
/** Set while a file is open in the drawer: true if this node's component
|
|
@@ -1294,6 +1614,30 @@ export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][]
|
|
|
1294
1614
|
['registers-into', 'registration pattern', false],
|
|
1295
1615
|
];
|
|
1296
1616
|
|
|
1617
|
+
/**
|
|
1618
|
+
* Single border/badge color for directory (folder) frames. Folders read as one
|
|
1619
|
+
* kind of container, so they share a neutral hue rather than hashing per-path
|
|
1620
|
+
* like modules / processes / packages. Host `boundaryColors` still overrides.
|
|
1621
|
+
*/
|
|
1622
|
+
export const FOLDER_FRAME_COLOR = '#7aa2d4';
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* True when a boundary frame names a folder. `directory` frames always are.
|
|
1626
|
+
* A `module` frame is a folder when its key looks like a directory path — the
|
|
1627
|
+
* last segment has no file extension (the model has no explicit file/folder
|
|
1628
|
+
* flag, so this is a heuristic; `Dockerfile`-style extensionless files read as
|
|
1629
|
+
* folders).
|
|
1630
|
+
*/
|
|
1631
|
+
export function isFolderFrame(
|
|
1632
|
+
region: { kind: string; key: string } | undefined,
|
|
1633
|
+
): boolean {
|
|
1634
|
+
if (!region) return false;
|
|
1635
|
+
if (region.kind === 'directory') return true;
|
|
1636
|
+
if (region.kind !== 'module') return false;
|
|
1637
|
+
const last = region.key.split('/').pop() ?? '';
|
|
1638
|
+
return last !== '' && !last.includes('.');
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1297
1641
|
/** Package color palette (derived deterministically from the package name). */
|
|
1298
1642
|
export function packageColor(name: string): string {
|
|
1299
1643
|
const palette = [
|
|
@@ -1709,10 +2053,15 @@ export async function buildSubsystemGraph(
|
|
|
1709
2053
|
measuredHeights,
|
|
1710
2054
|
showSingletonFrames,
|
|
1711
2055
|
packageFrames,
|
|
2056
|
+
moduleNesting,
|
|
1712
2057
|
graphifyRelations,
|
|
1713
2058
|
orderByLine = false,
|
|
1714
2059
|
} = opts;
|
|
1715
|
-
const frameOpts: BoundaryFrameOptions = {
|
|
2060
|
+
const frameOpts: BoundaryFrameOptions = {
|
|
2061
|
+
showSingletonFrames,
|
|
2062
|
+
packageFrames,
|
|
2063
|
+
moduleNesting,
|
|
2064
|
+
};
|
|
1716
2065
|
const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
|
|
1717
2066
|
const edges = convertSubsystemToEdges(doc, graphifyRelations);
|
|
1718
2067
|
// Nested boundary tree: package → process → module → leaves.
|
|
@@ -1804,7 +2153,12 @@ export async function buildSubsystemGraph(
|
|
|
1804
2153
|
try {
|
|
1805
2154
|
const result = await computeElkLayout(nodes, edges, {
|
|
1806
2155
|
routingStyle: 'orthogonal',
|
|
1807
|
-
|
|
2156
|
+
// Edge-driven graphs flow left-to-right. With no edges (pure
|
|
2157
|
+
// containment, e.g. the static-topology layer) a RIGHT layout stacks
|
|
2158
|
+
// disconnected top-level frames in one vertical column; DOWN makes the
|
|
2159
|
+
// layered pass place those sibling frames along the horizontal axis
|
|
2160
|
+
// while each frame's members stack vertically.
|
|
2161
|
+
direction: edges.length > 0 ? 'RIGHT' : 'DOWN',
|
|
1808
2162
|
nodeSpacing: 60,
|
|
1809
2163
|
edgeSpacing: 30,
|
|
1810
2164
|
edgeNodeSpacing: 60,
|
|
@@ -1825,7 +2179,7 @@ export async function buildSubsystemGraph(
|
|
|
1825
2179
|
// room for the collapsed badge. Processes/packages never collapse:
|
|
1826
2180
|
// size the frame to hold the full centered label plus padding.
|
|
1827
2181
|
minWidth:
|
|
1828
|
-
g.region.kind === 'module'
|
|
2182
|
+
g.region.kind === 'module' || g.region.kind === 'directory'
|
|
1829
2183
|
? moduleMinWidthForBadge(g.region.label)
|
|
1830
2184
|
: boundaryMinWidthForBadge(g.region.label),
|
|
1831
2185
|
})),
|
|
@@ -1890,7 +2244,7 @@ export async function buildSubsystemGraph(
|
|
|
1890
2244
|
position: { x: 0, y: 0 },
|
|
1891
2245
|
width: Math.max(
|
|
1892
2246
|
400,
|
|
1893
|
-
g.region.kind === 'module'
|
|
2247
|
+
g.region.kind === 'module' || g.region.kind === 'directory'
|
|
1894
2248
|
? moduleMinWidthForBadge(g.region.label)
|
|
1895
2249
|
: boundaryMinWidthForBadge(g.region.label),
|
|
1896
2250
|
),
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
moduleBadgeHoverLabel,
|
|
34
34
|
moduleBadgeWidth,
|
|
35
35
|
packageColor,
|
|
36
|
+
FOLDER_FRAME_COLOR,
|
|
37
|
+
isFolderFrame,
|
|
36
38
|
type SubsystemGraphNodeData,
|
|
37
39
|
type SubsystemGroupNodeData,
|
|
38
40
|
type SubsystemGraphEdge,
|
|
@@ -445,8 +447,14 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
445
447
|
selected?: boolean;
|
|
446
448
|
};
|
|
447
449
|
const region = data.region;
|
|
448
|
-
// Explicit host override wins over the derived
|
|
449
|
-
|
|
450
|
+
// Explicit host override wins over the derived frame color. Folder frames
|
|
451
|
+
// (directory, or a module whose key names a folder) share one neutral hue;
|
|
452
|
+
// everything else hashes per key.
|
|
453
|
+
const color =
|
|
454
|
+
data.color ??
|
|
455
|
+
(isFolderFrame(region ?? undefined)
|
|
456
|
+
? FOLDER_FRAME_COLOR
|
|
457
|
+
: packageColor(region?.key ?? 'process'));
|
|
450
458
|
const dimmed = data.dimmed === true;
|
|
451
459
|
const hidden = (data as { hidden?: boolean }).hidden === true;
|
|
452
460
|
// Label is the region identity alone (path / process key / owner/name).
|
|
@@ -454,7 +462,9 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
454
462
|
// — don't prefix `module ·` / `package ·` or the path reads twice.
|
|
455
463
|
const label = region?.label ?? '';
|
|
456
464
|
const [expandedLabel, setExpandedLabel] = useState<string | null>(null);
|
|
457
|
-
|
|
465
|
+
// Directory frames (path-derived nesting) render like modules — dashed,
|
|
466
|
+
// collapsible path badge.
|
|
467
|
+
const isModule = region?.kind === 'module' || region?.kind === 'directory';
|
|
458
468
|
const isProcess = region?.kind === 'process';
|
|
459
469
|
const expanded = isModule && expandedLabel === label;
|
|
460
470
|
const availableBadgeWidth = Math.max(0, (width ?? 400) - MODULE_BADGE_INSET * 2 - 4);
|
package/src/utils/elkLayout.ts
CHANGED
|
@@ -759,6 +759,16 @@ export async function computeElkLayout(
|
|
|
759
759
|
if (parentId && builtGroups.has(parentId)) continue; // nested inside parent
|
|
760
760
|
elkParents.push(node);
|
|
761
761
|
}
|
|
762
|
+
// Order root-level compounds by model order (the caller's `groups` array), so
|
|
763
|
+
// disconnected sibling frames lay out deterministically — e.g. `app/book`
|
|
764
|
+
// before `lib`. Without this, planCompoundGroups' bottom-up build order
|
|
765
|
+
// decides placement.
|
|
766
|
+
const groupOrder = new Map(groupDefs.map((g, i) => [g.id, i]));
|
|
767
|
+
elkParents.sort(
|
|
768
|
+
(a, b) =>
|
|
769
|
+
(groupOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
|
|
770
|
+
(groupOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER),
|
|
771
|
+
);
|
|
762
772
|
|
|
763
773
|
// Create ELK graph
|
|
764
774
|
const rootOptions = getElkOptions(options);
|