@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
|
@@ -18,19 +18,19 @@ export interface SubsystemFileTreeProps {
|
|
|
18
18
|
/** File to transiently highlight while its node is hovered on the graph;
|
|
19
19
|
* falls back to `selectedFile` when null. */
|
|
20
20
|
hoveredFile?: string | null;
|
|
21
|
+
/** Boundary path (folder or file) to transiently highlight — e.g. hovering a
|
|
22
|
+
* module/directory frame on the canvas. Matched with or without a trailing
|
|
23
|
+
* slash. Takes a back seat to `hoveredFile`. */
|
|
24
|
+
hoveredFolder?: string | null;
|
|
21
25
|
/** Called when a file row is clicked; upstream toggles the drawer. */
|
|
22
26
|
onSelectFile?: (file: string) => void;
|
|
23
|
-
/** Called on hover in/out of a file row — directories and non-file rows
|
|
24
|
-
* emit null. Pierre exposes no hover API, so this rides a shadow-DOM
|
|
25
|
-
* `mouseover` listener (see below). */
|
|
26
|
-
onHoverFile?: (file: string | null) => void;
|
|
27
27
|
/** Hide the built-in "Files N" header row — used when an upstream repo
|
|
28
28
|
* header already labels the tree. */
|
|
29
29
|
headerless?: boolean;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** Fills its parent height by design — pin it with a sized flex container. */
|
|
33
|
-
export function SubsystemFileTree({ files, selectedFile, hoveredFile,
|
|
33
|
+
export function SubsystemFileTree({ files, selectedFile, hoveredFile, hoveredFolder, onSelectFile, headerless }: SubsystemFileTreeProps) {
|
|
34
34
|
const { theme } = useTheme();
|
|
35
35
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
36
36
|
const paths = useMemo(() => Array.from(new Set(files)).sort(), [files]);
|
|
@@ -42,8 +42,6 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
|
|
|
42
42
|
pathSetRef.current = pathSet;
|
|
43
43
|
const onSelectFileRef = useRef(onSelectFile);
|
|
44
44
|
onSelectFileRef.current = onSelectFile;
|
|
45
|
-
const onHoverFileRef = useRef(onHoverFile);
|
|
46
|
-
onHoverFileRef.current = onHoverFile;
|
|
47
45
|
const selectedFileRef = useRef<string | null>(selectedFile ?? null);
|
|
48
46
|
selectedFileRef.current = selectedFile ?? null;
|
|
49
47
|
// Suppresses onSelectFile while selection is driven programmatically from
|
|
@@ -96,59 +94,28 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
|
|
|
96
94
|
useEffect(() => {
|
|
97
95
|
hoveredRowRef.current?.style.removeProperty('background');
|
|
98
96
|
hoveredRowRef.current = null;
|
|
99
|
-
const target = hoveredFile ?? null;
|
|
100
|
-
if (!target
|
|
97
|
+
const target = hoveredFile ?? hoveredFolder ?? null;
|
|
98
|
+
if (!target) return;
|
|
99
|
+
// Files are keyed exact; folder rows may carry a trailing slash. Try both.
|
|
100
|
+
const candidates = hoveredFile ? [target] : [target, `${target}/`];
|
|
101
101
|
|
|
102
102
|
// Depth-first walk across shadow roots — handles nested custom elements.
|
|
103
103
|
const roots: Array<Document | ShadowRoot> = [document];
|
|
104
104
|
while (roots.length > 0) {
|
|
105
105
|
const root = roots.shift()!;
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
for (const candidate of candidates) {
|
|
107
|
+
const row = root.querySelector(`[data-item-path="${CSS.escape(candidate)}"]`);
|
|
108
|
+
if (row instanceof HTMLElement) {
|
|
109
|
+
row.style.background = 'var(--trees-theme-list-hover-bg, rgba(128, 128, 128, 0.25))';
|
|
110
|
+
hoveredRowRef.current = row;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
111
113
|
}
|
|
112
114
|
for (const el of Array.from(root.querySelectorAll('*'))) {
|
|
113
115
|
if (el.shadowRoot) roots.push(el.shadowRoot);
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
|
-
}, [hoveredFile]);
|
|
117
|
-
|
|
118
|
-
// Hover notifications. Pierre exposes no hover callback hook, but its rows
|
|
119
|
-
// carry `data-item-path` / `data-item-type` inside an OPEN shadow root and
|
|
120
|
-
// it binds no `mouseover`/`mouseleave` handlers, so native mouse events
|
|
121
|
-
// bubble out to this wrapper. Resolve the row via `composedPath()` (which
|
|
122
|
-
// crosses the shadow boundary even though `event.target` is retargeted to
|
|
123
|
-
// the host), then emit the repo-root-relative path. Directories emit null.
|
|
124
|
-
const lastHoveredRef = useRef<string | null>(null);
|
|
125
|
-
useEffect(() => {
|
|
126
|
-
const host = treeRef.current;
|
|
127
|
-
if (!host) return;
|
|
128
|
-
const resolveFile = (e: MouseEvent): string | null => {
|
|
129
|
-
for (const el of e.composedPath()) {
|
|
130
|
-
if (!(el instanceof HTMLElement) || el.dataset.type !== 'item') continue;
|
|
131
|
-
if (el.dataset.itemType === 'folder') return null;
|
|
132
|
-
const raw = el.dataset.itemPath ?? '';
|
|
133
|
-
const path = raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
|
134
|
-
return pathSetRef.current.has(path) ? path : null;
|
|
135
|
-
}
|
|
136
|
-
return null;
|
|
137
|
-
};
|
|
138
|
-
const emit = (file: string | null) => {
|
|
139
|
-
if (lastHoveredRef.current === file) return;
|
|
140
|
-
lastHoveredRef.current = file;
|
|
141
|
-
onHoverFileRef.current?.(file);
|
|
142
|
-
};
|
|
143
|
-
const handleOver = (e: MouseEvent) => emit(resolveFile(e));
|
|
144
|
-
const handleLeave = () => emit(null);
|
|
145
|
-
host.addEventListener('mouseover', handleOver);
|
|
146
|
-
host.addEventListener('mouseleave', handleLeave);
|
|
147
|
-
return () => {
|
|
148
|
-
host.removeEventListener('mouseover', handleOver);
|
|
149
|
-
host.removeEventListener('mouseleave', handleLeave);
|
|
150
|
-
};
|
|
151
|
-
}, []);
|
|
118
|
+
}, [hoveredFile, hoveredFolder]);
|
|
152
119
|
|
|
153
120
|
// Re-scope the tree in place when the subsystem's file set changes.
|
|
154
121
|
useEffect(() => {
|
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
boundaryMinWidthForBadge,
|
|
23
23
|
MODULE_BADGE_INSET,
|
|
24
24
|
packageGroupNodeId,
|
|
25
|
+
directoryGroupNodeId,
|
|
25
26
|
buildBoundaryLayoutGroups,
|
|
27
|
+
buildBoundaryLayoutGroupsByPath,
|
|
26
28
|
buildSubsystemGraph,
|
|
27
29
|
componentPackageKey,
|
|
28
30
|
deriveNameFromSymbol,
|
|
@@ -851,6 +853,152 @@ describe('isConstructsOnlyModel', () => {
|
|
|
851
853
|
}),
|
|
852
854
|
).toBe(false);
|
|
853
855
|
});
|
|
856
|
+
|
|
857
|
+
test('false when module/process containment frames exist, even with no edges', () => {
|
|
858
|
+
expect(
|
|
859
|
+
isConstructsOnlyModel({
|
|
860
|
+
components: [
|
|
861
|
+
{ alias: 'a', module: 'src/a.ts' },
|
|
862
|
+
{ alias: 'b', module: 'src/b.ts' },
|
|
863
|
+
],
|
|
864
|
+
}),
|
|
865
|
+
).toBe(false);
|
|
866
|
+
expect(
|
|
867
|
+
isConstructsOnlyModel({
|
|
868
|
+
components: [{ alias: 'a', process: 'app/server' }],
|
|
869
|
+
}),
|
|
870
|
+
).toBe(false);
|
|
871
|
+
// Blank/whitespace membership is not a frame.
|
|
872
|
+
expect(
|
|
873
|
+
isConstructsOnlyModel({ components: [{ alias: 'a', module: ' ' }] }),
|
|
874
|
+
).toBe(true);
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
describe('buildBoundaryLayoutGroupsByPath', () => {
|
|
879
|
+
const pathComps: SubsystemComponent[] = [
|
|
880
|
+
{ alias: 'record', name: 'record', construct: 'type_alias', file: 'src/session/transcript.ts', module: 'src/session/transcript.ts', purl: 'pkg:github/p/app' },
|
|
881
|
+
{ alias: 'parse', name: 'parse', construct: 'function', file: 'src/session/transcript.ts', module: 'src/session/transcript.ts', purl: 'pkg:github/p/app' },
|
|
882
|
+
{ alias: 'tool-name', name: 'toolName', construct: 'function', file: 'src/session/paths.ts', module: 'src/session/paths.ts', purl: 'pkg:github/p/app' },
|
|
883
|
+
{ alias: 'boot', name: 'boot', construct: 'function', file: 'src/host/main.ts', module: 'src/host/main.ts', purl: 'pkg:github/p/app' },
|
|
884
|
+
{ alias: 'store', name: 'store', construct: 'store', file: 'src/host/store.ts', module: 'src/host/store.ts', purl: 'pkg:github/p/app' },
|
|
885
|
+
];
|
|
886
|
+
|
|
887
|
+
test('derives directory frames and nests modules under them', () => {
|
|
888
|
+
const groups = buildBoundaryLayoutGroupsByPath(
|
|
889
|
+
{ components: pathComps },
|
|
890
|
+
{ showSingletonFrames: true },
|
|
891
|
+
);
|
|
892
|
+
const byId = new Map(groups.map((g) => [g.id, g]));
|
|
893
|
+
|
|
894
|
+
expect(byId.has(directoryGroupNodeId('src'))).toBe(true);
|
|
895
|
+
expect(byId.has(directoryGroupNodeId('src/session'))).toBe(true);
|
|
896
|
+
expect(byId.has(directoryGroupNodeId('src/host'))).toBe(true);
|
|
897
|
+
|
|
898
|
+
// directory → directory nesting
|
|
899
|
+
expect(byId.get(directoryGroupNodeId('src/session'))?.parentId).toBe(
|
|
900
|
+
directoryGroupNodeId('src'),
|
|
901
|
+
);
|
|
902
|
+
// module → directory nesting
|
|
903
|
+
expect(byId.get(moduleGroupNodeId('src/session/transcript.ts'))?.parentId).toBe(
|
|
904
|
+
directoryGroupNodeId('src/session'),
|
|
905
|
+
);
|
|
906
|
+
// top directory has no frame above it
|
|
907
|
+
expect(byId.get(directoryGroupNodeId('src'))?.parentId).toBeUndefined();
|
|
908
|
+
|
|
909
|
+
// Labels are relative to the parent frame, not the full path.
|
|
910
|
+
expect(byId.get(directoryGroupNodeId('src'))?.region.label).toBe('src');
|
|
911
|
+
expect(byId.get(directoryGroupNodeId('src/session'))?.region.label).toBe('session');
|
|
912
|
+
expect(byId.get(moduleGroupNodeId('src/session/transcript.ts'))?.region.label).toBe(
|
|
913
|
+
'transcript.ts',
|
|
914
|
+
);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test('each leaf is claimed by exactly one group (no compound duplicates)', () => {
|
|
918
|
+
const groups = buildBoundaryLayoutGroupsByPath(
|
|
919
|
+
{ components: pathComps },
|
|
920
|
+
{ showSingletonFrames: true },
|
|
921
|
+
);
|
|
922
|
+
const groupIds = new Set(groups.map((g) => g.id));
|
|
923
|
+
const leafCount = new Map<string, number>();
|
|
924
|
+
for (const g of groups) {
|
|
925
|
+
for (const m of g.memberAliases) {
|
|
926
|
+
// group ids are allowed to repeat (as child references); leaves must not
|
|
927
|
+
if (groupIds.has(m)) continue;
|
|
928
|
+
leafCount.set(m, (leafCount.get(m) ?? 0) + 1);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
for (const [alias, n] of leafCount) {
|
|
932
|
+
expect(n, `${alias} claimed ${n} times`).toBe(1);
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test('compacts single-child directory chains (app → app/book)', () => {
|
|
937
|
+
const comps: SubsystemComponent[] = [
|
|
938
|
+
{ alias: 'page', name: 'page', construct: 'function', file: 'app/book/page.tsx', module: 'app/book/page.tsx', purl: 'pkg:github/p/app' },
|
|
939
|
+
{ alias: 'actions', name: 'actions', construct: 'function', file: 'app/book/actions.ts', module: 'app/book/actions.ts', purl: 'pkg:github/p/app' },
|
|
940
|
+
{ alias: 'lib-a', name: 'a', construct: 'function', file: 'lib/a.ts', module: 'lib/a.ts', purl: 'pkg:github/p/app' },
|
|
941
|
+
{ alias: 'lib-b', name: 'b', construct: 'function', file: 'lib/b.ts', module: 'lib/b.ts', purl: 'pkg:github/p/app' },
|
|
942
|
+
];
|
|
943
|
+
const groups = buildBoundaryLayoutGroupsByPath(
|
|
944
|
+
{ components: comps },
|
|
945
|
+
{ showSingletonFrames: true },
|
|
946
|
+
);
|
|
947
|
+
const ids = new Set(groups.map((g) => g.id));
|
|
948
|
+
// `app` has a single child dir and no direct files → merged into `app/book`.
|
|
949
|
+
expect(ids.has(directoryGroupNodeId('app'))).toBe(false);
|
|
950
|
+
expect(ids.has(directoryGroupNodeId('app/book'))).toBe(true);
|
|
951
|
+
const appBook = groups.find((g) => g.id === directoryGroupNodeId('app/book'))!;
|
|
952
|
+
expect(appBook.parentId).toBeUndefined();
|
|
953
|
+
expect(appBook.region.label).toBe('app/book');
|
|
954
|
+
expect(ids.has(directoryGroupNodeId('lib'))).toBe(true);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test('module directories are scoped to their process (process stays primary)', () => {
|
|
958
|
+
const comps: SubsystemComponent[] = [
|
|
959
|
+
{ alias: 'page', name: 'page', construct: 'function', file: 'app/book/page.tsx', module: 'app/book/page.tsx', process: 'web/client', purl: 'pkg:github/p/app' },
|
|
960
|
+
{ alias: 'a1', name: 'a1', construct: 'function', file: 'app/book/actions.ts', module: 'app/book/actions.ts', process: 'web/server', purl: 'pkg:github/p/app' },
|
|
961
|
+
{ alias: 'a2', name: 'a2', construct: 'function', file: 'app/book/actions.ts', module: 'app/book/actions.ts', process: 'web/server', purl: 'pkg:github/p/app' },
|
|
962
|
+
];
|
|
963
|
+
const groups = buildBoundaryLayoutGroupsByPath(
|
|
964
|
+
{ components: comps },
|
|
965
|
+
{ showSingletonFrames: true },
|
|
966
|
+
);
|
|
967
|
+
const client = groups.find((g) => g.id === processGroupNodeId('web/client'))!;
|
|
968
|
+
const server = groups.find((g) => g.id === processGroupNodeId('web/server'))!;
|
|
969
|
+
expect(client).toBeDefined();
|
|
970
|
+
expect(server).toBeDefined();
|
|
971
|
+
|
|
972
|
+
const clientDirs = groups.filter(
|
|
973
|
+
(g) => g.region.kind === 'directory' && g.parentId === processGroupNodeId('web/client'),
|
|
974
|
+
);
|
|
975
|
+
const serverDirs = groups.filter(
|
|
976
|
+
(g) => g.region.kind === 'directory' && g.parentId === processGroupNodeId('web/server'),
|
|
977
|
+
);
|
|
978
|
+
// Same folder path is split per process — separate, scoped frames.
|
|
979
|
+
expect(clientDirs.map((g) => g.region.key)).toEqual(['app/book']);
|
|
980
|
+
expect(serverDirs.map((g) => g.region.key)).toEqual(['app/book']);
|
|
981
|
+
expect(clientDirs[0]!.id).not.toBe(serverDirs[0]!.id);
|
|
982
|
+
// Process frames own those directories as children (not dropped/empty).
|
|
983
|
+
expect(client.memberAliases).toContain(clientDirs[0]!.id);
|
|
984
|
+
expect(server.memberAliases).toContain(serverDirs[0]!.id);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test('exact mode adds no directory frames', () => {
|
|
988
|
+
const groups = buildBoundaryLayoutGroups(
|
|
989
|
+
{ components: pathComps },
|
|
990
|
+
{ showSingletonFrames: true, moduleNesting: 'exact' },
|
|
991
|
+
);
|
|
992
|
+
expect(groups.some((g) => g.region.kind === 'directory')).toBe(false);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test('path mode is selected by the moduleNesting option', () => {
|
|
996
|
+
const groups = buildBoundaryLayoutGroups(
|
|
997
|
+
{ components: pathComps },
|
|
998
|
+
{ showSingletonFrames: true, moduleNesting: 'path' },
|
|
999
|
+
);
|
|
1000
|
+
expect(groups.some((g) => g.region.kind === 'directory')).toBe(true);
|
|
1001
|
+
});
|
|
854
1002
|
});
|
|
855
1003
|
|
|
856
1004
|
describe('describeConstructBreakdown', () => {
|