create-interview-cockpit 0.24.0 → 0.26.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/package.json +1 -1
- package/template/client/src/api.ts +26 -4
- package/template/client/src/components/ChatView.tsx +24 -3
- package/template/client/src/components/CodeContextPanel.tsx +242 -60
- package/template/client/src/components/FileViewerModal.tsx +209 -49
- package/template/client/src/components/GitDiffPanel.tsx +403 -73
- package/template/client/src/components/GitDiffViewerModal.tsx +2 -1
- package/template/client/src/components/MarkdownRenderer.tsx +8 -1
- package/template/client/src/store.ts +17 -2
- package/template/client/src/types.ts +8 -0
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +1739 -185
- package/template/server/src/storage.ts +2 -0
|
@@ -5,8 +5,14 @@ import {
|
|
|
5
5
|
Eye,
|
|
6
6
|
X,
|
|
7
7
|
Check,
|
|
8
|
+
CheckSquare,
|
|
8
9
|
ChevronDown,
|
|
9
10
|
ChevronRight,
|
|
11
|
+
File,
|
|
12
|
+
Folder,
|
|
13
|
+
FolderOpen,
|
|
14
|
+
MinusSquare,
|
|
15
|
+
Square,
|
|
10
16
|
} from "lucide-react";
|
|
11
17
|
import { useStore } from "../store";
|
|
12
18
|
import {
|
|
@@ -30,12 +36,265 @@ const STATUS_META: Record<string, { color: string; label: string }> = {
|
|
|
30
36
|
"?": { color: "text-slate-400", label: "untracked" },
|
|
31
37
|
};
|
|
32
38
|
|
|
39
|
+
const DIFF_ROOT_SEPARATOR = "::";
|
|
40
|
+
|
|
41
|
+
type DiffFileEntry = GitDiffTreeResponse["files"][number];
|
|
42
|
+
|
|
43
|
+
interface DiffTreeNode {
|
|
44
|
+
name: string;
|
|
45
|
+
path: string;
|
|
46
|
+
children: DiffTreeNode[];
|
|
47
|
+
files: DiffFileEntry[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function diffFileId(rootId: string | undefined, filePath: string): string {
|
|
51
|
+
return rootId ? `${rootId}${DIFF_ROOT_SEPARATOR}${filePath}` : filePath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseDiffFileId(fileId: string) {
|
|
55
|
+
const idx = fileId.indexOf(DIFF_ROOT_SEPARATOR);
|
|
56
|
+
if (idx === -1) return { rootId: null, relativePath: fileId };
|
|
57
|
+
return {
|
|
58
|
+
rootId: fileId.slice(0, idx),
|
|
59
|
+
relativePath: fileId.slice(idx + DIFF_ROOT_SEPARATOR.length),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeDiffSelection(files: string[], rootId?: string): string[] {
|
|
64
|
+
if (!rootId) return files;
|
|
65
|
+
return files.map((file) => {
|
|
66
|
+
const parsed = parseDiffFileId(file);
|
|
67
|
+
return parsed.rootId ? file : diffFileId(rootId, file);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildDiffTree(files: DiffFileEntry[]): DiffTreeNode {
|
|
72
|
+
const root: DiffTreeNode = { name: "", path: "", children: [], files: [] };
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const parts = file.path.split("/");
|
|
75
|
+
let current = root;
|
|
76
|
+
let currentPath = "";
|
|
77
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
78
|
+
const part = parts[i];
|
|
79
|
+
const isFile = i === parts.length - 1;
|
|
80
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
81
|
+
if (isFile) {
|
|
82
|
+
current.files.push(file);
|
|
83
|
+
} else {
|
|
84
|
+
let child = current.children.find(
|
|
85
|
+
(candidate) => candidate.name === part,
|
|
86
|
+
);
|
|
87
|
+
if (!child) {
|
|
88
|
+
child = { name: part, path: currentPath, children: [], files: [] };
|
|
89
|
+
current.children.push(child);
|
|
90
|
+
}
|
|
91
|
+
current = child;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return root;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getAllDiffFilePaths(node: DiffTreeNode): string[] {
|
|
99
|
+
return [
|
|
100
|
+
...node.files.map((file) => file.path),
|
|
101
|
+
...node.children.flatMap(getAllDiffFilePaths),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function DiffFileRow({
|
|
106
|
+
file,
|
|
107
|
+
rootId,
|
|
108
|
+
selected,
|
|
109
|
+
onToggleFile,
|
|
110
|
+
onViewFile,
|
|
111
|
+
depth,
|
|
112
|
+
}: {
|
|
113
|
+
file: DiffFileEntry;
|
|
114
|
+
rootId?: string;
|
|
115
|
+
selected: string[];
|
|
116
|
+
onToggleFile: (path: string) => void;
|
|
117
|
+
onViewFile: (path: string) => void;
|
|
118
|
+
depth: number;
|
|
119
|
+
}) {
|
|
120
|
+
const meta = STATUS_META[file.status] ?? STATUS_META.M;
|
|
121
|
+
const fileId = diffFileId(rootId, file.path);
|
|
122
|
+
const legacySelected = selected.includes(file.path);
|
|
123
|
+
const isSelected = selected.includes(fileId) || legacySelected;
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className={`flex items-center gap-1 text-xs group rounded px-1 py-0.5 ${
|
|
127
|
+
isSelected ? "bg-cyan-500/10" : ""
|
|
128
|
+
}`}
|
|
129
|
+
style={{ paddingLeft: depth * 12 + 4 }}
|
|
130
|
+
>
|
|
131
|
+
<button
|
|
132
|
+
onClick={() => onToggleFile(file.path)}
|
|
133
|
+
className="shrink-0"
|
|
134
|
+
title={meta.label}
|
|
135
|
+
>
|
|
136
|
+
{isSelected ? (
|
|
137
|
+
<Check className="w-3 h-3 text-cyan-400" />
|
|
138
|
+
) : (
|
|
139
|
+
<span
|
|
140
|
+
className={`w-3 h-3 inline-flex items-center justify-center text-[10px] font-mono ${meta.color}`}
|
|
141
|
+
>
|
|
142
|
+
{file.status}
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
</button>
|
|
146
|
+
<button
|
|
147
|
+
onClick={() => onToggleFile(file.path)}
|
|
148
|
+
className={`flex items-center gap-1 flex-1 min-w-0 text-left truncate ${
|
|
149
|
+
isSelected ? "text-cyan-300" : "text-slate-400"
|
|
150
|
+
} hover:text-slate-200`}
|
|
151
|
+
title={file.oldPath ? `${file.oldPath} → ${file.path}` : file.path}
|
|
152
|
+
>
|
|
153
|
+
<File className="w-3 h-3 shrink-0 text-slate-600" />
|
|
154
|
+
<span className="truncate">{file.path.split("/").pop()}</span>
|
|
155
|
+
</button>
|
|
156
|
+
<span
|
|
157
|
+
className={`shrink-0 text-[10px] font-mono ${
|
|
158
|
+
file.binary ? "text-slate-600" : "text-slate-500"
|
|
159
|
+
}`}
|
|
160
|
+
>
|
|
161
|
+
{file.binary ? "bin" : `+${file.additions}/-${file.deletions}`}
|
|
162
|
+
</span>
|
|
163
|
+
<button
|
|
164
|
+
onClick={() => onViewFile(file.path)}
|
|
165
|
+
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
|
|
166
|
+
title="View diff"
|
|
167
|
+
>
|
|
168
|
+
<Eye className="w-3 h-3" />
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function DiffFolderNode({
|
|
175
|
+
node,
|
|
176
|
+
rootId,
|
|
177
|
+
selected,
|
|
178
|
+
expandedFolders,
|
|
179
|
+
onToggleExpand,
|
|
180
|
+
onToggleFile,
|
|
181
|
+
onToggleFolder,
|
|
182
|
+
onViewFile,
|
|
183
|
+
depth,
|
|
184
|
+
}: {
|
|
185
|
+
node: DiffTreeNode;
|
|
186
|
+
rootId?: string;
|
|
187
|
+
selected: string[];
|
|
188
|
+
expandedFolders: Set<string>;
|
|
189
|
+
onToggleExpand: (path: string) => void;
|
|
190
|
+
onToggleFile: (path: string) => void;
|
|
191
|
+
onToggleFolder: (paths: string[]) => void;
|
|
192
|
+
onViewFile: (path: string) => void;
|
|
193
|
+
depth: number;
|
|
194
|
+
}) {
|
|
195
|
+
const folderKey = diffFileId(rootId, node.path);
|
|
196
|
+
const isExpanded = expandedFolders.has(folderKey);
|
|
197
|
+
const allPaths = getAllDiffFilePaths(node);
|
|
198
|
+
const allIds = allPaths.map((path) => diffFileId(rootId, path));
|
|
199
|
+
const selectedCount = allIds.filter((id) => selected.includes(id)).length;
|
|
200
|
+
const checkState: "none" | "some" | "all" =
|
|
201
|
+
selectedCount === 0
|
|
202
|
+
? "none"
|
|
203
|
+
: selectedCount === allIds.length
|
|
204
|
+
? "all"
|
|
205
|
+
: "some";
|
|
206
|
+
const CheckIcon =
|
|
207
|
+
checkState === "all"
|
|
208
|
+
? CheckSquare
|
|
209
|
+
: checkState === "some"
|
|
210
|
+
? MinusSquare
|
|
211
|
+
: Square;
|
|
212
|
+
const checkColor = checkState === "none" ? "text-slate-600" : "text-cyan-400";
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div>
|
|
216
|
+
<div
|
|
217
|
+
className="flex items-center gap-1 text-xs group rounded px-1 py-0.5"
|
|
218
|
+
style={{ paddingLeft: depth * 12 + 4 }}
|
|
219
|
+
>
|
|
220
|
+
<button
|
|
221
|
+
onClick={() => onToggleExpand(folderKey)}
|
|
222
|
+
className="shrink-0 text-slate-500 hover:text-slate-300"
|
|
223
|
+
>
|
|
224
|
+
{isExpanded ? (
|
|
225
|
+
<ChevronDown className="w-3 h-3" />
|
|
226
|
+
) : (
|
|
227
|
+
<ChevronRight className="w-3 h-3" />
|
|
228
|
+
)}
|
|
229
|
+
</button>
|
|
230
|
+
<button
|
|
231
|
+
onClick={() => onToggleFolder(allPaths)}
|
|
232
|
+
className={`shrink-0 ${checkColor} hover:text-cyan-300`}
|
|
233
|
+
title="Select folder changes"
|
|
234
|
+
>
|
|
235
|
+
<CheckIcon className="w-3.5 h-3.5" />
|
|
236
|
+
</button>
|
|
237
|
+
{isExpanded ? (
|
|
238
|
+
<FolderOpen className="w-3 h-3 text-amber-500/70 shrink-0" />
|
|
239
|
+
) : (
|
|
240
|
+
<Folder className="w-3 h-3 text-amber-500/70 shrink-0" />
|
|
241
|
+
)}
|
|
242
|
+
<button
|
|
243
|
+
onClick={() => onToggleExpand(folderKey)}
|
|
244
|
+
className="text-slate-400 hover:text-slate-200 truncate text-left flex-1"
|
|
245
|
+
title={node.path}
|
|
246
|
+
>
|
|
247
|
+
{node.name}
|
|
248
|
+
</button>
|
|
249
|
+
<span className="text-[10px] text-slate-600 shrink-0">
|
|
250
|
+
{selectedCount > 0 ? `${selectedCount}/` : ""}
|
|
251
|
+
{allIds.length}
|
|
252
|
+
</span>
|
|
253
|
+
</div>
|
|
254
|
+
{isExpanded && (
|
|
255
|
+
<div>
|
|
256
|
+
{node.children
|
|
257
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
258
|
+
.map((child) => (
|
|
259
|
+
<DiffFolderNode
|
|
260
|
+
key={child.path}
|
|
261
|
+
node={child}
|
|
262
|
+
rootId={rootId}
|
|
263
|
+
selected={selected}
|
|
264
|
+
expandedFolders={expandedFolders}
|
|
265
|
+
onToggleExpand={onToggleExpand}
|
|
266
|
+
onToggleFile={onToggleFile}
|
|
267
|
+
onToggleFolder={onToggleFolder}
|
|
268
|
+
onViewFile={onViewFile}
|
|
269
|
+
depth={depth + 1}
|
|
270
|
+
/>
|
|
271
|
+
))}
|
|
272
|
+
{node.files
|
|
273
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
274
|
+
.map((file) => (
|
|
275
|
+
<DiffFileRow
|
|
276
|
+
key={file.path}
|
|
277
|
+
file={file}
|
|
278
|
+
rootId={rootId}
|
|
279
|
+
selected={selected}
|
|
280
|
+
onToggleFile={onToggleFile}
|
|
281
|
+
onViewFile={onViewFile}
|
|
282
|
+
depth={depth + 1}
|
|
283
|
+
/>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
33
291
|
export default function GitDiffPanel() {
|
|
34
292
|
const { currentQuestion, updateGitDiffContext } = useStore();
|
|
35
293
|
const existing = currentQuestion?.gitDiffContext;
|
|
36
294
|
|
|
37
295
|
const [expanded, setExpanded] = useState<boolean>(Boolean(existing));
|
|
38
296
|
const [branches, setBranches] = useState<GitBranchesResponse | null>(null);
|
|
297
|
+
const [rootId, setRootId] = useState<string>(existing?.rootId || "");
|
|
39
298
|
const [baseRef, setBaseRef] = useState<string>(existing?.baseRef || "");
|
|
40
299
|
const [headRef, setHeadRef] = useState<string>(existing?.headRef || "");
|
|
41
300
|
const [mode, setMode] = useState<GitDiffMode>(existing?.mode || "three-dot");
|
|
@@ -43,17 +302,25 @@ export default function GitDiffPanel() {
|
|
|
43
302
|
const [loadingTree, setLoadingTree] = useState(false);
|
|
44
303
|
const [error, setError] = useState<string | null>(null);
|
|
45
304
|
const [selected, setSelected] = useState<string[]>(
|
|
46
|
-
existing?.selectedFiles || [],
|
|
305
|
+
normalizeDiffSelection(existing?.selectedFiles || [], existing?.rootId),
|
|
306
|
+
);
|
|
307
|
+
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
|
308
|
+
() => new Set(),
|
|
47
309
|
);
|
|
48
310
|
const [viewingFile, setViewingFile] = useState<string | null>(null);
|
|
49
311
|
|
|
50
312
|
// Reload selection when switching questions.
|
|
51
313
|
useEffect(() => {
|
|
314
|
+
setRootId(existing?.rootId || "");
|
|
52
315
|
setBaseRef(existing?.baseRef || "");
|
|
53
316
|
setHeadRef(existing?.headRef || "");
|
|
54
317
|
setMode(existing?.mode || "three-dot");
|
|
55
|
-
setSelected(
|
|
318
|
+
setSelected(
|
|
319
|
+
normalizeDiffSelection(existing?.selectedFiles || [], existing?.rootId),
|
|
320
|
+
);
|
|
321
|
+
setBranches(null);
|
|
56
322
|
setTree(null);
|
|
323
|
+
setExpandedFolders(new Set());
|
|
57
324
|
}, [currentQuestion?.id]);
|
|
58
325
|
|
|
59
326
|
const applyBranchDefaults = useCallback(
|
|
@@ -84,20 +351,17 @@ export default function GitDiffPanel() {
|
|
|
84
351
|
[selected.length],
|
|
85
352
|
);
|
|
86
353
|
|
|
87
|
-
// Lazy-load branches the
|
|
354
|
+
// Lazy-load branches for the active diff source root.
|
|
88
355
|
useEffect(() => {
|
|
89
|
-
if (!expanded) return;
|
|
90
|
-
|
|
91
|
-
applyBranchDefaults(branches);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
fetchGitBranches()
|
|
356
|
+
if (!expanded || !currentQuestion) return;
|
|
357
|
+
fetchGitBranches({ rootId: rootId || undefined })
|
|
95
358
|
.then((b) => {
|
|
96
359
|
setBranches(b);
|
|
360
|
+
setRootId((prev) => prev || b.rootId || b.roots?.[0]?.id || "");
|
|
97
361
|
applyBranchDefaults(b);
|
|
98
362
|
})
|
|
99
363
|
.catch((e) => setError(e?.message || "Failed to load branches"));
|
|
100
|
-
}, [expanded,
|
|
364
|
+
}, [expanded, currentQuestion?.id, rootId]);
|
|
101
365
|
|
|
102
366
|
const loadTree = useCallback(async () => {
|
|
103
367
|
if (!baseRef) return;
|
|
@@ -105,18 +369,22 @@ export default function GitDiffPanel() {
|
|
|
105
369
|
setError(null);
|
|
106
370
|
try {
|
|
107
371
|
const data = await fetchGitDiffTree({
|
|
372
|
+
rootId: rootId || branches?.rootId,
|
|
108
373
|
base: baseRef,
|
|
109
374
|
head: mode === "working-tree" ? "" : headRef,
|
|
110
375
|
mode,
|
|
111
376
|
});
|
|
112
377
|
setTree(data);
|
|
378
|
+
const nextRootId = data.rootId || rootId || branches?.rootId;
|
|
379
|
+
setSelected((prev) => normalizeDiffSelection(prev, nextRootId));
|
|
380
|
+
setExpandedFolders(new Set());
|
|
113
381
|
} catch (e: any) {
|
|
114
382
|
setTree(null);
|
|
115
383
|
setError(e?.message || "Failed to load diff");
|
|
116
384
|
} finally {
|
|
117
385
|
setLoadingTree(false);
|
|
118
386
|
}
|
|
119
|
-
}, [baseRef, headRef, mode]);
|
|
387
|
+
}, [rootId, branches?.rootId, baseRef, headRef, mode]);
|
|
120
388
|
|
|
121
389
|
// Auto-load when refs/mode change and the section is open.
|
|
122
390
|
useEffect(() => {
|
|
@@ -124,12 +392,15 @@ export default function GitDiffPanel() {
|
|
|
124
392
|
if (!baseRef) return;
|
|
125
393
|
if (mode !== "working-tree" && !headRef) return;
|
|
126
394
|
loadTree();
|
|
127
|
-
}, [expanded, baseRef, headRef, mode]);
|
|
395
|
+
}, [expanded, baseRef, headRef, mode, loadTree]);
|
|
396
|
+
|
|
397
|
+
const activeRootId = tree?.rootId || rootId || branches?.rootId || "";
|
|
128
398
|
|
|
129
399
|
const persist = useCallback(
|
|
130
400
|
(next: Partial<GitDiffContext>) => {
|
|
131
401
|
if (!currentQuestion) return;
|
|
132
402
|
const ctx: GitDiffContext = {
|
|
403
|
+
rootId: activeRootId,
|
|
133
404
|
baseRef,
|
|
134
405
|
headRef: mode === "working-tree" ? "" : headRef,
|
|
135
406
|
mode,
|
|
@@ -143,20 +414,68 @@ export default function GitDiffPanel() {
|
|
|
143
414
|
updateGitDiffContext(currentQuestion.id, ctx);
|
|
144
415
|
}
|
|
145
416
|
},
|
|
146
|
-
[
|
|
417
|
+
[
|
|
418
|
+
currentQuestion,
|
|
419
|
+
activeRootId,
|
|
420
|
+
baseRef,
|
|
421
|
+
headRef,
|
|
422
|
+
mode,
|
|
423
|
+
selected,
|
|
424
|
+
updateGitDiffContext,
|
|
425
|
+
],
|
|
147
426
|
);
|
|
148
427
|
|
|
149
428
|
const toggleFile = useCallback(
|
|
150
429
|
(filePath: string) => {
|
|
430
|
+
const id = diffFileId(activeRootId, filePath);
|
|
431
|
+
setSelected((prev) => {
|
|
432
|
+
const isSelected = prev.includes(id) || prev.includes(filePath);
|
|
433
|
+
const next = isSelected
|
|
434
|
+
? prev.filter((f) => f !== id && f !== filePath)
|
|
435
|
+
: [...prev, id];
|
|
436
|
+
persist({ rootId: activeRootId, selectedFiles: next });
|
|
437
|
+
return next;
|
|
438
|
+
});
|
|
439
|
+
},
|
|
440
|
+
[activeRootId, persist],
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const toggleFolder = useCallback(
|
|
444
|
+
(paths: string[]) => {
|
|
445
|
+
const ids = paths.map((path) => diffFileId(activeRootId, path));
|
|
151
446
|
setSelected((prev) => {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
447
|
+
const allSelected = ids.every((id) => prev.includes(id));
|
|
448
|
+
const next = allSelected
|
|
449
|
+
? prev.filter((file) => !ids.includes(file) && !paths.includes(file))
|
|
450
|
+
: Array.from(new Set([...prev, ...ids]));
|
|
451
|
+
persist({ rootId: activeRootId, selectedFiles: next });
|
|
156
452
|
return next;
|
|
157
453
|
});
|
|
158
454
|
},
|
|
159
|
-
[persist],
|
|
455
|
+
[activeRootId, persist],
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const toggleExpand = useCallback((path: string) => {
|
|
459
|
+
setExpandedFolders((prev) => {
|
|
460
|
+
const next = new Set(prev);
|
|
461
|
+
if (next.has(path)) next.delete(path);
|
|
462
|
+
else next.add(path);
|
|
463
|
+
return next;
|
|
464
|
+
});
|
|
465
|
+
}, []);
|
|
466
|
+
|
|
467
|
+
const changeRoot = useCallback(
|
|
468
|
+
(nextRootId: string) => {
|
|
469
|
+
setRootId(nextRootId);
|
|
470
|
+
setBranches(null);
|
|
471
|
+
setBaseRef("");
|
|
472
|
+
setHeadRef("");
|
|
473
|
+
setTree(null);
|
|
474
|
+
setSelected([]);
|
|
475
|
+
setExpandedFolders(new Set());
|
|
476
|
+
if (currentQuestion) updateGitDiffContext(currentQuestion.id, null);
|
|
477
|
+
},
|
|
478
|
+
[currentQuestion, updateGitDiffContext],
|
|
160
479
|
);
|
|
161
480
|
|
|
162
481
|
const clearSelection = useCallback(() => {
|
|
@@ -166,6 +485,8 @@ export default function GitDiffPanel() {
|
|
|
166
485
|
|
|
167
486
|
const branchOptions = branches?.branches ?? [];
|
|
168
487
|
const tagOptions = branches?.tags ?? [];
|
|
488
|
+
const rootOptions = branches?.roots ?? [];
|
|
489
|
+
const diffTree = tree ? buildDiffTree(tree.files) : null;
|
|
169
490
|
|
|
170
491
|
return (
|
|
171
492
|
<div className="border-t border-slate-800">
|
|
@@ -192,10 +513,29 @@ export default function GitDiffPanel() {
|
|
|
192
513
|
</p>
|
|
193
514
|
)}
|
|
194
515
|
|
|
516
|
+
{currentQuestion && rootOptions.length > 1 && (
|
|
517
|
+
<div className="space-y-1">
|
|
518
|
+
<label className="block text-[10px] uppercase tracking-wider text-slate-600">
|
|
519
|
+
Source
|
|
520
|
+
</label>
|
|
521
|
+
<select
|
|
522
|
+
value={activeRootId}
|
|
523
|
+
onChange={(e) => changeRoot(e.target.value)}
|
|
524
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
|
525
|
+
>
|
|
526
|
+
{rootOptions.map((root) => (
|
|
527
|
+
<option key={root.id} value={root.id}>
|
|
528
|
+
{root.name}
|
|
529
|
+
</option>
|
|
530
|
+
))}
|
|
531
|
+
</select>
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
|
|
195
535
|
{currentQuestion && branches && !branches.enabled && (
|
|
196
536
|
<p className="text-[10px] text-slate-600 italic">
|
|
197
|
-
Set <code>GIT_DIFF_DIR</code
|
|
198
|
-
|
|
537
|
+
Set <code>GIT_DIFF_DIR</code>, <code>GIT_DIFF_DIRS</code>, or a
|
|
538
|
+
git-backed <code>CODE_CONTEXT_DIR</code> to enable diffs.
|
|
199
539
|
{branches.error ? ` (${branches.error})` : ""}
|
|
200
540
|
</p>
|
|
201
541
|
)}
|
|
@@ -272,64 +612,53 @@ export default function GitDiffPanel() {
|
|
|
272
612
|
|
|
273
613
|
{tree && (
|
|
274
614
|
<div className="space-y-0.5 max-h-72 overflow-y-auto border-t border-slate-800 pt-2">
|
|
615
|
+
{tree.rootName && (
|
|
616
|
+
<div className="text-[10px] text-slate-600 px-1 pb-1 truncate">
|
|
617
|
+
{tree.rootName} · {tree.base}{" "}
|
|
618
|
+
{tree.mode === "two-dot"
|
|
619
|
+
? ".."
|
|
620
|
+
: tree.mode === "working-tree"
|
|
621
|
+
? "→"
|
|
622
|
+
: "..."}{" "}
|
|
623
|
+
{tree.mode === "working-tree"
|
|
624
|
+
? "working tree"
|
|
625
|
+
: tree.head}
|
|
626
|
+
</div>
|
|
627
|
+
)}
|
|
275
628
|
{tree.files.length === 0 && (
|
|
276
629
|
<p className="text-[10px] text-slate-600 italic">
|
|
277
630
|
No changes between these refs.
|
|
278
631
|
</p>
|
|
279
632
|
)}
|
|
280
|
-
{
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
} hover:text-slate-200`}
|
|
310
|
-
title={
|
|
311
|
-
f.oldPath ? `${f.oldPath} → ${f.path}` : f.path
|
|
312
|
-
}
|
|
313
|
-
>
|
|
314
|
-
{f.path}
|
|
315
|
-
</button>
|
|
316
|
-
<span
|
|
317
|
-
className={`shrink-0 text-[10px] font-mono ${
|
|
318
|
-
f.binary ? "text-slate-600" : "text-slate-500"
|
|
319
|
-
}`}
|
|
320
|
-
>
|
|
321
|
-
{f.binary ? "bin" : `+${f.additions}/-${f.deletions}`}
|
|
322
|
-
</span>
|
|
323
|
-
<button
|
|
324
|
-
onClick={() => setViewingFile(f.path)}
|
|
325
|
-
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-500 hover:text-cyan-400 transition-all"
|
|
326
|
-
title="View diff"
|
|
327
|
-
>
|
|
328
|
-
<Eye className="w-3 h-3" />
|
|
329
|
-
</button>
|
|
330
|
-
</div>
|
|
331
|
-
);
|
|
332
|
-
})}
|
|
633
|
+
{diffTree?.children
|
|
634
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
635
|
+
.map((child) => (
|
|
636
|
+
<DiffFolderNode
|
|
637
|
+
key={child.path}
|
|
638
|
+
node={child}
|
|
639
|
+
rootId={tree.rootId || activeRootId}
|
|
640
|
+
selected={selected}
|
|
641
|
+
expandedFolders={expandedFolders}
|
|
642
|
+
onToggleExpand={toggleExpand}
|
|
643
|
+
onToggleFile={toggleFile}
|
|
644
|
+
onToggleFolder={toggleFolder}
|
|
645
|
+
onViewFile={setViewingFile}
|
|
646
|
+
depth={0}
|
|
647
|
+
/>
|
|
648
|
+
))}
|
|
649
|
+
{diffTree?.files
|
|
650
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
651
|
+
.map((file) => (
|
|
652
|
+
<DiffFileRow
|
|
653
|
+
key={file.path}
|
|
654
|
+
file={file}
|
|
655
|
+
rootId={tree.rootId || activeRootId}
|
|
656
|
+
selected={selected}
|
|
657
|
+
onToggleFile={toggleFile}
|
|
658
|
+
onViewFile={setViewingFile}
|
|
659
|
+
depth={0}
|
|
660
|
+
/>
|
|
661
|
+
))}
|
|
333
662
|
{tree.truncated && (
|
|
334
663
|
<p className="text-[10px] text-amber-400/70 mt-1">
|
|
335
664
|
Truncated — too many changed files.
|
|
@@ -345,6 +674,7 @@ export default function GitDiffPanel() {
|
|
|
345
674
|
{viewingFile && baseRef && (mode === "working-tree" || headRef) && (
|
|
346
675
|
<GitDiffViewerModal
|
|
347
676
|
ctx={{
|
|
677
|
+
rootId: activeRootId,
|
|
348
678
|
baseRef,
|
|
349
679
|
headRef: mode === "working-tree" ? "" : headRef,
|
|
350
680
|
mode,
|
|
@@ -25,6 +25,7 @@ export default function GitDiffViewerModal({ ctx, filePath, onClose }: Props) {
|
|
|
25
25
|
setLoading(true);
|
|
26
26
|
setError(null);
|
|
27
27
|
fetchGitDiffFile({
|
|
28
|
+
rootId: ctx.rootId,
|
|
28
29
|
base: ctx.baseRef,
|
|
29
30
|
head: ctx.headRef,
|
|
30
31
|
mode: ctx.mode,
|
|
@@ -43,7 +44,7 @@ export default function GitDiffViewerModal({ ctx, filePath, onClose }: Props) {
|
|
|
43
44
|
return () => {
|
|
44
45
|
cancelled = true;
|
|
45
46
|
};
|
|
46
|
-
}, [ctx.baseRef, ctx.headRef, ctx.mode, filePath, view]);
|
|
47
|
+
}, [ctx.rootId, ctx.baseRef, ctx.headRef, ctx.mode, filePath, view]);
|
|
47
48
|
|
|
48
49
|
const language =
|
|
49
50
|
view === "patch"
|
|
@@ -489,7 +489,14 @@ export default function MarkdownRenderer({
|
|
|
489
489
|
);
|
|
490
490
|
}
|
|
491
491
|
if (href?.startsWith("coderef://")) {
|
|
492
|
-
const
|
|
492
|
+
const rawFilePath = href.slice("coderef://".length);
|
|
493
|
+
const filePath = (() => {
|
|
494
|
+
try {
|
|
495
|
+
return decodeURIComponent(rawFilePath);
|
|
496
|
+
} catch {
|
|
497
|
+
return rawFilePath;
|
|
498
|
+
}
|
|
499
|
+
})();
|
|
493
500
|
return (
|
|
494
501
|
<button
|
|
495
502
|
type="button"
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
FrontendLabWorkspace,
|
|
9
9
|
GithubActionsLabWorkspace,
|
|
10
10
|
ContextFileOrigin,
|
|
11
|
+
CodeContextRoot,
|
|
11
12
|
} from "./types";
|
|
12
13
|
import type { AiSettings } from "./api";
|
|
13
14
|
import * as api from "./api";
|
|
@@ -99,6 +100,12 @@ const DEFAULT_AI_SETTINGS: AiSettings = {
|
|
|
99
100
|
},
|
|
100
101
|
};
|
|
101
102
|
|
|
103
|
+
const CODE_CONTEXT_ROOT_SEPARATOR = "::";
|
|
104
|
+
|
|
105
|
+
function codeContextFileId(rootId: string, filePath: string): string {
|
|
106
|
+
return `${rootId}${CODE_CONTEXT_ROOT_SEPARATOR}${filePath}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
102
109
|
interface Store {
|
|
103
110
|
topics: Topic[];
|
|
104
111
|
questionsByTopic: Record<string, Question[]>;
|
|
@@ -106,6 +113,7 @@ interface Store {
|
|
|
106
113
|
selectedQuestionId: string | null;
|
|
107
114
|
currentQuestion: Question | null;
|
|
108
115
|
expandedTopics: string[];
|
|
116
|
+
codeContextRoots: CodeContextRoot[];
|
|
109
117
|
availableFiles: string[];
|
|
110
118
|
showCodePanel: boolean;
|
|
111
119
|
showLabsPanel: boolean;
|
|
@@ -388,6 +396,7 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
388
396
|
selectedQuestionId: null,
|
|
389
397
|
currentQuestion: null,
|
|
390
398
|
expandedTopics: [],
|
|
399
|
+
codeContextRoots: [],
|
|
391
400
|
availableFiles: [],
|
|
392
401
|
showCodePanel: false,
|
|
393
402
|
showLabsPanel: false,
|
|
@@ -821,8 +830,14 @@ export const useStore = create<Store>((set, get) => ({
|
|
|
821
830
|
},
|
|
822
831
|
|
|
823
832
|
fetchAvailableFiles: async () => {
|
|
824
|
-
const
|
|
825
|
-
|
|
833
|
+
const tree = await api.fetchCodeContextTree();
|
|
834
|
+
const roots = tree.roots || [];
|
|
835
|
+
set({
|
|
836
|
+
codeContextRoots: roots,
|
|
837
|
+
availableFiles: roots.flatMap((root) =>
|
|
838
|
+
root.files.map((file) => codeContextFileId(root.id, file)),
|
|
839
|
+
),
|
|
840
|
+
});
|
|
826
841
|
},
|
|
827
842
|
|
|
828
843
|
updateCodeContext: async (questionId, files) => {
|