project-graph-mcp 2.2.4 → 2.3.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.
Files changed (151) hide show
  1. package/ARCHITECTURE.md +81 -0
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +9 -4
  4. package/package.json +6 -13
  5. package/src/compact/expand.js +2 -4
  6. package/src/core/graph-builder.js +2 -2
  7. package/src/core/parser.js +2 -2
  8. package/src/network/server.js +1 -2
  9. package/src/network/web-server.js +4 -1
  10. package/vendor/symbiote-node/CHANGELOG.md +31 -0
  11. package/vendor/symbiote-node/LICENSE +21 -0
  12. package/vendor/symbiote-node/README.md +206 -0
  13. package/vendor/symbiote-node/canvas/AutoLayout.js +725 -0
  14. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +73 -0
  15. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +93 -0
  16. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +9 -0
  17. package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +962 -0
  18. package/vendor/symbiote-node/canvas/ConnectionRenderer.js +1468 -0
  19. package/vendor/symbiote-node/canvas/FlowSimulator.js +323 -0
  20. package/vendor/symbiote-node/canvas/ForceLayout.js +189 -0
  21. package/vendor/symbiote-node/canvas/ForceWorker.js +1325 -0
  22. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +97 -0
  23. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +176 -0
  24. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
  25. package/vendor/symbiote-node/canvas/LODManager.js +88 -0
  26. package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +71 -0
  27. package/vendor/symbiote-node/canvas/Minimap/Minimap.js +207 -0
  28. package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +9 -0
  29. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +261 -0
  30. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +1840 -0
  31. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
  32. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +97 -0
  33. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +132 -0
  34. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +21 -0
  35. package/vendor/symbiote-node/canvas/NodeViewManager.js +584 -0
  36. package/vendor/symbiote-node/canvas/PinExpansion.js +131 -0
  37. package/vendor/symbiote-node/canvas/PseudoConnection.js +80 -0
  38. package/vendor/symbiote-node/canvas/SubgraphManager.js +201 -0
  39. package/vendor/symbiote-node/canvas/SubgraphRouter.js +443 -0
  40. package/vendor/symbiote-node/canvas/ViewportActions.js +446 -0
  41. package/vendor/symbiote-node/core/Connection.js +45 -0
  42. package/vendor/symbiote-node/core/Editor.js +451 -0
  43. package/vendor/symbiote-node/core/Frame.js +31 -0
  44. package/vendor/symbiote-node/core/GraphMermaid.js +348 -0
  45. package/vendor/symbiote-node/core/GraphText.js +210 -0
  46. package/vendor/symbiote-node/core/Node.js +143 -0
  47. package/vendor/symbiote-node/core/Portal.js +104 -0
  48. package/vendor/symbiote-node/core/Socket.js +185 -0
  49. package/vendor/symbiote-node/core/SubgraphNode.js +125 -0
  50. package/vendor/symbiote-node/index.js +103 -0
  51. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +361 -0
  52. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +332 -0
  53. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
  54. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
  55. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +133 -0
  56. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
  57. package/vendor/symbiote-node/interactions/ConnectFlow.js +307 -0
  58. package/vendor/symbiote-node/interactions/Drag.js +102 -0
  59. package/vendor/symbiote-node/interactions/Selector.js +132 -0
  60. package/vendor/symbiote-node/interactions/SnapGrid.js +65 -0
  61. package/vendor/symbiote-node/interactions/Zoom.js +140 -0
  62. package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +88 -0
  63. package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +254 -0
  64. package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +11 -0
  65. package/vendor/symbiote-node/layout/Layout/Layout.css.js +88 -0
  66. package/vendor/symbiote-node/layout/Layout/Layout.js +622 -0
  67. package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +25 -0
  68. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +293 -0
  69. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +467 -0
  70. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +33 -0
  71. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +46 -0
  72. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +102 -0
  73. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
  74. package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +156 -0
  75. package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +250 -0
  76. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +379 -0
  77. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +263 -0
  78. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +20 -0
  79. package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +183 -0
  80. package/vendor/symbiote-node/layout/LayoutTree.js +246 -0
  81. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +43 -0
  82. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +89 -0
  83. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +14 -0
  84. package/vendor/symbiote-node/layout/index.js +16 -0
  85. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +61 -0
  86. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +79 -0
  87. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +19 -0
  88. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +41 -0
  89. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +24 -0
  90. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +16 -0
  91. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +65 -0
  92. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +29 -0
  93. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +13 -0
  94. package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +683 -0
  95. package/vendor/symbiote-node/node/GraphNode/GraphNode.js +92 -0
  96. package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +17 -0
  97. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +25 -0
  98. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +7 -0
  99. package/vendor/symbiote-node/node/PortItem/PortItem.css.js +90 -0
  100. package/vendor/symbiote-node/node/PortItem/PortItem.js +87 -0
  101. package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +10 -0
  102. package/vendor/symbiote-node/package.json +59 -0
  103. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
  104. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +131 -0
  105. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +16 -0
  106. package/vendor/symbiote-node/plugins/History.js +384 -0
  107. package/vendor/symbiote-node/plugins/Readonly.js +59 -0
  108. package/vendor/symbiote-node/shapes/CircleShape.js +80 -0
  109. package/vendor/symbiote-node/shapes/CommentShape.js +35 -0
  110. package/vendor/symbiote-node/shapes/DiamondShape.js +115 -0
  111. package/vendor/symbiote-node/shapes/NodeShape.js +80 -0
  112. package/vendor/symbiote-node/shapes/PillShape.js +91 -0
  113. package/vendor/symbiote-node/shapes/RectShape.js +72 -0
  114. package/vendor/symbiote-node/shapes/SVGShape.js +494 -0
  115. package/vendor/symbiote-node/shapes/index.js +53 -0
  116. package/vendor/symbiote-node/themes/Palette.js +32 -0
  117. package/vendor/symbiote-node/themes/Skin.js +113 -0
  118. package/vendor/symbiote-node/themes/Theme.js +84 -0
  119. package/vendor/symbiote-node/themes/carbon.js +137 -0
  120. package/vendor/symbiote-node/themes/dark.js +137 -0
  121. package/vendor/symbiote-node/themes/ebook.js +138 -0
  122. package/vendor/symbiote-node/themes/grey.js +137 -0
  123. package/vendor/symbiote-node/themes/light.js +137 -0
  124. package/vendor/symbiote-node/themes/neon.js +138 -0
  125. package/vendor/symbiote-node/themes/pcb.js +273 -0
  126. package/vendor/symbiote-node/themes/synthwave.js +137 -0
  127. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +86 -0
  128. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +128 -0
  129. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +29 -0
  130. package/web/app.js +6 -5
  131. package/web/components/canvas-graph.js +1666 -0
  132. package/web/components/event-feed/CodeWidget.js +32 -0
  133. package/web/components/event-feed/EventWidget.js +97 -0
  134. package/web/components/event-feed/ListWidget.js +57 -0
  135. package/web/components/event-feed/MiniGraphWidget.js +69 -0
  136. package/web/dashboard.js +1 -1
  137. package/web/index.html +4 -0
  138. package/web/panels/ActionBoard/ActionBoard.js +1 -1
  139. package/web/panels/SettingsPanel/SettingsPanel.tpl.js +1 -1
  140. package/web/panels/code-viewer.js +50 -15
  141. package/web/panels/dep-graph.js +2712 -7
  142. package/web/panels/file-tree.js +5 -2
  143. package/web/panels/live-monitor.js +75 -3
  144. package/web/style.css +33 -0
  145. package/docs/img/explorer-compact.jpg +0 -0
  146. package/docs/img/explorer-expanded.jpg +0 -0
  147. package/src/.contextignore +0 -22
  148. package/src/.project-graph-cache.json +0 -1
  149. package/src/compact/.project-graph-cache.json +0 -1
  150. package/web/.project-graph-cache.json +0 -1
  151. package/web/panels/SettingsPanel/.project-graph-cache.json +0 -1
@@ -0,0 +1,156 @@
1
+ /**
2
+ * LayoutRouter — universal hash-based router for layout system
3
+ *
4
+ * Uses Symbiote PubSub named data context (ROUTER) to provide
5
+ * reactive routing across the application.
6
+ *
7
+ * URL format: #panel/subpath?param1=value&param2=value
8
+ *
9
+ * Usage in templates: {{ROUTER/panel}}, {{ROUTER/subpath}}, {{ROUTER/query}}
10
+ * Usage in code: this.$['ROUTER/panel'], this.sub('ROUTER/panel', cb)
11
+ *
12
+ * @module symbiote-node/layout/LayoutRouter
13
+ */
14
+ import { PubSub } from '@symbiotejs/symbiote';
15
+
16
+ const CTX = 'ROUTER';
17
+
18
+ const routerCtx = PubSub.registerCtx({
19
+ panel: 'default',
20
+ subpath: '',
21
+ query: '',
22
+ }, CTX);
23
+
24
+ /**
25
+ * Parse query string into object
26
+ * @param {string} str - Query string (without leading ?)
27
+ * @returns {Object<string, string>}
28
+ */
29
+ export function parseQuery(str) {
30
+ if (!str) return {};
31
+ const result = {};
32
+ for (const pair of str.split('&')) {
33
+ const eqIdx = pair.indexOf('=');
34
+ if (eqIdx >= 0) {
35
+ result[decodeURIComponent(pair.substring(0, eqIdx))] = decodeURIComponent(pair.substring(eqIdx + 1));
36
+ }
37
+ }
38
+ return result;
39
+ }
40
+
41
+ /**
42
+ * Build query string from key-value object
43
+ * @param {Object<string, string>} params
44
+ * @returns {string}
45
+ */
46
+ export function buildQuery(params) {
47
+ const entries = Object.entries(params).filter(([, v]) => v !== '' && v != null);
48
+ if (entries.length === 0) return '';
49
+ return entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
50
+ }
51
+
52
+ /**
53
+ * Build full hash string from parts
54
+ * @param {string} panel
55
+ * @param {string} [subpath]
56
+ * @param {Object} [params]
57
+ * @returns {string}
58
+ */
59
+ export function buildHash(panel, subpath, params) {
60
+ let hash = panel;
61
+ if (subpath) hash += '/' + subpath;
62
+ const q = params ? buildQuery(params) : '';
63
+ if (q) hash += '?' + q;
64
+ return hash;
65
+ }
66
+
67
+ /**
68
+ * Navigate to a new route — updates URL and PubSub context
69
+ * @param {string} panel - Master panel section ID
70
+ * @param {string} [subpath] - Sub-path (entity ID, etc.)
71
+ * @param {Object} [params] - Query parameters
72
+ */
73
+ export function navigate(panel, subpath = '', params = {}) {
74
+ if (typeof location === 'undefined') return;
75
+ const hash = buildHash(panel, subpath, params);
76
+ // Use pushState instead of location.hash to ensure clean URL
77
+ // (location.hash preserves stale query strings like ?monitoring)
78
+ history.pushState(null, '', location.pathname + '#' + hash);
79
+ syncFromHash();
80
+ if (typeof window !== 'undefined') {
81
+ window.dispatchEvent(new Event('hashchange'));
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Update only query params of current route (keeps panel/subpath)
87
+ * Uses replaceState to avoid cluttering browser history
88
+ * @param {Object} params - Params to merge
89
+ */
90
+ export function updateParams(params) {
91
+ if (typeof location === 'undefined') return;
92
+ const currentQuery = parseQuery(routerCtx.read('query'));
93
+ const merged = { ...currentQuery };
94
+ for (const [k, v] of Object.entries(params)) {
95
+ if (v === '' || v == null) {
96
+ delete merged[k];
97
+ } else {
98
+ merged[k] = v;
99
+ }
100
+ }
101
+ const query = buildQuery(merged);
102
+ const hash = buildHash(routerCtx.read('panel'), routerCtx.read('subpath'), merged);
103
+ history.replaceState(null, '', '#' + hash);
104
+ routerCtx.pub('query', query);
105
+ if (typeof window !== 'undefined') {
106
+ window.dispatchEvent(new Event('hashchange'));
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Sync PubSub context from current URL hash
112
+ */
113
+ function syncFromHash() {
114
+ const raw = location.hash.replace(/^#/, '') || 'default';
115
+
116
+ const qIdx = raw.indexOf('?');
117
+ const pathPart = qIdx >= 0 ? raw.substring(0, qIdx) : raw;
118
+ const queryPart = qIdx >= 0 ? raw.substring(qIdx + 1) : '';
119
+
120
+ const slashIdx = pathPart.indexOf('/');
121
+ const panel = slashIdx >= 0 ? pathPart.substring(0, slashIdx) : pathPart;
122
+ const subpath = slashIdx >= 0 ? pathPart.substring(slashIdx + 1) : '';
123
+
124
+ routerCtx.pub('panel', panel);
125
+ routerCtx.pub('subpath', subpath);
126
+ routerCtx.pub('query', queryPart);
127
+ }
128
+
129
+ /**
130
+ * Get current route state
131
+ * @returns {{ panel: string, subpath: string, query: string }}
132
+ */
133
+ export function getRoute() {
134
+ return {
135
+ panel: routerCtx.read('panel'),
136
+ subpath: routerCtx.read('subpath'),
137
+ query: routerCtx.read('query'),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Set default panel (first section to show if hash is empty)
143
+ * @param {string} panel
144
+ */
145
+ export function setDefaultPanel(panel) {
146
+ if (typeof location === 'undefined') return;
147
+ if (!location.hash || location.hash === '#') {
148
+ navigate(panel);
149
+ }
150
+ }
151
+
152
+ // Initial sync + listen to hashchange (browser-only)
153
+ if (typeof location !== 'undefined' && typeof window !== 'undefined') {
154
+ syncFromHash();
155
+ window.addEventListener('hashchange', syncFromHash);
156
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * routerSync — bidirectional URL ↔ component state sync
3
+ *
4
+ * Maps URL query params to component init$ properties and vice versa.
5
+ * Only syncs when the component's panel is active.
6
+ *
7
+ * Supports two mapping formats:
8
+ *
9
+ * Simple: { componentProp: 'urlParam' }
10
+ * Extended: { componentProp: { param: 'urlParam', default: 'all', type: 'number' } }
11
+ *
12
+ * @example
13
+ * // Simple format:
14
+ * syncWithRouter(this, 'jobs', {
15
+ * filterStatus: 'status',
16
+ * filterRegion: 'region',
17
+ * });
18
+ *
19
+ * // Extended format:
20
+ * syncWithRouter(this, 'jobs', {
21
+ * filterStatus: { param: 'status', default: 'all' },
22
+ * currentPage: { param: 'page', default: 1, type: 'number' },
23
+ * });
24
+ *
25
+ * @module symbiote-node/layout/LayoutRouter/routerSync
26
+ */
27
+ import { parseQuery, updateParams } from './LayoutRouter.js';
28
+
29
+ /**
30
+ * Normalize mapping entry to { param, defaultVal, type }
31
+ * @param {string | { param: string, default?: *, type?: string }} entry
32
+ * @returns {{ param: string, defaultVal: *, type: string }}
33
+ */
34
+ function normalizeMapping(entry) {
35
+ if (typeof entry === 'string') {
36
+ return { param: entry, defaultVal: undefined, type: 'string' };
37
+ }
38
+ return {
39
+ param: entry.param,
40
+ defaultVal: entry.default,
41
+ type: entry.type ?? 'string',
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Cast value to the target type
47
+ * @param {string} value
48
+ * @param {string} type
49
+ * @returns {*}
50
+ */
51
+ function castValue(value, type) {
52
+ if (type === 'number') return Number(value);
53
+ if (type === 'boolean') return value === 'true';
54
+ return value;
55
+ }
56
+
57
+ /**
58
+ * Sync component state with router URL params
59
+ *
60
+ * @param {import('@symbiotejs/symbiote').default} component - Symbiote component
61
+ * @param {string} panelName - Panel this component belongs to
62
+ * @param {Object<string, string | { param: string, default?: *, type?: string }>} mapping
63
+ */
64
+ export function syncWithRouter(component, panelName, mapping) {
65
+ let syncing = false;
66
+
67
+ // Pre-normalize all mapping entries
68
+ const normalizedMap = {};
69
+ for (const [prop, entry] of Object.entries(mapping)) {
70
+ normalizedMap[prop] = normalizeMapping(entry);
71
+ }
72
+
73
+ /**
74
+ * Read URL params into component state
75
+ */
76
+ function readFromURL() {
77
+ if (syncing) return;
78
+ syncing = true;
79
+ const query = parseQuery(component.$['ROUTER/query']);
80
+ for (const [prop, { param, defaultVal, type }] of Object.entries(normalizedMap)) {
81
+ const rawValue = query[param];
82
+ if (rawValue !== undefined) {
83
+ const val = castValue(rawValue, type);
84
+ if (component.$[prop] !== val) {
85
+ component.$[prop] = val;
86
+ }
87
+ } else if (defaultVal !== undefined) {
88
+ // Apply default when param missing from URL
89
+ if (component.$[prop] !== defaultVal) {
90
+ component.$[prop] = defaultVal;
91
+ }
92
+ }
93
+ }
94
+ syncing = false;
95
+ }
96
+
97
+ /**
98
+ * Write component state to URL params
99
+ * @param {string} prop - Changed property name
100
+ */
101
+ function writeToURL(prop) {
102
+ if (syncing) return;
103
+ if (component.$['ROUTER/panel'] !== panelName) return;
104
+ syncing = true;
105
+ const { param, defaultVal } = normalizedMap[prop];
106
+ const value = component.$[prop];
107
+ // Skip writing default values to keep URL clean
108
+ if (value === defaultVal) {
109
+ updateParams({ [param]: '' });
110
+ } else {
111
+ updateParams({ [param]: String(value) });
112
+ }
113
+ syncing = false;
114
+ }
115
+
116
+ // Subscribe to route changes — read URL when this panel becomes active
117
+ component.sub('ROUTER/panel', (panel) => {
118
+ if (panel === panelName) {
119
+ readFromURL();
120
+ }
121
+ });
122
+
123
+ // Subscribe to query changes — update component when URL params change
124
+ component.sub('ROUTER/query', () => {
125
+ if (component.$['ROUTER/panel'] !== panelName) return;
126
+ readFromURL();
127
+ });
128
+
129
+ // Subscribe to component property changes — write to URL
130
+ for (const prop of Object.keys(normalizedMap)) {
131
+ component.sub(prop, () => {
132
+ if (component.$['ROUTER/panel'] === panelName) {
133
+ writeToURL(prop);
134
+ }
135
+ });
136
+ }
137
+
138
+ // Initial read if already on this panel
139
+ if (component.$['ROUTER/panel'] === panelName) {
140
+ readFromURL();
141
+ }
142
+ }
143
+
144
+ /**
145
+ * setupPanelRouting — high-level panel routing setup
146
+ *
147
+ * Centralizes all routing logic for a panel:
148
+ * - Panel activation (onActivate callback)
149
+ * - List/detail switching via ROUTER/subpath
150
+ * - Tab sync via ?tab= query param
151
+ *
152
+ * Convention:
153
+ * #panel → list view, default tab
154
+ * #panel?tab=groups → list view, groups tab
155
+ * #panel/{id} → detail view
156
+ *
157
+ * Component requirements:
158
+ * - ref="listWrap" → container for list view (hidden when detail)
159
+ * - <detail-component> → detail view element (hidden when list)
160
+ * - $.activeTab → tab state property (if tabs configured)
161
+ *
162
+ * @param {import('@symbiotejs/symbiote').default} component
163
+ * @param {string} panelName - Panel section ID (e.g. 'users')
164
+ * @param {Object} config
165
+ * @param {string[]} [config.tabs] - Tab names, first is default
166
+ * @param {{ component: string, loadMethod: string }} [config.detail] - Detail view config
167
+ * @param {Function} [config.onActivate] - Called when panel becomes active (list mode)
168
+ * @param {Object} [config.syncParams] - Additional params to sync via syncWithRouter
169
+ *
170
+ * @example
171
+ * renderCallback() {
172
+ * setupPanelRouting(this, 'users', {
173
+ * tabs: ['users', 'groups'],
174
+ * detail: { component: 'user-detail-view', loadMethod: 'loadUser' },
175
+ * onActivate: () => this.#loadData(),
176
+ * });
177
+ * }
178
+ */
179
+ export function setupPanelRouting(component, panelName, config = {}) {
180
+ const { tabs, detail, onActivate, syncParams } = config;
181
+
182
+ // --- Tab sync via ?tab= ---
183
+ if (tabs && tabs.length > 0) {
184
+ const defaultTab = tabs[0];
185
+ syncWithRouter(component, panelName, {
186
+ activeTab: { param: 'tab', default: defaultTab },
187
+ ...(syncParams || {}),
188
+ });
189
+ } else if (syncParams) {
190
+ syncWithRouter(component, panelName, syncParams);
191
+ }
192
+
193
+ /**
194
+ * Check and apply list/detail mode based on ROUTER/subpath
195
+ */
196
+ function checkDetailMode() {
197
+ if (component.$['ROUTER/panel'] !== panelName) return;
198
+
199
+ const subpath = component.$['ROUTER/subpath'];
200
+ const listWrap = component.ref?.listWrap;
201
+ const isDetail = !!(detail && subpath);
202
+
203
+ // Global signal — CSS can hide tabs/actions via [data-detail]
204
+ component.toggleAttribute('data-detail', isDetail);
205
+
206
+ if (isDetail) {
207
+ // --- Detail mode ---
208
+ if (listWrap) listWrap.hidden = true;
209
+
210
+ const detailEl = component.querySelector(detail.component);
211
+ if (detailEl) {
212
+ detailEl.hidden = false;
213
+ if (typeof detailEl[detail.loadMethod] === 'function') {
214
+ detailEl[detail.loadMethod](subpath);
215
+ }
216
+ }
217
+ } else {
218
+ // --- List mode ---
219
+ if (listWrap) listWrap.hidden = false;
220
+
221
+ if (detail) {
222
+ const detailEl = component.querySelector(detail.component);
223
+ if (detailEl) detailEl.hidden = true;
224
+ }
225
+
226
+ if (onActivate) onActivate();
227
+ }
228
+ }
229
+
230
+ // Subscribe to panel activation
231
+ component.sub('ROUTER/panel', (panel) => {
232
+ if (panel === panelName) {
233
+ checkDetailMode();
234
+ }
235
+ });
236
+
237
+ // Subscribe to subpath changes (list ↔ detail)
238
+ if (detail) {
239
+ component.sub('ROUTER/subpath', () => {
240
+ if (component.$['ROUTER/panel'] === panelName) {
241
+ checkDetailMode();
242
+ }
243
+ });
244
+ }
245
+
246
+ // Initial check if already on this panel
247
+ if (component.$['ROUTER/panel'] === panelName) {
248
+ checkDetailMode();
249
+ }
250
+ }