create-project-arch 1.0.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/README.md +58 -0
- package/dist/cli.js +232 -0
- package/dist/cli.test.js +8 -0
- package/package.json +29 -0
- package/templates/arch-ui/.arch/edges/decision_to_domain.json +4 -0
- package/templates/arch-ui/.arch/edges/milestone_to_task.json +4 -0
- package/templates/arch-ui/.arch/edges/task_to_decision.json +4 -0
- package/templates/arch-ui/.arch/edges/task_to_module.json +4 -0
- package/templates/arch-ui/.arch/graph.json +17 -0
- package/templates/arch-ui/.arch/nodes/decisions.json +4 -0
- package/templates/arch-ui/.arch/nodes/domains.json +4 -0
- package/templates/arch-ui/.arch/nodes/milestones.json +4 -0
- package/templates/arch-ui/.arch/nodes/modules.json +4 -0
- package/templates/arch-ui/.arch/nodes/tasks.json +4 -0
- package/templates/arch-ui/app/api/architecture/map/route.ts +13 -0
- package/templates/arch-ui/app/api/decisions/route.ts +23 -0
- package/templates/arch-ui/app/api/domain-docs/route.ts +89 -0
- package/templates/arch-ui/app/api/domains/route.ts +10 -0
- package/templates/arch-ui/app/api/graph/route.ts +16 -0
- package/templates/arch-ui/app/api/health/route.ts +44 -0
- package/templates/arch-ui/app/api/node-files/route.ts +173 -0
- package/templates/arch-ui/app/api/phases/route.ts +10 -0
- package/templates/arch-ui/app/api/route.ts +22 -0
- package/templates/arch-ui/app/api/search/route.ts +56 -0
- package/templates/arch-ui/app/api/task-doc/[taskId]/route.ts +60 -0
- package/templates/arch-ui/app/api/tasks/route.ts +36 -0
- package/templates/arch-ui/app/api/trace/file/route.ts +40 -0
- package/templates/arch-ui/app/api/trace/task/[taskId]/route.ts +12 -0
- package/templates/arch-ui/app/architecture/page.tsx +5 -0
- package/templates/arch-ui/app/globals.css +240 -0
- package/templates/arch-ui/app/health/page.tsx +48 -0
- package/templates/arch-ui/app/layout.tsx +19 -0
- package/templates/arch-ui/app/page.tsx +5 -0
- package/templates/arch-ui/app/work/page.tsx +265 -0
- package/templates/arch-ui/components/app-shell.tsx +171 -0
- package/templates/arch-ui/components/error-boundary.tsx +53 -0
- package/templates/arch-ui/components/graph/arch-node.tsx +77 -0
- package/templates/arch-ui/components/graph/build-graph-from-dataset.ts +196 -0
- package/templates/arch-ui/components/graph/build-initial-graph.ts +245 -0
- package/templates/arch-ui/components/graph/graph-context-menu.tsx +84 -0
- package/templates/arch-ui/components/graph/graph-doc-panel.tsx +46 -0
- package/templates/arch-ui/components/graph/graph-types.ts +82 -0
- package/templates/arch-ui/components/graph/use-auto-layout.ts +65 -0
- package/templates/arch-ui/components/graph/use-connection-validation.ts +62 -0
- package/templates/arch-ui/components/graph/use-flow-persistence.ts +48 -0
- package/templates/arch-ui/components/graph-canvas.tsx +670 -0
- package/templates/arch-ui/components/health-panel.tsx +49 -0
- package/templates/arch-ui/components/inspector-context.tsx +35 -0
- package/templates/arch-ui/components/inspector.tsx +895 -0
- package/templates/arch-ui/components/markdown-viewer.tsx +74 -0
- package/templates/arch-ui/components/sidebar.tsx +531 -0
- package/templates/arch-ui/components/topbar.tsx +187 -0
- package/templates/arch-ui/components/work-table.tsx +57 -0
- package/templates/arch-ui/components/workspace-context.tsx +274 -0
- package/templates/arch-ui/eslint.config.js +2 -0
- package/templates/arch-ui/global.d.ts +1 -0
- package/templates/arch-ui/lib/api.ts +93 -0
- package/templates/arch-ui/lib/arch-model.ts +113 -0
- package/templates/arch-ui/lib/graph-dataset.ts +756 -0
- package/templates/arch-ui/lib/graph-schema.ts +408 -0
- package/templates/arch-ui/lib/project-root.ts +52 -0
- package/templates/arch-ui/lib/types.ts +116 -0
- package/templates/arch-ui/next-env.d.ts +6 -0
- package/templates/arch-ui/next.config.js +17 -0
- package/templates/arch-ui/package.json +38 -0
- package/templates/arch-ui/postcss.config.mjs +6 -0
- package/templates/arch-ui/tailwind.config.ts +11 -0
- package/templates/arch-ui/tsconfig.json +21 -0
- package/templates/ui-package/eslint.config.mjs +4 -0
- package/templates/ui-package/package.json +26 -0
- package/templates/ui-package/src/accordion.tsx +10 -0
- package/templates/ui-package/src/badge.tsx +12 -0
- package/templates/ui-package/src/button.tsx +32 -0
- package/templates/ui-package/src/card.tsx +22 -0
- package/templates/ui-package/src/code.tsx +6 -0
- package/templates/ui-package/src/command.tsx +18 -0
- package/templates/ui-package/src/dialog.tsx +6 -0
- package/templates/ui-package/src/dropdown-menu.tsx +10 -0
- package/templates/ui-package/src/input.tsx +6 -0
- package/templates/ui-package/src/navigation-menu.tsx +6 -0
- package/templates/ui-package/src/scroll-area.tsx +6 -0
- package/templates/ui-package/src/select.tsx +6 -0
- package/templates/ui-package/src/separator.tsx +6 -0
- package/templates/ui-package/src/sheet.tsx +6 -0
- package/templates/ui-package/src/skeleton.tsx +6 -0
- package/templates/ui-package/src/table.tsx +26 -0
- package/templates/ui-package/src/tabs.tsx +14 -0
- package/templates/ui-package/src/toggle-group.tsx +10 -0
- package/templates/ui-package/src/utils.ts +3 -0
- package/templates/ui-package/tsconfig.json +10 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@repo/ui/button";
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/card";
|
|
5
|
+
import { Input } from "@repo/ui/input";
|
|
6
|
+
import {
|
|
7
|
+
addEdge,
|
|
8
|
+
Background,
|
|
9
|
+
ControlButton,
|
|
10
|
+
Controls,
|
|
11
|
+
Edge,
|
|
12
|
+
MiniMap,
|
|
13
|
+
Node,
|
|
14
|
+
OnConnect,
|
|
15
|
+
ReactFlow,
|
|
16
|
+
ReactFlowInstance,
|
|
17
|
+
useEdgesState,
|
|
18
|
+
useNodesState,
|
|
19
|
+
} from "reactflow";
|
|
20
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
21
|
+
import { GraphDatasetResponse } from "../lib/types";
|
|
22
|
+
import { GRAPH_VIEW_BEHAVIOR } from "../lib/graph-schema";
|
|
23
|
+
|
|
24
|
+
import { nodeTypes } from "./graph/arch-node";
|
|
25
|
+
import { buildGraphFromDataset } from "./graph/build-graph-from-dataset";
|
|
26
|
+
import { GraphContextMenu } from "./graph/graph-context-menu";
|
|
27
|
+
import {
|
|
28
|
+
ArchNodeData,
|
|
29
|
+
GraphEdgeAuthority,
|
|
30
|
+
GraphCanvasViewMode,
|
|
31
|
+
GraphEdgeFilter,
|
|
32
|
+
buildNodeMarkdown,
|
|
33
|
+
ContextMenuState,
|
|
34
|
+
GraphFilter,
|
|
35
|
+
GraphKind,
|
|
36
|
+
GraphTone,
|
|
37
|
+
InspectorNode,
|
|
38
|
+
parseNodeId,
|
|
39
|
+
toneColor,
|
|
40
|
+
} from "./graph/graph-types";
|
|
41
|
+
import { useAutoLayout } from "./graph/use-auto-layout";
|
|
42
|
+
import { isValidArchitectureConnection } from "./graph/use-connection-validation";
|
|
43
|
+
import { useFlowPersistence } from "./graph/use-flow-persistence";
|
|
44
|
+
|
|
45
|
+
import "reactflow/dist/style.css";
|
|
46
|
+
|
|
47
|
+
type GraphCanvasProps = {
|
|
48
|
+
data: GraphDatasetResponse["dataset"];
|
|
49
|
+
viewMode: GraphCanvasViewMode;
|
|
50
|
+
enabledFilters: GraphFilter[];
|
|
51
|
+
enabledEdgeFilters: GraphEdgeFilter[];
|
|
52
|
+
enabledAuthorityFilters: GraphEdgeAuthority[];
|
|
53
|
+
hopDepth: number;
|
|
54
|
+
onHopDepthChange: (next: number) => void;
|
|
55
|
+
showExternalDependencies: boolean;
|
|
56
|
+
hideCompletedTasks: boolean;
|
|
57
|
+
onNodeSelect: (node: InspectorNode) => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export { type GraphFilter, type InspectorNode } from "./graph/graph-types";
|
|
61
|
+
|
|
62
|
+
export function GraphCanvas({
|
|
63
|
+
data,
|
|
64
|
+
viewMode,
|
|
65
|
+
enabledFilters,
|
|
66
|
+
enabledEdgeFilters,
|
|
67
|
+
enabledAuthorityFilters,
|
|
68
|
+
hopDepth,
|
|
69
|
+
onHopDepthChange,
|
|
70
|
+
showExternalDependencies,
|
|
71
|
+
hideCompletedTasks,
|
|
72
|
+
onNodeSelect,
|
|
73
|
+
}: GraphCanvasProps) {
|
|
74
|
+
const SELECTED_NODE_KEY = "arch:graph:selected-node:v1";
|
|
75
|
+
const hasAppliedInitialView = useRef(false);
|
|
76
|
+
const rememberedPositions = useRef<Map<string, { x: number; y: number }>>(new Map());
|
|
77
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
78
|
+
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
|
|
79
|
+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
80
|
+
const [pinnedNodeIds, setPinnedNodeIds] = useState<Set<string>>(new Set());
|
|
81
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
82
|
+
const [viewportZoom, setViewportZoom] = useState(1);
|
|
83
|
+
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<ArchNodeData, Edge> | null>(
|
|
84
|
+
null,
|
|
85
|
+
);
|
|
86
|
+
const storageKey = `arch:graph:view:${viewMode}:v1`;
|
|
87
|
+
|
|
88
|
+
const initialGraph = useMemo(
|
|
89
|
+
() => buildGraphFromDataset(data, viewMode),
|
|
90
|
+
[data, viewMode],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const [nodes, setNodes, onNodesChange] = useNodesState<ArchNodeData>(initialGraph.nodes);
|
|
94
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState(initialGraph.edges);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
setNodes((currentNodes) => {
|
|
98
|
+
const currentPositions = new Map(currentNodes.map((node) => [node.id, node.position] as const));
|
|
99
|
+
return initialGraph.nodes.map((node) => {
|
|
100
|
+
const remembered = rememberedPositions.current.get(node.id);
|
|
101
|
+
const current = currentPositions.get(node.id);
|
|
102
|
+
if (!remembered && !current) return node;
|
|
103
|
+
return { ...node, position: remembered ?? current ?? node.position };
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
setEdges(initialGraph.edges);
|
|
107
|
+
setHiddenNodeIds((prev) => {
|
|
108
|
+
const validIds = new Set(initialGraph.nodes.map((node) => node.id));
|
|
109
|
+
return new Set([...prev].filter((id) => validIds.has(id)));
|
|
110
|
+
});
|
|
111
|
+
setSelectedNodeId((current) => {
|
|
112
|
+
if (!current) return null;
|
|
113
|
+
return initialGraph.nodes.some((node) => node.id === current) ? current : null;
|
|
114
|
+
});
|
|
115
|
+
setPinnedNodeIds((current) => {
|
|
116
|
+
const validIds = new Set(initialGraph.nodes.map((node) => node.id));
|
|
117
|
+
return new Set([...current].filter((id) => validIds.has(id)));
|
|
118
|
+
});
|
|
119
|
+
}, [initialGraph, setEdges, setNodes]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
rememberedPositions.current = new Map(
|
|
123
|
+
nodes.map((node) => [node.id, { x: node.position.x, y: node.position.y }] as const),
|
|
124
|
+
);
|
|
125
|
+
}, [nodes]);
|
|
126
|
+
|
|
127
|
+
const behavior = GRAPH_VIEW_BEHAVIOR[viewMode];
|
|
128
|
+
const viewCanonicalTypes: Record<GraphCanvasViewMode, string[]> = {
|
|
129
|
+
"architecture-map": ["arch_folder", "domain_doc", "architecture_doc", "architecture_model"],
|
|
130
|
+
tasks: ["roadmap_folder", "roadmap_epic", "roadmap_story", "roadmap_task"],
|
|
131
|
+
project: ["project_folder", "app", "package", "module", "component"],
|
|
132
|
+
};
|
|
133
|
+
const effectiveHopDepth = useMemo(() => {
|
|
134
|
+
const min = Math.min(...behavior.allowedHopDepths);
|
|
135
|
+
const max = Math.max(...behavior.allowedHopDepths);
|
|
136
|
+
const next = Math.max(min, Math.min(max, hopDepth));
|
|
137
|
+
return behavior.allowedHopDepths.includes(next) ? next : behavior.defaultHopDepth;
|
|
138
|
+
}, [behavior, hopDepth]);
|
|
139
|
+
|
|
140
|
+
const neighborhoodIds = useMemo(() => {
|
|
141
|
+
if (!selectedNodeId || effectiveHopDepth <= 0) {
|
|
142
|
+
return new Set<string>(selectedNodeId ? [selectedNodeId] : []);
|
|
143
|
+
}
|
|
144
|
+
const ids = new Set<string>([selectedNodeId]);
|
|
145
|
+
let frontier = new Set<string>([selectedNodeId]);
|
|
146
|
+
for (let depth = 0; depth < effectiveHopDepth; depth++) {
|
|
147
|
+
const nextFrontier = new Set<string>();
|
|
148
|
+
edges.forEach((edge) => {
|
|
149
|
+
if (hiddenNodeIds.has(edge.source) || hiddenNodeIds.has(edge.target)) return;
|
|
150
|
+
if (frontier.has(edge.source) && !ids.has(edge.target)) {
|
|
151
|
+
ids.add(edge.target);
|
|
152
|
+
nextFrontier.add(edge.target);
|
|
153
|
+
}
|
|
154
|
+
if (frontier.has(edge.target) && !ids.has(edge.source)) {
|
|
155
|
+
ids.add(edge.source);
|
|
156
|
+
nextFrontier.add(edge.source);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
frontier = nextFrontier;
|
|
160
|
+
if (frontier.size === 0) break;
|
|
161
|
+
}
|
|
162
|
+
return ids;
|
|
163
|
+
}, [edges, effectiveHopDepth, hiddenNodeIds, selectedNodeId]);
|
|
164
|
+
|
|
165
|
+
const visibleNodes = useMemo(() => {
|
|
166
|
+
const viewKinds: Record<GraphCanvasViewMode, GraphKind[]> = {
|
|
167
|
+
"architecture-map": ["domain", "decision"],
|
|
168
|
+
tasks: ["phase", "milestone", "task"],
|
|
169
|
+
project: ["file"],
|
|
170
|
+
};
|
|
171
|
+
const baseNodeIds = new Set(
|
|
172
|
+
nodes
|
|
173
|
+
.filter((node) => {
|
|
174
|
+
if (hiddenNodeIds.has(node.id)) return false;
|
|
175
|
+
if (!viewKinds[viewMode].includes(node.data.kind)) return false;
|
|
176
|
+
const typeLabel = (node.data.canonicalType ?? "").toLowerCase();
|
|
177
|
+
if (
|
|
178
|
+
typeLabel.includes("arch_folder") &&
|
|
179
|
+
!enabledFilters.includes("domains")
|
|
180
|
+
) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
(typeLabel.includes("domain") || typeLabel.includes("architecture_doc")) &&
|
|
185
|
+
!enabledFilters.includes("domains")
|
|
186
|
+
) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
if (typeLabel.includes("architecture_model") && !enabledFilters.includes("decisions")) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (typeLabel.includes("roadmap_") && !enabledFilters.includes("tasks")) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
typeLabel.includes("project_folder") &&
|
|
197
|
+
!enabledFilters.includes("modules")
|
|
198
|
+
) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
(typeLabel.includes("app") ||
|
|
203
|
+
typeLabel.includes("package") ||
|
|
204
|
+
typeLabel.includes("module") ||
|
|
205
|
+
typeLabel.includes("component")) &&
|
|
206
|
+
!enabledFilters.includes("modules")
|
|
207
|
+
) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
if (hideCompletedTasks && typeLabel === "roadmap_task") {
|
|
211
|
+
const lane = node.data.metadata.find((entry) => entry.label === "lane")?.value;
|
|
212
|
+
if (lane === "complete") return false;
|
|
213
|
+
}
|
|
214
|
+
if (viewMode === "project" && !showExternalDependencies) {
|
|
215
|
+
if (typeLabel.includes("project_folder")) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
const source =
|
|
219
|
+
node.data.metadata.find((entry) => entry.label === "Source")?.value ??
|
|
220
|
+
node.data.label;
|
|
221
|
+
if (!source.startsWith("apps/") && !source.startsWith("packages/")) return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
})
|
|
225
|
+
.map((node) => node.id),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const lowered = searchQuery.trim().toLowerCase();
|
|
229
|
+
const matchesSearch = (node: Node<ArchNodeData>) => {
|
|
230
|
+
if (!lowered) return true;
|
|
231
|
+
const haystack = [
|
|
232
|
+
node.id,
|
|
233
|
+
node.data.label,
|
|
234
|
+
node.data.subtitle ?? "",
|
|
235
|
+
...node.data.metadata.map((entry) => entry.value),
|
|
236
|
+
]
|
|
237
|
+
.join(" ")
|
|
238
|
+
.toLowerCase();
|
|
239
|
+
return haystack.includes(lowered);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Global search mode: search across all nodes in the active view scope, not only focus context.
|
|
243
|
+
if (lowered) {
|
|
244
|
+
return nodes.filter((node) => baseNodeIds.has(node.id) && matchesSearch(node));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (viewMode === "tasks" && !selectedNodeId) {
|
|
248
|
+
const phaseIds = new Set(
|
|
249
|
+
nodes
|
|
250
|
+
.filter(
|
|
251
|
+
(node) =>
|
|
252
|
+
baseNodeIds.has(node.id) &&
|
|
253
|
+
(node.data.canonicalType ?? "").toLowerCase() === "roadmap_folder" &&
|
|
254
|
+
node.data.metadata.some(
|
|
255
|
+
(entry) => entry.label.toLowerCase() === "level" && entry.value === "root",
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
.map((node) => node.id),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (phaseIds.size > 0) {
|
|
262
|
+
const topGraphIds = new Set(phaseIds);
|
|
263
|
+
// Fixed +1 hop boot state from all phases when nothing is selected.
|
|
264
|
+
edges.forEach((edge) => {
|
|
265
|
+
if (phaseIds.has(edge.source) && baseNodeIds.has(edge.target)) {
|
|
266
|
+
topGraphIds.add(edge.target);
|
|
267
|
+
}
|
|
268
|
+
if (phaseIds.has(edge.target) && baseNodeIds.has(edge.source)) {
|
|
269
|
+
topGraphIds.add(edge.source);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
return nodes.filter((node) => topGraphIds.has(node.id));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (viewMode === "architecture-map" && !selectedNodeId) {
|
|
277
|
+
const domainRootIds = new Set(
|
|
278
|
+
nodes
|
|
279
|
+
.filter(
|
|
280
|
+
(node) =>
|
|
281
|
+
baseNodeIds.has(node.id) &&
|
|
282
|
+
(node.data.canonicalType ?? "").toLowerCase() === "arch_folder" &&
|
|
283
|
+
node.data.metadata.some(
|
|
284
|
+
(entry) => entry.label.toLowerCase() === "level" && entry.value === "root",
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
.map((node) => node.id),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (domainRootIds.size > 0) {
|
|
291
|
+
const topGraphIds = new Set(domainRootIds);
|
|
292
|
+
// Match tasks boot behavior: roots +1 hop when nothing is selected.
|
|
293
|
+
edges.forEach((edge) => {
|
|
294
|
+
if (domainRootIds.has(edge.source) && baseNodeIds.has(edge.target)) {
|
|
295
|
+
topGraphIds.add(edge.target);
|
|
296
|
+
}
|
|
297
|
+
if (domainRootIds.has(edge.target) && baseNodeIds.has(edge.source)) {
|
|
298
|
+
topGraphIds.add(edge.source);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
return nodes.filter((node) => topGraphIds.has(node.id));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (viewMode === "project" && !selectedNodeId) {
|
|
306
|
+
const projectRootIds = new Set(
|
|
307
|
+
nodes
|
|
308
|
+
.filter((node) => {
|
|
309
|
+
if (!baseNodeIds.has(node.id)) return false;
|
|
310
|
+
const canonical = (node.data.canonicalType ?? "").toLowerCase();
|
|
311
|
+
if (canonical !== "project_folder") return false;
|
|
312
|
+
return node.data.metadata.some(
|
|
313
|
+
(entry) => entry.label.toLowerCase() === "level" && entry.value === "root",
|
|
314
|
+
);
|
|
315
|
+
})
|
|
316
|
+
.map((node) => node.id),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
if (projectRootIds.size > 0) {
|
|
320
|
+
const topGraphIds = new Set(projectRootIds);
|
|
321
|
+
// Match tasks/architecture boot behavior: roots +1 hop when nothing is selected.
|
|
322
|
+
edges.forEach((edge) => {
|
|
323
|
+
if (projectRootIds.has(edge.source) && baseNodeIds.has(edge.target)) {
|
|
324
|
+
topGraphIds.add(edge.target);
|
|
325
|
+
}
|
|
326
|
+
if (projectRootIds.has(edge.target) && baseNodeIds.has(edge.source)) {
|
|
327
|
+
topGraphIds.add(edge.source);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
return nodes.filter((node) => topGraphIds.has(node.id));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!selectedNodeId) {
|
|
335
|
+
return nodes.filter((node) => baseNodeIds.has(node.id));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const selectedNode = nodes.find((node) => node.id === selectedNodeId);
|
|
339
|
+
if (!selectedNode || hiddenNodeIds.has(selectedNode.id)) {
|
|
340
|
+
return nodes.filter((node) => baseNodeIds.has(node.id));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return nodes.filter((node) => {
|
|
344
|
+
const inScope = neighborhoodIds.has(node.id) || pinnedNodeIds.has(node.id);
|
|
345
|
+
if (!inScope) return false;
|
|
346
|
+
return matchesSearch(node);
|
|
347
|
+
});
|
|
348
|
+
}, [edges, enabledFilters, hideCompletedTasks, hiddenNodeIds, neighborhoodIds, nodes, pinnedNodeIds, searchQuery, selectedNodeId, showExternalDependencies, viewMode]);
|
|
349
|
+
const visibleEdges = useMemo(
|
|
350
|
+
() => {
|
|
351
|
+
const allowedEdgeTypes = new Set(enabledEdgeFilters);
|
|
352
|
+
const allowedAuthorities = new Set(enabledAuthorityFilters);
|
|
353
|
+
const baseEdges = edges.filter(
|
|
354
|
+
(edge) =>
|
|
355
|
+
!hiddenNodeIds.has(edge.source) &&
|
|
356
|
+
!hiddenNodeIds.has(edge.target) &&
|
|
357
|
+
allowedEdgeTypes.has((edge.data?.edgeType as GraphEdgeFilter | undefined) ?? "dependency") &&
|
|
358
|
+
allowedAuthorities.has(
|
|
359
|
+
(edge.data?.authority as GraphEdgeAuthority | undefined) ?? "authoritative",
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
const precedence = new Map<string, number>([
|
|
363
|
+
["authoritative", 3],
|
|
364
|
+
["manual", 2],
|
|
365
|
+
["inferred", 1],
|
|
366
|
+
]);
|
|
367
|
+
const dedupedByRelation = new Map<string, Edge>();
|
|
368
|
+
baseEdges.forEach((edge) => {
|
|
369
|
+
const key = `${edge.source}|${edge.target}|${edge.data?.edgeType ?? "dependency"}`;
|
|
370
|
+
const incomingScore = precedence.get(edge.data?.authority ?? "authoritative") ?? 0;
|
|
371
|
+
const existing = dedupedByRelation.get(key);
|
|
372
|
+
if (!existing) {
|
|
373
|
+
dedupedByRelation.set(key, edge);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const existingScore = precedence.get(existing.data?.authority ?? "authoritative") ?? 0;
|
|
377
|
+
if (incomingScore > existingScore) {
|
|
378
|
+
dedupedByRelation.set(key, edge);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
const resolvedEdges = [...dedupedByRelation.values()];
|
|
382
|
+
if (!selectedNodeId) {
|
|
383
|
+
if (viewMode === "project" && searchQuery.trim().length === 0) {
|
|
384
|
+
const visibleNodeIdSet = new Set(visibleNodes.map((node) => node.id));
|
|
385
|
+
return resolvedEdges.filter(
|
|
386
|
+
(edge) => visibleNodeIdSet.has(edge.source) && visibleNodeIdSet.has(edge.target),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (viewMode === "architecture-map" && searchQuery.trim().length === 0) {
|
|
390
|
+
const visibleNodeIdSet = new Set(visibleNodes.map((node) => node.id));
|
|
391
|
+
return resolvedEdges.filter(
|
|
392
|
+
(edge) => visibleNodeIdSet.has(edge.source) && visibleNodeIdSet.has(edge.target),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (viewMode === "tasks" && searchQuery.trim().length === 0) {
|
|
396
|
+
const visibleNodeIdSet = new Set(visibleNodes.map((node) => node.id));
|
|
397
|
+
return resolvedEdges.filter(
|
|
398
|
+
(edge) => visibleNodeIdSet.has(edge.source) && visibleNodeIdSet.has(edge.target),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (pinnedNodeIds.size === 0) return [];
|
|
402
|
+
return resolvedEdges.filter(
|
|
403
|
+
(edge) => pinnedNodeIds.has(edge.source) && pinnedNodeIds.has(edge.target),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const visibleNodeIdSet = new Set(visibleNodes.map((node) => node.id));
|
|
407
|
+
return resolvedEdges.filter(
|
|
408
|
+
(edge) => visibleNodeIdSet.has(edge.source) && visibleNodeIdSet.has(edge.target),
|
|
409
|
+
);
|
|
410
|
+
},
|
|
411
|
+
[edges, enabledAuthorityFilters, enabledEdgeFilters, hiddenNodeIds, pinnedNodeIds, searchQuery, selectedNodeId, viewMode, visibleNodes],
|
|
412
|
+
);
|
|
413
|
+
const renderedEdges = useMemo(
|
|
414
|
+
() =>
|
|
415
|
+
visibleEdges.map((edge) => ({
|
|
416
|
+
...edge,
|
|
417
|
+
animated:
|
|
418
|
+
edge.source === selectedNodeId ||
|
|
419
|
+
edge.target === selectedNodeId ||
|
|
420
|
+
(pinnedNodeIds.has(edge.source) && pinnedNodeIds.has(edge.target)),
|
|
421
|
+
})),
|
|
422
|
+
[pinnedNodeIds, selectedNodeId, visibleEdges],
|
|
423
|
+
);
|
|
424
|
+
const renderedNodes = useMemo(() => {
|
|
425
|
+
if (visibleNodes.length === 0) return visibleNodes;
|
|
426
|
+
if (viewportZoom <= 1) return visibleNodes;
|
|
427
|
+
|
|
428
|
+
const minX = Math.min(...visibleNodes.map((node) => node.position.x));
|
|
429
|
+
const spreadFactor = Math.min(1.8, 1 + (viewportZoom - 1) * 0.45);
|
|
430
|
+
|
|
431
|
+
return visibleNodes.map((node) => ({
|
|
432
|
+
...node,
|
|
433
|
+
position: {
|
|
434
|
+
...node.position,
|
|
435
|
+
x: minX + (node.position.x - minX) * spreadFactor,
|
|
436
|
+
},
|
|
437
|
+
}));
|
|
438
|
+
}, [viewportZoom, visibleNodes]);
|
|
439
|
+
|
|
440
|
+
const onConnect: OnConnect = useCallback(
|
|
441
|
+
(connection) => {
|
|
442
|
+
setEdges((existingEdges) => {
|
|
443
|
+
const candidateNodes = nodes.filter((node) => !hiddenNodeIds.has(node.id));
|
|
444
|
+
const candidateEdges = existingEdges.filter(
|
|
445
|
+
(edge) => !hiddenNodeIds.has(edge.source) && !hiddenNodeIds.has(edge.target),
|
|
446
|
+
);
|
|
447
|
+
if (!isValidArchitectureConnection(connection, candidateNodes, candidateEdges)) {
|
|
448
|
+
return existingEdges;
|
|
449
|
+
}
|
|
450
|
+
return addEdge(
|
|
451
|
+
{
|
|
452
|
+
...connection,
|
|
453
|
+
type: "smoothstep",
|
|
454
|
+
animated: true,
|
|
455
|
+
data: { edgeType: "dependency", authority: "manual" as GraphEdgeAuthority },
|
|
456
|
+
},
|
|
457
|
+
existingEdges,
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
},
|
|
461
|
+
[hiddenNodeIds, nodes, setEdges],
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const { saveFlow, restoreFlow } = useFlowPersistence(
|
|
465
|
+
setNodes,
|
|
466
|
+
setEdges,
|
|
467
|
+
setHiddenNodeIds,
|
|
468
|
+
flowInstance,
|
|
469
|
+
storageKey,
|
|
470
|
+
);
|
|
471
|
+
const autoLayout = useAutoLayout(setNodes, edges, hiddenNodeIds, flowInstance);
|
|
472
|
+
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
if (hasAppliedInitialView.current) return;
|
|
475
|
+
if (!flowInstance || typeof window === "undefined") return;
|
|
476
|
+
|
|
477
|
+
hasAppliedInitialView.current = true;
|
|
478
|
+
const hasSavedView = !!window.localStorage.getItem(storageKey);
|
|
479
|
+
|
|
480
|
+
if (hasSavedView) {
|
|
481
|
+
restoreFlow();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
window.requestAnimationFrame(() => {
|
|
486
|
+
autoLayout();
|
|
487
|
+
});
|
|
488
|
+
}, [autoLayout, flowInstance, restoreFlow, storageKey]);
|
|
489
|
+
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
if (typeof window === "undefined") return;
|
|
492
|
+
const stored = window.localStorage.getItem(SELECTED_NODE_KEY);
|
|
493
|
+
if (!stored) return;
|
|
494
|
+
if (selectedNodeId) return;
|
|
495
|
+
const selected = nodes.find((node) => node.id === stored);
|
|
496
|
+
if (selected && viewCanonicalTypes[viewMode].includes(selected.data.canonicalType ?? "")) {
|
|
497
|
+
setSelectedNodeId(stored);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const mapped = edges.find((edge) => edge.source === stored || edge.target === stored);
|
|
501
|
+
if (!mapped) return;
|
|
502
|
+
const candidateId = mapped.source === stored ? mapped.target : mapped.source;
|
|
503
|
+
const candidateNode = nodes.find((node) => node.id === candidateId);
|
|
504
|
+
if (!candidateNode) return;
|
|
505
|
+
if (!viewCanonicalTypes[viewMode].includes(candidateNode.data.canonicalType ?? "")) return;
|
|
506
|
+
setSelectedNodeId(candidateId);
|
|
507
|
+
}, [edges, nodes, selectedNodeId, viewMode]);
|
|
508
|
+
|
|
509
|
+
const resetGraph = useCallback(() => {
|
|
510
|
+
setNodes(initialGraph.nodes);
|
|
511
|
+
setEdges(initialGraph.edges);
|
|
512
|
+
setHiddenNodeIds(new Set());
|
|
513
|
+
setSelectedNodeId(null);
|
|
514
|
+
setPinnedNodeIds(new Set());
|
|
515
|
+
setSearchQuery("");
|
|
516
|
+
if (typeof window !== "undefined") {
|
|
517
|
+
window.localStorage.removeItem(SELECTED_NODE_KEY);
|
|
518
|
+
}
|
|
519
|
+
flowInstance?.fitView();
|
|
520
|
+
}, [flowInstance, initialGraph, setEdges, setNodes]);
|
|
521
|
+
|
|
522
|
+
const contextNode = useMemo(() => {
|
|
523
|
+
if (!contextMenu) return null;
|
|
524
|
+
return nodes.find((node) => node.id === contextMenu.nodeId) ?? null;
|
|
525
|
+
}, [contextMenu, nodes]);
|
|
526
|
+
|
|
527
|
+
function selectNodeForInspector(node: Node<ArchNodeData>) {
|
|
528
|
+
const parsed = parseNodeId(node.id);
|
|
529
|
+
const hasVisibleLinks = visibleEdges.some((edge) => edge.source === node.id || edge.target === node.id);
|
|
530
|
+
const metadata = [...node.data.metadata];
|
|
531
|
+
if (viewMode === "architecture-map" && !hasVisibleLinks) {
|
|
532
|
+
metadata.push({
|
|
533
|
+
label: "Link Status",
|
|
534
|
+
value: "No linked tasks or project components yet.",
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
onNodeSelect({
|
|
538
|
+
type: parsed.kind,
|
|
539
|
+
id: parsed.id,
|
|
540
|
+
title: node.data.label,
|
|
541
|
+
metadata,
|
|
542
|
+
markdown: buildNodeMarkdown(node),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<Card className="min-h-[520px]">
|
|
548
|
+
<CardHeader>
|
|
549
|
+
<CardTitle>
|
|
550
|
+
{viewMode === "architecture-map"
|
|
551
|
+
? "Architecture Map"
|
|
552
|
+
: viewMode === "tasks"
|
|
553
|
+
? "Tasks Graph"
|
|
554
|
+
: "Project Graph"}
|
|
555
|
+
</CardTitle>
|
|
556
|
+
<div className="flex flex-wrap gap-2">
|
|
557
|
+
<Input
|
|
558
|
+
className="w-[220px]"
|
|
559
|
+
placeholder="Search id/title/path/tags..."
|
|
560
|
+
value={searchQuery}
|
|
561
|
+
onChange={(event) => setSearchQuery(event.target.value)}
|
|
562
|
+
/>
|
|
563
|
+
<Button
|
|
564
|
+
variant="outline"
|
|
565
|
+
disabled={effectiveHopDepth <= Math.min(...behavior.allowedHopDepths)}
|
|
566
|
+
onClick={() => onHopDepthChange(effectiveHopDepth - 1)}
|
|
567
|
+
>
|
|
568
|
+
Hop -
|
|
569
|
+
</Button>
|
|
570
|
+
<Button
|
|
571
|
+
variant="outline"
|
|
572
|
+
disabled={effectiveHopDepth >= Math.max(...behavior.allowedHopDepths)}
|
|
573
|
+
onClick={() => onHopDepthChange(effectiveHopDepth + 1)}
|
|
574
|
+
>
|
|
575
|
+
Hop +
|
|
576
|
+
</Button>
|
|
577
|
+
<span className="grid place-items-center rounded border border-slate-700 px-3 text-xs text-slate-300">
|
|
578
|
+
Hop: {effectiveHopDepth}
|
|
579
|
+
</span>
|
|
580
|
+
<Button
|
|
581
|
+
variant="outline"
|
|
582
|
+
disabled={!selectedNodeId}
|
|
583
|
+
onClick={() => setPinnedNodeIds(new Set(neighborhoodIds))}
|
|
584
|
+
>
|
|
585
|
+
Pin Focus
|
|
586
|
+
</Button>
|
|
587
|
+
<Button variant="outline" onClick={() => setPinnedNodeIds(new Set())}>
|
|
588
|
+
Clear Pins
|
|
589
|
+
</Button>
|
|
590
|
+
<Button variant="outline" onClick={autoLayout}>
|
|
591
|
+
Auto Layout
|
|
592
|
+
</Button>
|
|
593
|
+
<Button variant="outline" onClick={saveFlow}>
|
|
594
|
+
Save View
|
|
595
|
+
</Button>
|
|
596
|
+
<Button variant="outline" onClick={restoreFlow}>
|
|
597
|
+
Restore View
|
|
598
|
+
</Button>
|
|
599
|
+
<Button variant="ghost" onClick={resetGraph}>
|
|
600
|
+
Reset
|
|
601
|
+
</Button>
|
|
602
|
+
</div>
|
|
603
|
+
</CardHeader>
|
|
604
|
+
<CardContent className="relative h-[540px]">
|
|
605
|
+
<ReactFlow
|
|
606
|
+
fitView
|
|
607
|
+
nodes={renderedNodes}
|
|
608
|
+
edges={renderedEdges}
|
|
609
|
+
nodeTypes={nodeTypes}
|
|
610
|
+
onInit={setFlowInstance}
|
|
611
|
+
onNodesChange={onNodesChange}
|
|
612
|
+
onEdgesChange={onEdgesChange}
|
|
613
|
+
onConnect={onConnect}
|
|
614
|
+
onPaneClick={() => {
|
|
615
|
+
setContextMenu(null);
|
|
616
|
+
setSelectedNodeId(null);
|
|
617
|
+
if (typeof window !== "undefined") {
|
|
618
|
+
window.localStorage.removeItem(SELECTED_NODE_KEY);
|
|
619
|
+
}
|
|
620
|
+
}}
|
|
621
|
+
isValidConnection={(connection) =>
|
|
622
|
+
isValidArchitectureConnection(connection, visibleNodes, visibleEdges)
|
|
623
|
+
}
|
|
624
|
+
onNodeContextMenu={(event, node) => {
|
|
625
|
+
event.preventDefault();
|
|
626
|
+
setContextMenu({ nodeId: node.id, x: event.clientX, y: event.clientY });
|
|
627
|
+
}}
|
|
628
|
+
onMove={(_, viewport) => {
|
|
629
|
+
setViewportZoom(viewport.zoom);
|
|
630
|
+
}}
|
|
631
|
+
onNodeClick={(_, node) => {
|
|
632
|
+
setSelectedNodeId(node.id);
|
|
633
|
+
if (typeof window !== "undefined") {
|
|
634
|
+
window.localStorage.setItem(SELECTED_NODE_KEY, node.id);
|
|
635
|
+
}
|
|
636
|
+
selectNodeForInspector(node as Node<ArchNodeData>);
|
|
637
|
+
setContextMenu(null);
|
|
638
|
+
}}
|
|
639
|
+
>
|
|
640
|
+
<Background />
|
|
641
|
+
<MiniMap
|
|
642
|
+
pannable
|
|
643
|
+
zoomable
|
|
644
|
+
nodeColor={(node) =>
|
|
645
|
+
toneColor[((node.data as ArchNodeData).tone ?? "file") as GraphTone]
|
|
646
|
+
}
|
|
647
|
+
nodeStrokeWidth={2}
|
|
648
|
+
/>
|
|
649
|
+
<Controls showInteractive>
|
|
650
|
+
<ControlButton onClick={() => setHiddenNodeIds(new Set())} title="Show hidden nodes">
|
|
651
|
+
Show All
|
|
652
|
+
</ControlButton>
|
|
653
|
+
</Controls>
|
|
654
|
+
</ReactFlow>
|
|
655
|
+
|
|
656
|
+
{contextMenu && contextNode ? (
|
|
657
|
+
<GraphContextMenu
|
|
658
|
+
contextMenu={contextMenu}
|
|
659
|
+
node={contextNode}
|
|
660
|
+
flowInstance={flowInstance}
|
|
661
|
+
onInspect={onNodeSelect}
|
|
662
|
+
onHideNode={(id) => setHiddenNodeIds((prev) => new Set(prev).add(id))}
|
|
663
|
+
onShowAll={() => setHiddenNodeIds(new Set())}
|
|
664
|
+
onClose={() => setContextMenu(null)}
|
|
665
|
+
/>
|
|
666
|
+
) : null}
|
|
667
|
+
</CardContent>
|
|
668
|
+
</Card>
|
|
669
|
+
);
|
|
670
|
+
}
|