newpr 1.0.22 → 1.0.23
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/package.json +1 -1
- package/src/stack/delta.test.ts +79 -1
- package/src/stack/delta.ts +130 -12
- package/src/stack/execute.test.ts +129 -1
- package/src/stack/execute.ts +79 -32
- package/src/stack/integration.test.ts +1 -0
- package/src/stack/merge-groups.test.ts +23 -0
- package/src/stack/merge-groups.ts +17 -1
- package/src/stack/plan.test.ts +30 -0
- package/src/stack/plan.ts +20 -14
- package/src/stack/publish.test.ts +83 -0
- package/src/stack/publish.ts +34 -18
- package/src/stack/types.ts +10 -2
- package/src/stack/verify.ts +76 -19
- package/src/web/client/components/StackDagView.tsx +264 -194
- package/src/web/client/hooks/useStack.ts +43 -0
- package/src/web/client/panels/StackPanel.tsx +1 -0
- package/src/web/server/routes.ts +5 -1
- package/src/web/server/stack-manager.ts +77 -3
- package/src/web/styles/built.css +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChevronRight, GitBranch, ExternalLink, Plus, Minus, GitMerge } from "lucide-react";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { useState, useRef, useLayoutEffect } from "react";
|
|
3
3
|
import type { StackGroupStats } from "../../../stack/types.ts";
|
|
4
4
|
|
|
5
5
|
const TYPE_COLORS: Record<string, { dot: string; badge: string; text: string }> = {
|
|
@@ -17,6 +17,14 @@ function formatStat(n: number): string {
|
|
|
17
17
|
return String(n);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function normalizeFilePath(path: string): string {
|
|
21
|
+
return path
|
|
22
|
+
.replace(/\\/g, "/")
|
|
23
|
+
.replace(/^\.\//, "")
|
|
24
|
+
.replace(/^a\//, "")
|
|
25
|
+
.replace(/^b\//, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
export interface DagGroup {
|
|
21
29
|
id: string;
|
|
22
30
|
name: string;
|
|
@@ -24,8 +32,10 @@ export interface DagGroup {
|
|
|
24
32
|
description: string;
|
|
25
33
|
files: string[];
|
|
26
34
|
deps: string[];
|
|
35
|
+
explicit_deps?: string[];
|
|
27
36
|
order: number;
|
|
28
37
|
stats?: StackGroupStats;
|
|
38
|
+
file_stats?: Record<string, { additions: number; deletions: number }>;
|
|
29
39
|
pr_title?: string;
|
|
30
40
|
}
|
|
31
41
|
|
|
@@ -44,227 +54,232 @@ interface DagPr {
|
|
|
44
54
|
|
|
45
55
|
interface DagNode {
|
|
46
56
|
group: DagGroup;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
indent: number;
|
|
58
|
+
isLastChild: boolean;
|
|
59
|
+
parentId: string | null;
|
|
50
60
|
childIds: string[];
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
const INDENT = 18;
|
|
64
|
+
const DOT_CX = 8;
|
|
65
|
+
const DOT_RADIUS = 2.5;
|
|
66
|
+
const ROW_HEIGHT = 36;
|
|
67
|
+
|
|
53
68
|
function buildDagNodes(groups: DagGroup[]): DagNode[] {
|
|
54
69
|
const byId = new Map(groups.map((g) => [g.id, g]));
|
|
55
70
|
|
|
56
|
-
const
|
|
57
|
-
const inDegree = new Map(groups.map((g) => [g.id, 0]));
|
|
71
|
+
const childrenOf = new Map<string, string[]>();
|
|
58
72
|
for (const g of groups) {
|
|
59
|
-
for (const dep of (g.deps ?? [])) {
|
|
60
|
-
if (byId.has(dep))
|
|
73
|
+
for (const dep of (g.explicit_deps ?? g.deps ?? [])) {
|
|
74
|
+
if (!byId.has(dep)) continue;
|
|
75
|
+
const arr = childrenOf.get(dep) ?? [];
|
|
76
|
+
arr.push(g.id);
|
|
77
|
+
childrenOf.set(dep, arr);
|
|
61
78
|
}
|
|
62
79
|
}
|
|
63
80
|
|
|
64
|
-
const
|
|
65
|
-
for (const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const id = queue.shift()!;
|
|
69
|
-
const level = levels.get(id) ?? 0;
|
|
70
|
-
for (const g of groups) {
|
|
71
|
-
if ((g.deps ?? []).includes(id)) {
|
|
72
|
-
const newLevel = Math.max(levels.get(g.id) ?? 0, level + 1);
|
|
73
|
-
levels.set(g.id, newLevel);
|
|
74
|
-
const remaining = (inDegree.get(g.id) ?? 1) - 1;
|
|
75
|
-
inDegree.set(g.id, remaining);
|
|
76
|
-
if (remaining === 0) queue.push(g.id);
|
|
77
|
-
}
|
|
81
|
+
const hasIncomingEdge = new Set<string>();
|
|
82
|
+
for (const g of groups) {
|
|
83
|
+
for (const dep of (g.explicit_deps ?? g.deps ?? [])) {
|
|
84
|
+
if (byId.has(dep)) hasIncomingEdge.add(g.id);
|
|
78
85
|
}
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
88
|
+
const roots = groups.filter((g) => !hasIncomingEdge.has(g.id)).sort((a, b) => a.order - b.order);
|
|
89
|
+
const result: DagNode[] = [];
|
|
90
|
+
const visited = new Set<string>();
|
|
91
|
+
|
|
92
|
+
const dfs = (id: string, indent: number, parentId: string | null, siblingIndex: number, siblingCount: number) => {
|
|
93
|
+
if (visited.has(id)) return;
|
|
94
|
+
visited.add(id);
|
|
95
|
+
|
|
96
|
+
const g = byId.get(id)!;
|
|
97
|
+
const children = (childrenOf.get(id) ?? []).sort((a, b) => {
|
|
98
|
+
return (byId.get(a)?.order ?? 0) - (byId.get(b)?.order ?? 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
result.push({
|
|
102
|
+
group: g,
|
|
103
|
+
indent,
|
|
104
|
+
isLastChild: siblingIndex === siblingCount - 1,
|
|
105
|
+
parentId,
|
|
106
|
+
childIds: children,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
children.forEach((childId, i) => dfs(childId, indent + 1, id, i, children.length));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
roots.forEach((root, i) => dfs(root.id, 0, null, i, roots.length));
|
|
84
113
|
|
|
85
|
-
const childrenOf = new Map<string, string[]>();
|
|
86
114
|
for (const g of groups) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
arr.push(g.id);
|
|
90
|
-
childrenOf.set(dep, arr);
|
|
115
|
+
if (!visited.has(g.id)) {
|
|
116
|
+
result.push({ group: g, indent: 0, isLastChild: true, parentId: null, childIds: childrenOf.get(g.id) ?? [] });
|
|
91
117
|
}
|
|
92
118
|
}
|
|
93
119
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const lb = levels.get(b.id) ?? 0;
|
|
97
|
-
if (la !== lb) return la - lb;
|
|
98
|
-
return a.order - b.order;
|
|
99
|
-
});
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
100
122
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
parentIds: (g.deps ?? []).filter((d) => byId.has(d)),
|
|
111
|
-
childIds: childrenOf.get(g.id) ?? [],
|
|
112
|
-
};
|
|
123
|
+
const BUTTON_HEIGHT = 36;
|
|
124
|
+
const DOT_TOP_OFFSET = BUTTON_HEIGHT / 2;
|
|
125
|
+
|
|
126
|
+
function buildPaths(nodes: DagNode[], rowHeights: number[]): string[] {
|
|
127
|
+
let cumulativeY = 0;
|
|
128
|
+
const dotY = nodes.map((_, i) => {
|
|
129
|
+
const y = cumulativeY + DOT_TOP_OFFSET;
|
|
130
|
+
cumulativeY += rowHeights[i] ?? ROW_HEIGHT;
|
|
131
|
+
return y;
|
|
113
132
|
});
|
|
114
|
-
}
|
|
115
133
|
|
|
134
|
+
const idToIndex = new Map(nodes.map((n, i) => [n.group.id, i]));
|
|
135
|
+
const paths: string[] = [];
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
138
|
+
const node = nodes[i]!;
|
|
139
|
+
const parentIdx = node.parentId !== null ? idToIndex.get(node.parentId) : undefined;
|
|
140
|
+
if (parentIdx === undefined) continue;
|
|
141
|
+
|
|
142
|
+
const parentX = (node.indent - 1) * INDENT + DOT_CX;
|
|
143
|
+
const childX = node.indent * INDENT + DOT_CX;
|
|
144
|
+
const parentY = dotY[parentIdx]!;
|
|
145
|
+
const childY = dotY[i]!;
|
|
146
|
+
|
|
147
|
+
paths.push(`M ${parentX} ${parentY + DOT_RADIUS} L ${parentX} ${childY} L ${childX - DOT_RADIUS} ${childY}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return paths;
|
|
151
|
+
}
|
|
116
152
|
|
|
117
153
|
function DagNodeCard({
|
|
118
154
|
node,
|
|
119
155
|
commit,
|
|
120
156
|
pr,
|
|
121
|
-
|
|
157
|
+
fileStatsByPath,
|
|
158
|
+
rowRef,
|
|
122
159
|
}: {
|
|
123
160
|
node: DagNode;
|
|
124
161
|
commit?: DagCommit;
|
|
125
162
|
pr?: DagPr;
|
|
126
|
-
|
|
163
|
+
fileStatsByPath?: Record<string, { additions: number; deletions: number }>;
|
|
164
|
+
allGroups?: DagGroup[];
|
|
165
|
+
rowRef: (el: HTMLDivElement | null) => void;
|
|
127
166
|
}) {
|
|
128
167
|
const [expanded, setExpanded] = useState(false);
|
|
129
|
-
const { group
|
|
130
|
-
const
|
|
168
|
+
const { group } = node;
|
|
169
|
+
const resolveFileStats = (file: string): { additions: number; deletions: number } => {
|
|
170
|
+
const normalizedPath = normalizeFilePath(file);
|
|
171
|
+
const fallbackStats = fileStatsByPath?.[file]
|
|
172
|
+
?? fileStatsByPath?.[normalizedPath]
|
|
173
|
+
?? { additions: 0, deletions: 0 };
|
|
174
|
+
const fromGroup = group.file_stats?.[file]
|
|
175
|
+
?? group.file_stats?.[normalizedPath];
|
|
176
|
+
if (fromGroup && (fromGroup.additions > 0 || fromGroup.deletions > 0)) return fromGroup;
|
|
177
|
+
if (fallbackStats.additions > 0 || fallbackStats.deletions > 0) return fallbackStats;
|
|
178
|
+
return fromGroup ?? fallbackStats;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const fileRows = group.files.map((file) => ({
|
|
182
|
+
path: file,
|
|
183
|
+
stats: resolveFileStats(file),
|
|
184
|
+
}));
|
|
185
|
+
const fileTotals = fileRows.reduce(
|
|
186
|
+
(acc, row) => ({ additions: acc.additions + row.stats.additions, deletions: acc.deletions + row.stats.deletions }),
|
|
187
|
+
{ additions: 0, deletions: 0 },
|
|
188
|
+
);
|
|
189
|
+
const hasFileTotals = fileRows.some((row) => row.stats.additions > 0 || row.stats.deletions > 0);
|
|
190
|
+
const stats = hasFileTotals
|
|
191
|
+
? {
|
|
192
|
+
additions: fileTotals.additions,
|
|
193
|
+
deletions: fileTotals.deletions,
|
|
194
|
+
files_added: group.stats?.files_added ?? 0,
|
|
195
|
+
files_modified: group.stats?.files_modified ?? 0,
|
|
196
|
+
files_deleted: group.stats?.files_deleted ?? 0,
|
|
197
|
+
}
|
|
198
|
+
: group.stats;
|
|
131
199
|
const colors = TYPE_COLORS[group.type] ?? TYPE_COLORS.chore!;
|
|
132
|
-
|
|
133
|
-
const depNames = (group.deps ?? []).map((depId) => {
|
|
134
|
-
const found = allGroups.find((g) => g.id === depId);
|
|
135
|
-
return found?.pr_title ?? found?.name ?? depId;
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
const isParallel = node.parentIds.length === 0
|
|
139
|
-
? false
|
|
140
|
-
: allGroups.filter((g) => {
|
|
141
|
-
const gDeps = g.deps ?? [];
|
|
142
|
-
return node.parentIds.every((p) => gDeps.includes(p)) && g.id !== group.id;
|
|
143
|
-
}).length > 0;
|
|
200
|
+
const leftPad = node.indent * INDENT + DOT_CX * 2 + 4;
|
|
144
201
|
|
|
145
202
|
return (
|
|
146
|
-
<div
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
>
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
203
|
+
<div ref={rowRef}>
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
onClick={() => setExpanded(!expanded)}
|
|
207
|
+
className="w-full flex items-center gap-2 py-1.5 text-left hover:bg-accent/20 transition-colors rounded-md pr-2"
|
|
208
|
+
style={{ paddingLeft: leftPad }}
|
|
209
|
+
>
|
|
210
|
+
<span className={`text-[9px] font-medium px-1.5 py-px rounded ${colors.badge} ${colors.text} shrink-0 leading-none`}>
|
|
211
|
+
{group.type}
|
|
212
|
+
</span>
|
|
213
|
+
<span className="text-[11.5px] font-medium text-foreground/90 flex-1 min-w-0 truncate">
|
|
214
|
+
{group.pr_title ?? group.name}
|
|
215
|
+
</span>
|
|
216
|
+
<span className="shrink-0 flex items-center gap-1.5">
|
|
217
|
+
{stats ? (
|
|
218
|
+
<>
|
|
219
|
+
<span className="text-[10px] text-green-600/60 dark:text-green-400/60 tabular-nums">+{formatStat(stats.additions)}</span>
|
|
220
|
+
<span className="text-[10px] text-red-500/60 tabular-nums">−{formatStat(stats.deletions)}</span>
|
|
221
|
+
</>
|
|
222
|
+
) : (
|
|
223
|
+
<span className="text-[9px] text-muted-foreground/20 tabular-nums">{group.files.length}f</span>
|
|
224
|
+
)}
|
|
225
|
+
<ChevronRight className={`h-3 w-3 text-muted-foreground/20 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
|
|
226
|
+
</span>
|
|
227
|
+
</button>
|
|
228
|
+
|
|
229
|
+
{expanded && (
|
|
230
|
+
<div className="pb-2 space-y-2" style={{ paddingLeft: leftPad }}>
|
|
231
|
+
{group.description && (
|
|
232
|
+
<p className="text-[10.5px] text-muted-foreground/45 leading-[1.55]">{group.description}</p>
|
|
233
|
+
)}
|
|
234
|
+
{stats && (
|
|
235
|
+
<div className="flex items-center gap-3 text-[9.5px] text-muted-foreground/30 tabular-nums">
|
|
236
|
+
<span>{group.files.length} files</span>
|
|
237
|
+
<span className="flex items-center gap-0.5">
|
|
238
|
+
<Plus className="h-2 w-2 text-green-600/50 dark:text-green-400/50" />
|
|
239
|
+
{stats.additions.toLocaleString()}
|
|
171
240
|
</span>
|
|
172
|
-
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
<span className="text-[11.5px] font-medium text-foreground/90 truncate">
|
|
176
|
-
{group.pr_title ?? group.name}
|
|
241
|
+
<span className="flex items-center gap-0.5">
|
|
242
|
+
<Minus className="h-2 w-2 text-red-500/50" />
|
|
243
|
+
{stats.deletions.toLocaleString()}
|
|
177
244
|
</span>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
{depNames.map((name, i) => (
|
|
184
|
-
<span key={i} className="text-[9px] text-muted-foreground/25 font-mono truncate max-w-[120px]">
|
|
185
|
-
{name}
|
|
186
|
-
</span>
|
|
187
|
-
))}
|
|
188
|
-
</div>
|
|
189
|
-
)}
|
|
190
|
-
</div>
|
|
191
|
-
|
|
192
|
-
<div className="flex-shrink-0 flex items-center gap-2 mt-[3px]">
|
|
193
|
-
{stats ? (
|
|
194
|
-
<span className="flex items-center gap-1.5">
|
|
195
|
-
<span className="text-[10px] text-green-600/60 dark:text-green-400/60 tabular-nums">
|
|
196
|
-
+{formatStat(stats.additions)}
|
|
197
|
-
</span>
|
|
198
|
-
<span className="text-[10px] text-red-500/60 tabular-nums">
|
|
199
|
-
−{formatStat(stats.deletions)}
|
|
200
|
-
</span>
|
|
201
|
-
</span>
|
|
202
|
-
) : (
|
|
203
|
-
<span className="text-[9px] text-muted-foreground/20 tabular-nums">{group.files.length}f</span>
|
|
204
|
-
)}
|
|
205
|
-
<ChevronRight className={`h-3 w-3 text-muted-foreground/20 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
|
|
206
|
-
</div>
|
|
207
|
-
</button>
|
|
208
|
-
|
|
209
|
-
{expanded && (
|
|
210
|
-
<div className="ml-4 pl-3 pb-3 pt-1 space-y-2 border-l border-border/30">
|
|
211
|
-
{group.description && (
|
|
212
|
-
<p className="text-[10.5px] text-muted-foreground/45 leading-[1.55]">{group.description}</p>
|
|
213
|
-
)}
|
|
214
|
-
|
|
215
|
-
{stats && (
|
|
216
|
-
<div className="flex items-center gap-3 text-[9.5px] text-muted-foreground/30 tabular-nums">
|
|
217
|
-
<span>{group.files.length} files</span>
|
|
218
|
-
<span className="flex items-center gap-0.5">
|
|
219
|
-
<Plus className="h-2 w-2 text-green-600/50 dark:text-green-400/50" />
|
|
220
|
-
{stats.additions.toLocaleString()}
|
|
221
|
-
</span>
|
|
222
|
-
<span className="flex items-center gap-0.5">
|
|
223
|
-
<Minus className="h-2 w-2 text-red-500/50" />
|
|
224
|
-
{stats.deletions.toLocaleString()}
|
|
245
|
+
{(stats.files_added > 0 || stats.files_modified > 0 || stats.files_deleted > 0) && (
|
|
246
|
+
<span>
|
|
247
|
+
{stats.files_added > 0 && `${stats.files_added}A `}
|
|
248
|
+
{stats.files_modified > 0 && `${stats.files_modified}M `}
|
|
249
|
+
{stats.files_deleted > 0 && `${stats.files_deleted}D`}
|
|
225
250
|
</span>
|
|
226
|
-
|
|
227
|
-
<span>
|
|
228
|
-
{stats.files_added > 0 && `${stats.files_added}A `}
|
|
229
|
-
{stats.files_modified > 0 && `${stats.files_modified}M `}
|
|
230
|
-
{stats.files_deleted > 0 && `${stats.files_deleted}D`}
|
|
231
|
-
</span>
|
|
232
|
-
)}
|
|
233
|
-
</div>
|
|
234
|
-
)}
|
|
235
|
-
|
|
236
|
-
{commit && (
|
|
237
|
-
<div className="flex items-center gap-1.5 text-[9.5px] text-muted-foreground/25">
|
|
238
|
-
<GitBranch className="h-2.5 w-2.5 shrink-0" />
|
|
239
|
-
<span className="font-mono truncate">{commit.branch_name}</span>
|
|
240
|
-
<span className="text-muted-foreground/15">·</span>
|
|
241
|
-
<span className="font-mono shrink-0">{commit.commit_sha.slice(0, 7)}</span>
|
|
242
|
-
</div>
|
|
243
|
-
)}
|
|
244
|
-
|
|
245
|
-
{pr && (
|
|
246
|
-
<a
|
|
247
|
-
href={pr.url}
|
|
248
|
-
target="_blank"
|
|
249
|
-
rel="noopener noreferrer"
|
|
250
|
-
className="inline-flex items-center gap-1 text-[9.5px] text-foreground/40 hover:text-foreground/70 transition-colors"
|
|
251
|
-
>
|
|
252
|
-
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
|
|
253
|
-
<span className="tabular-nums">#{pr.number}</span>
|
|
254
|
-
<span className="truncate max-w-[180px]">{pr.title}</span>
|
|
255
|
-
</a>
|
|
256
|
-
)}
|
|
257
|
-
|
|
258
|
-
<div className="space-y-0 pt-0.5">
|
|
259
|
-
{group.files.map((file) => (
|
|
260
|
-
<div key={file} className="text-[9px] font-mono text-muted-foreground/20 py-[1px] truncate">
|
|
261
|
-
{file}
|
|
262
|
-
</div>
|
|
263
|
-
))}
|
|
251
|
+
)}
|
|
264
252
|
</div>
|
|
253
|
+
)}
|
|
254
|
+
{commit && (
|
|
255
|
+
<div className="flex items-center gap-1.5 text-[9.5px] text-muted-foreground/25">
|
|
256
|
+
<GitBranch className="h-2.5 w-2.5 shrink-0" />
|
|
257
|
+
<span className="font-mono truncate">{commit.branch_name}</span>
|
|
258
|
+
<span className="text-muted-foreground/15">·</span>
|
|
259
|
+
<span className="font-mono shrink-0">{commit.commit_sha.slice(0, 7)}</span>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
{pr && (
|
|
263
|
+
<a href={pr.url} target="_blank" rel="noopener noreferrer"
|
|
264
|
+
className="inline-flex items-center gap-1 text-[9.5px] text-foreground/40 hover:text-foreground/70 transition-colors">
|
|
265
|
+
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
|
|
266
|
+
<span className="tabular-nums">#{pr.number}</span>
|
|
267
|
+
<span className="truncate max-w-[180px]">{pr.title}</span>
|
|
268
|
+
</a>
|
|
269
|
+
)}
|
|
270
|
+
<div className="space-y-0 pt-0.5">
|
|
271
|
+
{fileRows.map(({ path, stats: fileStats }) => {
|
|
272
|
+
return (
|
|
273
|
+
<div key={path} className="flex items-center gap-2 text-[9px] font-mono text-muted-foreground/20 py-[1px]">
|
|
274
|
+
<span className="truncate flex-1 min-w-0">{path}</span>
|
|
275
|
+
<span className="tabular-nums text-green-600/60 dark:text-green-400/60 shrink-0">+{fileStats.additions}</span>
|
|
276
|
+
<span className="tabular-nums text-red-500/60 shrink-0">-{fileStats.deletions}</span>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
})}
|
|
265
280
|
</div>
|
|
266
|
-
|
|
267
|
-
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
268
283
|
</div>
|
|
269
284
|
);
|
|
270
285
|
}
|
|
@@ -273,25 +288,85 @@ export function StackDagView({
|
|
|
273
288
|
groups,
|
|
274
289
|
groupCommits,
|
|
275
290
|
publishedPrs,
|
|
291
|
+
fileStatsByPath,
|
|
276
292
|
}: {
|
|
277
293
|
groups: DagGroup[];
|
|
278
294
|
groupCommits?: DagCommit[];
|
|
279
295
|
publishedPrs?: DagPr[];
|
|
296
|
+
fileStatsByPath?: Record<string, { additions: number; deletions: number }>;
|
|
280
297
|
}) {
|
|
281
298
|
const nodes = buildDagNodes(groups);
|
|
282
|
-
const isLinear = nodes.every((n) => n.
|
|
299
|
+
const isLinear = nodes.every((n) => n.indent === 0);
|
|
300
|
+
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
301
|
+
const [rowHeights, setRowHeights] = useState<number[]>([]);
|
|
302
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
303
|
+
const [svgHeight, setSvgHeight] = useState(0);
|
|
304
|
+
const maxIndent = Math.max(...nodes.map((n) => n.indent), 0);
|
|
305
|
+
const svgWidth = (maxIndent + 1) * INDENT + DOT_CX * 2;
|
|
306
|
+
|
|
307
|
+
useLayoutEffect(() => {
|
|
308
|
+
const measure = () => {
|
|
309
|
+
const heights = rowRefs.current.map((el) => el?.getBoundingClientRect().height ?? ROW_HEIGHT);
|
|
310
|
+
const total = heights.reduce((a, b) => a + b, 0);
|
|
311
|
+
setRowHeights((prev) => {
|
|
312
|
+
if (prev.length === heights.length && prev.every((h, i) => h === heights[i])) return prev;
|
|
313
|
+
return heights;
|
|
314
|
+
});
|
|
315
|
+
setSvgHeight((prev) => prev === total ? prev : total);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
measure();
|
|
319
|
+
|
|
320
|
+
const ro = new ResizeObserver(measure);
|
|
321
|
+
const container = containerRef.current;
|
|
322
|
+
if (container) ro.observe(container);
|
|
323
|
+
return () => ro.disconnect();
|
|
324
|
+
}, [nodes.length]);
|
|
325
|
+
|
|
326
|
+
const paths = rowHeights.length === nodes.length ? buildPaths(nodes, rowHeights) : [];
|
|
327
|
+
|
|
328
|
+
let cumulativeY = 0;
|
|
329
|
+
const dotPositions = nodes.map((node, i) => {
|
|
330
|
+
const h = rowHeights[i] ?? ROW_HEIGHT;
|
|
331
|
+
const y = cumulativeY + DOT_TOP_OFFSET;
|
|
332
|
+
cumulativeY += h;
|
|
333
|
+
return { y, cx: node.indent * INDENT + DOT_CX, node };
|
|
334
|
+
});
|
|
283
335
|
|
|
284
336
|
return (
|
|
285
|
-
<div className="relative">
|
|
337
|
+
<div className="relative" ref={containerRef}>
|
|
286
338
|
{!isLinear && (
|
|
287
|
-
<div className="flex items-center gap-1.5 mb-
|
|
339
|
+
<div className="flex items-center gap-1.5 mb-1 px-1">
|
|
288
340
|
<GitMerge className="h-3 w-3 text-muted-foreground/25" />
|
|
289
341
|
<span className="text-[9px] text-muted-foreground/25 uppercase tracking-wider">DAG</span>
|
|
290
342
|
</div>
|
|
291
343
|
)}
|
|
292
344
|
|
|
293
|
-
<div className="relative
|
|
294
|
-
{
|
|
345
|
+
<div className="relative">
|
|
346
|
+
{svgHeight > 0 && (
|
|
347
|
+
<svg
|
|
348
|
+
className="absolute top-0 left-0 pointer-events-none overflow-visible"
|
|
349
|
+
width={svgWidth}
|
|
350
|
+
height={svgHeight}
|
|
351
|
+
>
|
|
352
|
+
{paths.map((d, i) => (
|
|
353
|
+
<path key={i} d={d}
|
|
354
|
+
stroke="currentColor" strokeOpacity="0.35" strokeWidth="1.5"
|
|
355
|
+
fill="none" strokeLinejoin="round"
|
|
356
|
+
className="text-border" />
|
|
357
|
+
))}
|
|
358
|
+
{dotPositions.map(({ y, cx, node }) => {
|
|
359
|
+
const colors = TYPE_COLORS[node.group.type] ?? TYPE_COLORS.chore!;
|
|
360
|
+
const colorClass = colors.dot;
|
|
361
|
+
return (
|
|
362
|
+
<circle key={node.group.id} cx={cx} cy={y} r={DOT_RADIUS}
|
|
363
|
+
className={colorClass} fill="currentColor" fillOpacity="0.7" />
|
|
364
|
+
);
|
|
365
|
+
})}
|
|
366
|
+
</svg>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{nodes.map((node, i) => {
|
|
295
370
|
const commit = groupCommits?.find((c) => c.group_id === node.group.id);
|
|
296
371
|
const pr = publishedPrs?.find((p) => p.group_id === node.group.id);
|
|
297
372
|
return (
|
|
@@ -300,17 +375,12 @@ export function StackDagView({
|
|
|
300
375
|
node={node}
|
|
301
376
|
commit={commit}
|
|
302
377
|
pr={pr}
|
|
378
|
+
fileStatsByPath={fileStatsByPath}
|
|
303
379
|
allGroups={groups}
|
|
380
|
+
rowRef={(el) => { rowRefs.current[i] = el; }}
|
|
304
381
|
/>
|
|
305
382
|
);
|
|
306
383
|
})}
|
|
307
|
-
|
|
308
|
-
{nodes.length > 0 && (
|
|
309
|
-
<div
|
|
310
|
-
className="absolute top-[14px] bottom-[14px] w-px bg-border/15 pointer-events-none"
|
|
311
|
-
style={{ left: "8px" }}
|
|
312
|
-
/>
|
|
313
|
-
)}
|
|
314
384
|
</div>
|
|
315
385
|
</div>
|
|
316
386
|
);
|
|
@@ -43,11 +43,22 @@ interface PlanData {
|
|
|
43
43
|
deps: string[];
|
|
44
44
|
order: number;
|
|
45
45
|
stats?: StackGroupStats;
|
|
46
|
+
file_stats?: Record<string, { additions: number; deletions: number }>;
|
|
46
47
|
pr_title?: string;
|
|
47
48
|
}>;
|
|
48
49
|
expected_trees: Record<string, string>;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
interface SessionFileStat {
|
|
53
|
+
path: string;
|
|
54
|
+
additions: number;
|
|
55
|
+
deletions: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SessionDataForStats {
|
|
59
|
+
files?: SessionFileStat[];
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
interface ExecResultData {
|
|
52
63
|
run_id: string;
|
|
53
64
|
source_copy_branch: string;
|
|
@@ -142,6 +153,26 @@ export interface StackState {
|
|
|
142
153
|
publishCleanupLoading: boolean;
|
|
143
154
|
publishCleanupError: string | null;
|
|
144
155
|
progressMessage: string | null;
|
|
156
|
+
analysisFileStatsByPath: Record<string, { additions: number; deletions: number }>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeFilePath(path: string): string {
|
|
160
|
+
return path
|
|
161
|
+
.replace(/\\/g, "/")
|
|
162
|
+
.replace(/^\.\//, "")
|
|
163
|
+
.replace(/^a\//, "")
|
|
164
|
+
.replace(/^b\//, "");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildAnalysisFileStatsByPath(files: SessionFileStat[] | undefined): Record<string, { additions: number; deletions: number }> {
|
|
168
|
+
const map: Record<string, { additions: number; deletions: number }> = {};
|
|
169
|
+
for (const file of files ?? []) {
|
|
170
|
+
const stats = { additions: file.additions, deletions: file.deletions };
|
|
171
|
+
map[file.path] = stats;
|
|
172
|
+
const normalized = normalizeFilePath(file.path);
|
|
173
|
+
if (!map[normalized]) map[normalized] = stats;
|
|
174
|
+
}
|
|
175
|
+
return map;
|
|
145
176
|
}
|
|
146
177
|
|
|
147
178
|
function serverPhaseToClient(status: string, phase: string | null): StackPhase {
|
|
@@ -191,6 +222,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
191
222
|
publishCleanupLoading: false,
|
|
192
223
|
publishCleanupError: null,
|
|
193
224
|
progressMessage: null,
|
|
225
|
+
analysisFileStatsByPath: {},
|
|
194
226
|
});
|
|
195
227
|
|
|
196
228
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
@@ -205,6 +237,16 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
205
237
|
setState((s) => ({ ...s, ...applyServerState(data.state!) }));
|
|
206
238
|
})
|
|
207
239
|
.catch(() => {});
|
|
240
|
+
|
|
241
|
+
fetch(`/api/sessions/${sessionId}`)
|
|
242
|
+
.then((res) => res.json())
|
|
243
|
+
.then((data: SessionDataForStats) => {
|
|
244
|
+
setState((s) => ({
|
|
245
|
+
...s,
|
|
246
|
+
analysisFileStatsByPath: buildAnalysisFileStatsByPath(data.files),
|
|
247
|
+
}));
|
|
248
|
+
})
|
|
249
|
+
.catch(() => {});
|
|
208
250
|
}, [sessionId]);
|
|
209
251
|
|
|
210
252
|
const setMaxGroups = useCallback((n: number | null) => {
|
|
@@ -479,6 +521,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
479
521
|
publishCleanupLoading: false,
|
|
480
522
|
publishCleanupError: null,
|
|
481
523
|
progressMessage: null,
|
|
524
|
+
analysisFileStatsByPath: s.analysisFileStatsByPath,
|
|
482
525
|
}));
|
|
483
526
|
}, []);
|
|
484
527
|
|
|
@@ -232,6 +232,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
|
|
|
232
232
|
groups={stack.plan.groups}
|
|
233
233
|
groupCommits={stack.execResult?.group_commits}
|
|
234
234
|
publishedPrs={stack.publishResult?.prs}
|
|
235
|
+
fileStatsByPath={stack.analysisFileStatsByPath}
|
|
235
236
|
/>
|
|
236
237
|
</div>
|
|
237
238
|
)}
|
package/src/web/server/routes.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { createResilientLlmClient } from "../../llm/resilient-client.ts";
|
|
|
17
17
|
import { detectAgents, runAgent } from "../../workspace/agent.ts";
|
|
18
18
|
import { randomBytes } from "node:crypto";
|
|
19
19
|
import { publishStack, buildStackPublishPreview } from "../../stack/publish.ts";
|
|
20
|
-
import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks, setStackPublishResult, setStackPublishPreview, setStackPublishCleanupResult } from "./stack-manager.ts";
|
|
20
|
+
import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks, setStackPublishResult, setStackPublishPreview, setStackPublishCleanupResult, recomputeStackPlanStatsIfNeeded } from "./stack-manager.ts";
|
|
21
21
|
import { getTelemetryConsent, setTelemetryConsent, telemetry } from "../../telemetry/index.ts";
|
|
22
22
|
|
|
23
23
|
function json(data: unknown, status = 200): Response {
|
|
@@ -1820,6 +1820,10 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
|
|
|
1820
1820
|
await restoreCompletedStacks([id]);
|
|
1821
1821
|
state = getStackState(id);
|
|
1822
1822
|
}
|
|
1823
|
+
if (state?.plan && state.context) {
|
|
1824
|
+
await recomputeStackPlanStatsIfNeeded(id);
|
|
1825
|
+
state = getStackState(id);
|
|
1826
|
+
}
|
|
1823
1827
|
if (!state) return json({ state: null });
|
|
1824
1828
|
return json({ state });
|
|
1825
1829
|
},
|