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,187 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "@repo/ui/badge";
|
|
4
|
+
import { Button } from "@repo/ui/button";
|
|
5
|
+
import { Command, CommandInput, CommandItem, CommandList } from "@repo/ui/command";
|
|
6
|
+
import { Input } from "@repo/ui/input";
|
|
7
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
8
|
+
import { useEffect, useMemo, useState } from "react";
|
|
9
|
+
import { getHealth, searchWorkspace } from "../lib/api";
|
|
10
|
+
import { SearchResultItem } from "../lib/types";
|
|
11
|
+
import { useInspector } from "./inspector-context";
|
|
12
|
+
import { useWorkspace } from "./workspace-context";
|
|
13
|
+
|
|
14
|
+
type HealthState = "OK" | "Warning" | "Drift";
|
|
15
|
+
|
|
16
|
+
const staticCommands = [
|
|
17
|
+
{ id: "architecture", label: "Open Architecture Map", route: "/work?view=architecture" },
|
|
18
|
+
{ id: "work", label: "Open Tasks View", route: "/work?view=tasks" },
|
|
19
|
+
{ id: "project", label: "Open Project View", route: "/work?view=project" },
|
|
20
|
+
{ id: "health", label: "Open Health", route: "/health" },
|
|
21
|
+
{ id: "search-task", label: "Open Tasks", route: "/work?view=tasks" },
|
|
22
|
+
{ id: "trace-file", label: "Trace File", route: "/health?view=trace" },
|
|
23
|
+
{ id: "open-domain", label: "Open Domain View", route: "/work?view=architecture" },
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export function Topbar({ projectName }: { projectName: string }) {
|
|
27
|
+
const [query, setQuery] = useState("");
|
|
28
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
29
|
+
const [health, setHealth] = useState<HealthState>("OK");
|
|
30
|
+
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
|
31
|
+
const router = useRouter();
|
|
32
|
+
const pathname = usePathname();
|
|
33
|
+
const searchParams = useSearchParams();
|
|
34
|
+
const { selection } = useInspector();
|
|
35
|
+
const { splitPane, setSplitPane, rightCollapsed, setRightCollapsed, resetLayout } = useWorkspace();
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
39
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
setPaletteOpen((prev) => !prev);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
window.addEventListener("keydown", onKeyDown);
|
|
46
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const timeout = window.setTimeout(() => {
|
|
51
|
+
void searchWorkspace(query)
|
|
52
|
+
.then((result) => setSearchResults(result.results))
|
|
53
|
+
.catch(() => setSearchResults([]));
|
|
54
|
+
}, 140);
|
|
55
|
+
return () => window.clearTimeout(timeout);
|
|
56
|
+
}, [query]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
void getHealth()
|
|
60
|
+
.then((result) => {
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
setHealth("Drift");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (result.warnings.length > 0) {
|
|
66
|
+
setHealth("Warning");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
setHealth("OK");
|
|
70
|
+
})
|
|
71
|
+
.catch(() => setHealth("Drift"));
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const filteredCommands = useMemo(() => {
|
|
75
|
+
if (!query) return staticCommands;
|
|
76
|
+
const q = query.toLowerCase();
|
|
77
|
+
return staticCommands.filter((entry) => entry.label.toLowerCase().includes(q));
|
|
78
|
+
}, [query]);
|
|
79
|
+
|
|
80
|
+
const breadcrumbs = useMemo(() => {
|
|
81
|
+
const pathLabel =
|
|
82
|
+
pathname === "/work"
|
|
83
|
+
? "Work Graph"
|
|
84
|
+
: pathname === "/health"
|
|
85
|
+
? "Health"
|
|
86
|
+
: "Workspace";
|
|
87
|
+
const view = searchParams.get("view");
|
|
88
|
+
const secondary =
|
|
89
|
+
view === "docs"
|
|
90
|
+
? "Repository Docs"
|
|
91
|
+
: view === "decisions"
|
|
92
|
+
? "Decisions"
|
|
93
|
+
: view === "architecture"
|
|
94
|
+
? "Architecture"
|
|
95
|
+
: view === "project"
|
|
96
|
+
? "Project"
|
|
97
|
+
: view === "tasks"
|
|
98
|
+
? "Tasks"
|
|
99
|
+
: "Map";
|
|
100
|
+
|
|
101
|
+
const parts = [pathLabel];
|
|
102
|
+
if (pathname === "/work") parts.push(secondary);
|
|
103
|
+
if (selection?.title) parts.push(selection.title);
|
|
104
|
+
return parts.join(" > ");
|
|
105
|
+
}, [pathname, searchParams, selection?.title]);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<header className="grid items-center gap-3 border-b border-slate-800 px-4 py-2 xl:grid-cols-[auto_1fr_auto]">
|
|
109
|
+
<div>
|
|
110
|
+
<p className="m-0 text-[11px] uppercase tracking-[0.1em] text-slate-400">Project</p>
|
|
111
|
+
<p className="m-0 text-base font-semibold">{projectName}</p>
|
|
112
|
+
<p className="mt-0.5 text-xs leading-tight text-slate-400">{breadcrumbs}</p>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="flex items-center gap-2">
|
|
116
|
+
<Input
|
|
117
|
+
placeholder="Search task, file, decision..."
|
|
118
|
+
value={query}
|
|
119
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
120
|
+
onFocus={() => setPaletteOpen(true)}
|
|
121
|
+
/>
|
|
122
|
+
<Button type="button" variant="outline" onClick={() => setPaletteOpen((prev) => !prev)}>
|
|
123
|
+
⌘K
|
|
124
|
+
</Button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="flex flex-wrap gap-2">
|
|
128
|
+
<Button type="button" variant="outline" onClick={() => setSplitPane(!splitPane)}>
|
|
129
|
+
{splitPane ? "Single Pane" : "Split Pane"}
|
|
130
|
+
</Button>
|
|
131
|
+
<Button type="button" variant="outline" onClick={() => setRightCollapsed(!rightCollapsed)}>
|
|
132
|
+
{rightCollapsed ? "Show Inspector" : "Hide Inspector"}
|
|
133
|
+
</Button>
|
|
134
|
+
<Button type="button" variant="ghost" onClick={resetLayout}>
|
|
135
|
+
Reset Layout
|
|
136
|
+
</Button>
|
|
137
|
+
<Badge variant={health === "OK" ? "success" : health === "Warning" ? "warning" : "danger"}>
|
|
138
|
+
Health: {health}
|
|
139
|
+
</Badge>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
{paletteOpen ? (
|
|
143
|
+
<div
|
|
144
|
+
className="fixed inset-0 z-50 grid place-items-start bg-black/60 pt-[14vh]"
|
|
145
|
+
role="presentation"
|
|
146
|
+
onClick={() => setPaletteOpen(false)}
|
|
147
|
+
>
|
|
148
|
+
<Command
|
|
149
|
+
className="w-[min(680px,90vw)] rounded-2xl border border-slate-600 bg-slate-950 p-3"
|
|
150
|
+
onClick={(event) => event.stopPropagation()}
|
|
151
|
+
>
|
|
152
|
+
<CommandInput
|
|
153
|
+
placeholder="Type a command"
|
|
154
|
+
value={query}
|
|
155
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
156
|
+
autoFocus
|
|
157
|
+
/>
|
|
158
|
+
<CommandList>
|
|
159
|
+
{searchResults.map((entry) => (
|
|
160
|
+
<CommandItem
|
|
161
|
+
key={entry.id}
|
|
162
|
+
onClick={() => {
|
|
163
|
+
router.push(entry.route);
|
|
164
|
+
setPaletteOpen(false);
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{entry.kind}: {entry.title}
|
|
168
|
+
</CommandItem>
|
|
169
|
+
))}
|
|
170
|
+
{filteredCommands.map((entry) => (
|
|
171
|
+
<CommandItem
|
|
172
|
+
key={entry.id}
|
|
173
|
+
onClick={() => {
|
|
174
|
+
router.push(entry.route);
|
|
175
|
+
setPaletteOpen(false);
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{entry.label}
|
|
179
|
+
</CommandItem>
|
|
180
|
+
))}
|
|
181
|
+
</CommandList>
|
|
182
|
+
</Command>
|
|
183
|
+
</div>
|
|
184
|
+
) : null}
|
|
185
|
+
</header>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "@repo/ui/badge";
|
|
4
|
+
import { Code } from "@repo/ui/code";
|
|
5
|
+
import { DropdownMenu, DropdownMenuItem } from "@repo/ui/dropdown-menu";
|
|
6
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/table";
|
|
7
|
+
import { TaskNode } from "../lib/types";
|
|
8
|
+
|
|
9
|
+
function statusVariant(status: string): "default" | "secondary" | "success" {
|
|
10
|
+
if (status === "complete") return "success";
|
|
11
|
+
if (status === "discovered") return "secondary";
|
|
12
|
+
return "default";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type WorkTableProps = {
|
|
16
|
+
tasks: TaskNode[];
|
|
17
|
+
onSelectTask: (task: TaskNode) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function WorkTable({ tasks, onSelectTask }: WorkTableProps) {
|
|
21
|
+
return (
|
|
22
|
+
<Table>
|
|
23
|
+
<TableHeader>
|
|
24
|
+
<TableRow>
|
|
25
|
+
<TableHead>ID</TableHead>
|
|
26
|
+
<TableHead>Title</TableHead>
|
|
27
|
+
<TableHead>Status</TableHead>
|
|
28
|
+
<TableHead>Domain</TableHead>
|
|
29
|
+
<TableHead>Files</TableHead>
|
|
30
|
+
</TableRow>
|
|
31
|
+
</TableHeader>
|
|
32
|
+
<TableBody>
|
|
33
|
+
{tasks.map((task) => (
|
|
34
|
+
<TableRow
|
|
35
|
+
className="cursor-pointer"
|
|
36
|
+
key={task.id}
|
|
37
|
+
onClick={() => onSelectTask(task)}
|
|
38
|
+
>
|
|
39
|
+
<TableCell>
|
|
40
|
+
<Code>{task.id}</Code>
|
|
41
|
+
</TableCell>
|
|
42
|
+
<TableCell>{task.title}</TableCell>
|
|
43
|
+
<TableCell>
|
|
44
|
+
<Badge variant={statusVariant(task.lane)}>{task.lane}</Badge>
|
|
45
|
+
</TableCell>
|
|
46
|
+
<TableCell>{task.domain ?? "foundation"}</TableCell>
|
|
47
|
+
<TableCell>
|
|
48
|
+
<DropdownMenu>
|
|
49
|
+
<DropdownMenuItem>Open Trace</DropdownMenuItem>
|
|
50
|
+
</DropdownMenu>
|
|
51
|
+
</TableCell>
|
|
52
|
+
</TableRow>
|
|
53
|
+
))}
|
|
54
|
+
</TableBody>
|
|
55
|
+
</Table>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname, useSearchParams } from "next/navigation";
|
|
4
|
+
import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { GraphViewMode } from "../lib/graph-schema";
|
|
6
|
+
|
|
7
|
+
export type WorkspaceView = "architecture-map" | "decisions" | "tasks-roadmap" | "project-map";
|
|
8
|
+
export type GraphNodeFilter = "domains" | "modules" | "tasks" | "decisions";
|
|
9
|
+
export type GraphEdgeFilter = "dependency" | "data-flow" | "blocking";
|
|
10
|
+
export type GraphAuthorityFilter = "authoritative" | "manual" | "inferred";
|
|
11
|
+
|
|
12
|
+
type WorkspaceFilters = {
|
|
13
|
+
nodeTypes: Record<GraphNodeFilter, boolean>;
|
|
14
|
+
edgeTypes: Record<GraphEdgeFilter, boolean>;
|
|
15
|
+
authorityTypes: Record<GraphAuthorityFilter, boolean>;
|
|
16
|
+
hideCompletedTasks: boolean;
|
|
17
|
+
showExternalDependencies: boolean;
|
|
18
|
+
hopDepth: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type WorkspacePersistedState = {
|
|
22
|
+
splitPane: boolean;
|
|
23
|
+
leftCollapsed: boolean;
|
|
24
|
+
rightCollapsed: boolean;
|
|
25
|
+
leftWidth: number;
|
|
26
|
+
rightWidth: number;
|
|
27
|
+
filtersByView: Record<GraphViewMode, WorkspaceFilters>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type WorkspaceContextValue = {
|
|
31
|
+
splitPane: boolean;
|
|
32
|
+
leftCollapsed: boolean;
|
|
33
|
+
rightCollapsed: boolean;
|
|
34
|
+
leftWidth: number;
|
|
35
|
+
rightWidth: number;
|
|
36
|
+
filters: WorkspaceFilters;
|
|
37
|
+
setSplitPane: (next: boolean) => void;
|
|
38
|
+
setLeftCollapsed: (next: boolean) => void;
|
|
39
|
+
setRightCollapsed: (next: boolean) => void;
|
|
40
|
+
setLeftWidth: (next: number) => void;
|
|
41
|
+
setRightWidth: (next: number) => void;
|
|
42
|
+
toggleNodeType: (filter: GraphNodeFilter) => void;
|
|
43
|
+
toggleEdgeType: (filter: GraphEdgeFilter) => void;
|
|
44
|
+
toggleAuthorityType: (filter: GraphAuthorityFilter) => void;
|
|
45
|
+
setHideCompletedTasks: (next: boolean) => void;
|
|
46
|
+
setShowExternalDependencies: (next: boolean) => void;
|
|
47
|
+
setHopDepth: (next: number) => void;
|
|
48
|
+
resetLayout: () => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const DEFAULT_LEFT_WIDTH = 280;
|
|
52
|
+
const DEFAULT_RIGHT_WIDTH = 420;
|
|
53
|
+
const STORAGE_KEY = "arch:workspace:v1";
|
|
54
|
+
|
|
55
|
+
const WorkspaceContext = createContext<WorkspaceContextValue | null>(null);
|
|
56
|
+
|
|
57
|
+
function clamp(value: number, min: number, max: number) {
|
|
58
|
+
return Math.max(min, Math.min(max, value));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultFilters(): WorkspaceFilters {
|
|
62
|
+
return {
|
|
63
|
+
nodeTypes: {
|
|
64
|
+
domains: true,
|
|
65
|
+
modules: true,
|
|
66
|
+
tasks: true,
|
|
67
|
+
decisions: true,
|
|
68
|
+
},
|
|
69
|
+
edgeTypes: {
|
|
70
|
+
dependency: true,
|
|
71
|
+
"data-flow": true,
|
|
72
|
+
blocking: true,
|
|
73
|
+
},
|
|
74
|
+
authorityTypes: {
|
|
75
|
+
authoritative: true,
|
|
76
|
+
manual: true,
|
|
77
|
+
inferred: true,
|
|
78
|
+
},
|
|
79
|
+
hideCompletedTasks: false,
|
|
80
|
+
showExternalDependencies: false,
|
|
81
|
+
hopDepth: 1,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveActiveGraphView(pathname: string, searchParams: URLSearchParams): GraphViewMode {
|
|
86
|
+
if (pathname === "/work" && searchParams.get("view") === "project") return "project";
|
|
87
|
+
if (pathname === "/work" && searchParams.get("view") === "architecture") return "architecture-map";
|
|
88
|
+
if (pathname === "/work") return "tasks";
|
|
89
|
+
return "architecture-map";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function WorkspaceProvider({ children }: { children: ReactNode }) {
|
|
93
|
+
const pathname = usePathname();
|
|
94
|
+
const searchParams = useSearchParams();
|
|
95
|
+
const activeGraphView = useMemo(
|
|
96
|
+
() => resolveActiveGraphView(pathname, searchParams),
|
|
97
|
+
[pathname, searchParams],
|
|
98
|
+
);
|
|
99
|
+
const [splitPane, setSplitPane] = useState(false);
|
|
100
|
+
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
101
|
+
const [rightCollapsed, setRightCollapsed] = useState(false);
|
|
102
|
+
const [leftWidth, setLeftWidth] = useState(DEFAULT_LEFT_WIDTH);
|
|
103
|
+
const [rightWidth, setRightWidth] = useState(DEFAULT_RIGHT_WIDTH);
|
|
104
|
+
const [filtersByView, setFiltersByView] = useState<Record<GraphViewMode, WorkspaceFilters>>({
|
|
105
|
+
"architecture-map": defaultFilters(),
|
|
106
|
+
tasks: defaultFilters(),
|
|
107
|
+
project: defaultFilters(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
try {
|
|
112
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
113
|
+
if (!raw) return;
|
|
114
|
+
const parsed = JSON.parse(raw) as Partial<{
|
|
115
|
+
splitPane: boolean;
|
|
116
|
+
leftCollapsed: boolean;
|
|
117
|
+
rightCollapsed: boolean;
|
|
118
|
+
leftWidth: number;
|
|
119
|
+
rightWidth: number;
|
|
120
|
+
filtersByView: Partial<Record<GraphViewMode, Partial<WorkspaceFilters>>>;
|
|
121
|
+
}>;
|
|
122
|
+
if (typeof parsed.splitPane === "boolean") setSplitPane(parsed.splitPane);
|
|
123
|
+
if (typeof parsed.leftCollapsed === "boolean") setLeftCollapsed(parsed.leftCollapsed);
|
|
124
|
+
if (typeof parsed.rightCollapsed === "boolean") setRightCollapsed(parsed.rightCollapsed);
|
|
125
|
+
if (typeof parsed.leftWidth === "number") setLeftWidth(clamp(parsed.leftWidth, 220, 520));
|
|
126
|
+
if (typeof parsed.rightWidth === "number") setRightWidth(clamp(parsed.rightWidth, 320, 720));
|
|
127
|
+
if (parsed.filtersByView) {
|
|
128
|
+
const views: GraphViewMode[] = ["architecture-map", "tasks", "project"];
|
|
129
|
+
const nextByView = { ...filtersByView };
|
|
130
|
+
for (const view of views) {
|
|
131
|
+
const incoming = parsed.filtersByView[view];
|
|
132
|
+
if (!incoming) continue;
|
|
133
|
+
const base = defaultFilters();
|
|
134
|
+
nextByView[view] = {
|
|
135
|
+
nodeTypes: {
|
|
136
|
+
domains: incoming.nodeTypes?.domains ?? base.nodeTypes.domains,
|
|
137
|
+
modules: incoming.nodeTypes?.modules ?? base.nodeTypes.modules,
|
|
138
|
+
tasks: incoming.nodeTypes?.tasks ?? base.nodeTypes.tasks,
|
|
139
|
+
decisions: incoming.nodeTypes?.decisions ?? base.nodeTypes.decisions,
|
|
140
|
+
},
|
|
141
|
+
edgeTypes: {
|
|
142
|
+
dependency: incoming.edgeTypes?.dependency ?? base.edgeTypes.dependency,
|
|
143
|
+
"data-flow": incoming.edgeTypes?.["data-flow"] ?? base.edgeTypes["data-flow"],
|
|
144
|
+
blocking: incoming.edgeTypes?.blocking ?? base.edgeTypes.blocking,
|
|
145
|
+
},
|
|
146
|
+
authorityTypes: {
|
|
147
|
+
authoritative:
|
|
148
|
+
incoming.authorityTypes?.authoritative ?? base.authorityTypes.authoritative,
|
|
149
|
+
manual: incoming.authorityTypes?.manual ?? base.authorityTypes.manual,
|
|
150
|
+
inferred: incoming.authorityTypes?.inferred ?? base.authorityTypes.inferred,
|
|
151
|
+
},
|
|
152
|
+
hideCompletedTasks: incoming.hideCompletedTasks ?? base.hideCompletedTasks,
|
|
153
|
+
showExternalDependencies:
|
|
154
|
+
incoming.showExternalDependencies ?? base.showExternalDependencies,
|
|
155
|
+
hopDepth: clamp(incoming.hopDepth ?? base.hopDepth, 0, 3),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
setFiltersByView(nextByView);
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Ignore invalid persisted state.
|
|
162
|
+
}
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const payload = JSON.stringify({
|
|
167
|
+
splitPane,
|
|
168
|
+
leftCollapsed,
|
|
169
|
+
rightCollapsed,
|
|
170
|
+
leftWidth,
|
|
171
|
+
rightWidth,
|
|
172
|
+
filtersByView,
|
|
173
|
+
});
|
|
174
|
+
window.localStorage.setItem(STORAGE_KEY, payload);
|
|
175
|
+
}, [filtersByView, leftCollapsed, leftWidth, rightCollapsed, rightWidth, splitPane]);
|
|
176
|
+
|
|
177
|
+
const filters = filtersByView[activeGraphView] ?? defaultFilters();
|
|
178
|
+
|
|
179
|
+
const value = useMemo<WorkspaceContextValue>(
|
|
180
|
+
() => ({
|
|
181
|
+
splitPane,
|
|
182
|
+
leftCollapsed,
|
|
183
|
+
rightCollapsed,
|
|
184
|
+
leftWidth,
|
|
185
|
+
rightWidth,
|
|
186
|
+
filters,
|
|
187
|
+
setSplitPane,
|
|
188
|
+
setLeftCollapsed,
|
|
189
|
+
setRightCollapsed,
|
|
190
|
+
setLeftWidth: (next) => setLeftWidth(clamp(next, 220, 520)),
|
|
191
|
+
setRightWidth: (next) => setRightWidth(clamp(next, 320, 720)),
|
|
192
|
+
toggleNodeType: (filter) =>
|
|
193
|
+
setFiltersByView((prev) => ({
|
|
194
|
+
...prev,
|
|
195
|
+
[activeGraphView]: {
|
|
196
|
+
...prev[activeGraphView],
|
|
197
|
+
nodeTypes: {
|
|
198
|
+
...prev[activeGraphView].nodeTypes,
|
|
199
|
+
[filter]: !prev[activeGraphView].nodeTypes[filter],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
})),
|
|
203
|
+
toggleEdgeType: (filter) =>
|
|
204
|
+
setFiltersByView((prev) => ({
|
|
205
|
+
...prev,
|
|
206
|
+
[activeGraphView]: {
|
|
207
|
+
...prev[activeGraphView],
|
|
208
|
+
edgeTypes: {
|
|
209
|
+
...prev[activeGraphView].edgeTypes,
|
|
210
|
+
[filter]: !prev[activeGraphView].edgeTypes[filter],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
})),
|
|
214
|
+
toggleAuthorityType: (filter) =>
|
|
215
|
+
setFiltersByView((prev) => ({
|
|
216
|
+
...prev,
|
|
217
|
+
[activeGraphView]: {
|
|
218
|
+
...prev[activeGraphView],
|
|
219
|
+
authorityTypes: {
|
|
220
|
+
...prev[activeGraphView].authorityTypes,
|
|
221
|
+
[filter]: !prev[activeGraphView].authorityTypes[filter],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
})),
|
|
225
|
+
setHideCompletedTasks: (next) =>
|
|
226
|
+
setFiltersByView((prev) => ({
|
|
227
|
+
...prev,
|
|
228
|
+
[activeGraphView]: {
|
|
229
|
+
...prev[activeGraphView],
|
|
230
|
+
hideCompletedTasks: next,
|
|
231
|
+
},
|
|
232
|
+
})),
|
|
233
|
+
setShowExternalDependencies: (next) =>
|
|
234
|
+
setFiltersByView((prev) => ({
|
|
235
|
+
...prev,
|
|
236
|
+
[activeGraphView]: {
|
|
237
|
+
...prev[activeGraphView],
|
|
238
|
+
showExternalDependencies: next,
|
|
239
|
+
},
|
|
240
|
+
})),
|
|
241
|
+
setHopDepth: (next) =>
|
|
242
|
+
setFiltersByView((prev) => ({
|
|
243
|
+
...prev,
|
|
244
|
+
[activeGraphView]: {
|
|
245
|
+
...prev[activeGraphView],
|
|
246
|
+
hopDepth: clamp(next, 0, 3),
|
|
247
|
+
},
|
|
248
|
+
})),
|
|
249
|
+
resetLayout: () => {
|
|
250
|
+
setSplitPane(false);
|
|
251
|
+
setLeftCollapsed(false);
|
|
252
|
+
setRightCollapsed(false);
|
|
253
|
+
setLeftWidth(DEFAULT_LEFT_WIDTH);
|
|
254
|
+
setRightWidth(DEFAULT_RIGHT_WIDTH);
|
|
255
|
+
setFiltersByView({
|
|
256
|
+
"architecture-map": defaultFilters(),
|
|
257
|
+
tasks: defaultFilters(),
|
|
258
|
+
project: defaultFilters(),
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
[activeGraphView, filters, leftCollapsed, leftWidth, rightCollapsed, rightWidth, splitPane],
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function useWorkspace() {
|
|
269
|
+
const value = useContext(WorkspaceContext);
|
|
270
|
+
if (!value) {
|
|
271
|
+
throw new Error("useWorkspace must be used within WorkspaceProvider");
|
|
272
|
+
}
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module "*.css";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ApiResult,
|
|
3
|
+
ArchitectureMapData,
|
|
4
|
+
CheckData,
|
|
5
|
+
DomainDocsData,
|
|
6
|
+
FileTraceData,
|
|
7
|
+
GraphDatasetResponse,
|
|
8
|
+
NodeFilesData,
|
|
9
|
+
PhaseListData,
|
|
10
|
+
SearchResultData,
|
|
11
|
+
TaskDocumentData,
|
|
12
|
+
TaskNode,
|
|
13
|
+
TaskTraceData,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
async function readJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
|
17
|
+
const response = await fetch(input, init);
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
20
|
+
}
|
|
21
|
+
return (await response.json()) as T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function unwrap<T>(value: ApiResult<T> | T): T {
|
|
25
|
+
if (typeof value === "object" && value !== null && "success" in value) {
|
|
26
|
+
const result = value as ApiResult<T>;
|
|
27
|
+
if (result.success === false) {
|
|
28
|
+
throw new Error(result.errors?.join("; ") ?? "Request failed");
|
|
29
|
+
}
|
|
30
|
+
if (result.data === undefined) {
|
|
31
|
+
throw new Error("Missing data payload");
|
|
32
|
+
}
|
|
33
|
+
return result.data;
|
|
34
|
+
}
|
|
35
|
+
return value as T;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function getHealth(): Promise<CheckData> {
|
|
39
|
+
return unwrap(await readJson<ApiResult<CheckData>>("/api/health"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function getArchitectureMap(): Promise<ArchitectureMapData> {
|
|
43
|
+
return await readJson<ArchitectureMapData>("/api/architecture/map");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function getDomains(): Promise<{
|
|
47
|
+
domains: Array<{ name: string; description?: string; ownedPackages?: string[] }>;
|
|
48
|
+
}> {
|
|
49
|
+
return await readJson<{
|
|
50
|
+
domains: Array<{ name: string; description?: string; ownedPackages?: string[] }>;
|
|
51
|
+
}>("/api/domains");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function getPhases(): Promise<PhaseListData> {
|
|
55
|
+
return unwrap(await readJson<ApiResult<PhaseListData>>("/api/phases"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getTasks(): Promise<{ tasks: TaskNode[] }> {
|
|
59
|
+
return await readJson<{ tasks: TaskNode[] }>("/api/tasks");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function getTaskTrace(taskId: string): Promise<TaskTraceData> {
|
|
63
|
+
return await readJson<TaskTraceData>(`/api/trace/task/${encodeURIComponent(taskId)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function getFileTrace(filePath: string): Promise<FileTraceData> {
|
|
67
|
+
return await readJson<FileTraceData>(`/api/trace/file?path=${encodeURIComponent(filePath)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function getTaskDocument(taskId: string): Promise<TaskDocumentData> {
|
|
71
|
+
return await readJson<TaskDocumentData>(`/api/task-doc/${encodeURIComponent(taskId)}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function getNodeFiles(
|
|
75
|
+
type: "phase" | "milestone" | "task" | "domain" | "file",
|
|
76
|
+
id: string,
|
|
77
|
+
): Promise<NodeFilesData> {
|
|
78
|
+
return await readJson<NodeFilesData>(
|
|
79
|
+
`/api/node-files?type=${encodeURIComponent(type)}&id=${encodeURIComponent(id)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function searchWorkspace(query: string): Promise<SearchResultData> {
|
|
84
|
+
return await readJson<SearchResultData>(`/api/search?q=${encodeURIComponent(query)}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function getDomainDocs(): Promise<DomainDocsData> {
|
|
88
|
+
return await readJson<DomainDocsData>("/api/domain-docs");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function getGraphDataset(): Promise<GraphDatasetResponse> {
|
|
92
|
+
return await readJson<GraphDatasetResponse>("/api/graph");
|
|
93
|
+
}
|