create-interview-cockpit 0.20.0 → 0.22.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 +67 -0
- package/template/client/src/components/ChatView.tsx +2 -0
- package/template/client/src/components/CodeContextPanel.tsx +28 -1
- package/template/client/src/components/FileViewerModal.tsx +1 -0
- package/template/client/src/components/GitDiffPanel.tsx +403 -0
- package/template/client/src/components/GitDiffViewerModal.tsx +124 -0
- package/template/client/src/store.ts +14 -0
- package/template/client/src/types.ts +23 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +624 -0
- package/template/server/src/storage.ts +12 -0
package/package.json
CHANGED
|
@@ -743,6 +743,73 @@ export async function fetchCodeContextTree(): Promise<string[]> {
|
|
|
743
743
|
return res.json();
|
|
744
744
|
}
|
|
745
745
|
|
|
746
|
+
// --- Git Diff Context ---
|
|
747
|
+
|
|
748
|
+
export interface GitBranchesResponse {
|
|
749
|
+
enabled: boolean;
|
|
750
|
+
head?: string;
|
|
751
|
+
defaultBranch?: string;
|
|
752
|
+
branches: string[];
|
|
753
|
+
tags?: string[];
|
|
754
|
+
error?: string;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export interface GitDiffTreeResponse {
|
|
758
|
+
base: string;
|
|
759
|
+
baseSha?: string;
|
|
760
|
+
head: string;
|
|
761
|
+
headSha?: string | null;
|
|
762
|
+
mode: "two-dot" | "three-dot" | "working-tree";
|
|
763
|
+
truncated: boolean;
|
|
764
|
+
files: Array<{
|
|
765
|
+
path: string;
|
|
766
|
+
oldPath?: string;
|
|
767
|
+
status: "A" | "M" | "D" | "R" | "C" | "T" | "U" | "?";
|
|
768
|
+
additions: number;
|
|
769
|
+
deletions: number;
|
|
770
|
+
binary: boolean;
|
|
771
|
+
}>;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export async function fetchGitBranches(): Promise<GitBranchesResponse> {
|
|
775
|
+
const res = await fetch(`${BASE}/code-context/git/branches`);
|
|
776
|
+
return res.json();
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export async function fetchGitDiffTree(params: {
|
|
780
|
+
base: string;
|
|
781
|
+
head: string;
|
|
782
|
+
mode: "two-dot" | "three-dot" | "working-tree";
|
|
783
|
+
}): Promise<GitDiffTreeResponse> {
|
|
784
|
+
const qs = new URLSearchParams({
|
|
785
|
+
base: params.base,
|
|
786
|
+
head: params.head,
|
|
787
|
+
mode: params.mode,
|
|
788
|
+
});
|
|
789
|
+
const res = await fetch(`${BASE}/code-context/git/diff-tree?${qs}`);
|
|
790
|
+
if (!res.ok) throw new Error((await res.json()).error || "diff-tree failed");
|
|
791
|
+
return res.json();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export async function fetchGitDiffFile(params: {
|
|
795
|
+
base: string;
|
|
796
|
+
head: string;
|
|
797
|
+
mode: "two-dot" | "three-dot" | "working-tree";
|
|
798
|
+
path: string;
|
|
799
|
+
view: "patch" | "before" | "after";
|
|
800
|
+
}): Promise<{ path: string; view: string; content: string }> {
|
|
801
|
+
const qs = new URLSearchParams({
|
|
802
|
+
base: params.base,
|
|
803
|
+
head: params.head,
|
|
804
|
+
mode: params.mode,
|
|
805
|
+
path: params.path,
|
|
806
|
+
view: params.view,
|
|
807
|
+
});
|
|
808
|
+
const res = await fetch(`${BASE}/code-context/git/diff-file?${qs}`);
|
|
809
|
+
if (!res.ok) throw new Error((await res.json()).error || "diff-file failed");
|
|
810
|
+
return res.json();
|
|
811
|
+
}
|
|
812
|
+
|
|
746
813
|
// --- Workspaces ---
|
|
747
814
|
|
|
748
815
|
export async function fetchWorkspaces(): Promise<WorkspacesRegistry> {
|
|
@@ -411,6 +411,7 @@ export default function ChatView({ question }: Props) {
|
|
|
411
411
|
topicTitle: "",
|
|
412
412
|
questionTitle: question.title,
|
|
413
413
|
codeContextFiles: question.codeContextFiles,
|
|
414
|
+
gitDiffContext: question.gitDiffContext,
|
|
414
415
|
systemContext,
|
|
415
416
|
responseLength: "normal" as string,
|
|
416
417
|
groupSelections,
|
|
@@ -429,6 +430,7 @@ export default function ChatView({ question }: Props) {
|
|
|
429
430
|
topicTitle: currentTopic?.name || "",
|
|
430
431
|
questionTitle: question.title,
|
|
431
432
|
codeContextFiles: question.codeContextFiles,
|
|
433
|
+
gitDiffContext: question.gitDiffContext,
|
|
432
434
|
systemContext,
|
|
433
435
|
responseLength: groupSelections["length"] ?? "normal",
|
|
434
436
|
groupSelections,
|
|
@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from "react";
|
|
|
2
2
|
import { useStore } from "../store";
|
|
3
3
|
import FileViewerModal from "./FileViewerModal";
|
|
4
4
|
import NotesModal, { notesKey } from "./NotesModal";
|
|
5
|
+
import GitDiffPanel from "./GitDiffPanel";
|
|
5
6
|
import {
|
|
6
7
|
File,
|
|
7
8
|
Search,
|
|
@@ -294,6 +295,13 @@ export default function CodeContextPanel() {
|
|
|
294
295
|
[currentQuestion, updateCodeContext],
|
|
295
296
|
);
|
|
296
297
|
|
|
298
|
+
const selectAllFiles = useCallback(() => {
|
|
299
|
+
if (!currentQuestion || availableFiles.length === 0) return;
|
|
300
|
+
const next = [...availableFiles];
|
|
301
|
+
setSelectedFiles(next);
|
|
302
|
+
updateCodeContext(currentQuestion.id, next);
|
|
303
|
+
}, [availableFiles, currentQuestion, updateCodeContext]);
|
|
304
|
+
|
|
297
305
|
const toggleExpand = useCallback((path: string) => {
|
|
298
306
|
setExpandedFolders((prev) => {
|
|
299
307
|
const next = new Set(prev);
|
|
@@ -307,6 +315,10 @@ export default function CodeContextPanel() {
|
|
|
307
315
|
}, []);
|
|
308
316
|
|
|
309
317
|
const filter = search.toLowerCase();
|
|
318
|
+
const selectedSet = new Set(selectedFiles);
|
|
319
|
+
const allFilesSelected =
|
|
320
|
+
availableFiles.length > 0 &&
|
|
321
|
+
availableFiles.every((filePath) => selectedSet.has(filePath));
|
|
310
322
|
|
|
311
323
|
return (
|
|
312
324
|
<div className="w-72 h-full min-h-0 border-l border-slate-800 flex flex-col bg-slate-900/30 shrink-0 overflow-hidden">
|
|
@@ -316,7 +328,19 @@ export default function CodeContextPanel() {
|
|
|
316
328
|
<span className="text-xs font-bold uppercase tracking-wider text-slate-500">
|
|
317
329
|
Code Context
|
|
318
330
|
</span>
|
|
319
|
-
<
|
|
331
|
+
<div className="flex items-center gap-2">
|
|
332
|
+
{currentQuestion && availableFiles.length > 0 && (
|
|
333
|
+
<button
|
|
334
|
+
onClick={selectAllFiles}
|
|
335
|
+
disabled={allFilesSelected}
|
|
336
|
+
className="text-[10px] text-cyan-400/70 hover:text-cyan-300 disabled:text-slate-600 disabled:cursor-not-allowed"
|
|
337
|
+
title="Select all code-context files"
|
|
338
|
+
>
|
|
339
|
+
Select all
|
|
340
|
+
</button>
|
|
341
|
+
)}
|
|
342
|
+
<FolderOpen className="w-3.5 h-3.5 text-slate-600" />
|
|
343
|
+
</div>
|
|
320
344
|
</div>
|
|
321
345
|
<div className="relative">
|
|
322
346
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-600" />
|
|
@@ -635,6 +659,9 @@ export default function CodeContextPanel() {
|
|
|
635
659
|
</div>
|
|
636
660
|
)}
|
|
637
661
|
|
|
662
|
+
{/* ── Git Diff section ───────────────────────────────── */}
|
|
663
|
+
<GitDiffPanel />
|
|
664
|
+
|
|
638
665
|
{/* ── Notes section ────────────────────────────────────── */}
|
|
639
666
|
<div className="border-t border-slate-800 px-3 py-2">
|
|
640
667
|
<button
|
|
@@ -320,6 +320,7 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
|
|
|
320
320
|
questionId: currentQuestion?.id,
|
|
321
321
|
topicId: currentQuestion?.topicId ?? selectedTopicId,
|
|
322
322
|
codeContextFiles: currentQuestion?.codeContextFiles,
|
|
323
|
+
gitDiffContext: currentQuestion?.gitDiffContext,
|
|
323
324
|
codeSnippets,
|
|
324
325
|
preferenceSuffix: livePreferenceSuffix,
|
|
325
326
|
}),
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
GitBranch,
|
|
4
|
+
RefreshCcw,
|
|
5
|
+
Eye,
|
|
6
|
+
X,
|
|
7
|
+
Check,
|
|
8
|
+
ChevronDown,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { useStore } from "../store";
|
|
12
|
+
import {
|
|
13
|
+
fetchGitBranches,
|
|
14
|
+
fetchGitDiffTree,
|
|
15
|
+
type GitBranchesResponse,
|
|
16
|
+
type GitDiffTreeResponse,
|
|
17
|
+
} from "../api";
|
|
18
|
+
import type { GitDiffContext, GitDiffMode } from "../types";
|
|
19
|
+
import GitDiffViewerModal from "./GitDiffViewerModal";
|
|
20
|
+
|
|
21
|
+
// Status letter → color/label for the changed-files list.
|
|
22
|
+
const STATUS_META: Record<string, { color: string; label: string }> = {
|
|
23
|
+
A: { color: "text-emerald-400", label: "added" },
|
|
24
|
+
M: { color: "text-amber-400", label: "modified" },
|
|
25
|
+
D: { color: "text-red-400", label: "deleted" },
|
|
26
|
+
R: { color: "text-sky-400", label: "renamed" },
|
|
27
|
+
C: { color: "text-sky-400", label: "copied" },
|
|
28
|
+
T: { color: "text-violet-400", label: "type" },
|
|
29
|
+
U: { color: "text-orange-400", label: "unmerged" },
|
|
30
|
+
"?": { color: "text-slate-400", label: "untracked" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default function GitDiffPanel() {
|
|
34
|
+
const { currentQuestion, updateGitDiffContext } = useStore();
|
|
35
|
+
const existing = currentQuestion?.gitDiffContext;
|
|
36
|
+
|
|
37
|
+
const [expanded, setExpanded] = useState<boolean>(Boolean(existing));
|
|
38
|
+
const [branches, setBranches] = useState<GitBranchesResponse | null>(null);
|
|
39
|
+
const [baseRef, setBaseRef] = useState<string>(existing?.baseRef || "");
|
|
40
|
+
const [headRef, setHeadRef] = useState<string>(existing?.headRef || "");
|
|
41
|
+
const [mode, setMode] = useState<GitDiffMode>(existing?.mode || "three-dot");
|
|
42
|
+
const [tree, setTree] = useState<GitDiffTreeResponse | null>(null);
|
|
43
|
+
const [loadingTree, setLoadingTree] = useState(false);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
const [selected, setSelected] = useState<string[]>(
|
|
46
|
+
existing?.selectedFiles || [],
|
|
47
|
+
);
|
|
48
|
+
const [viewingFile, setViewingFile] = useState<string | null>(null);
|
|
49
|
+
|
|
50
|
+
// Reload selection when switching questions.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
setBaseRef(existing?.baseRef || "");
|
|
53
|
+
setHeadRef(existing?.headRef || "");
|
|
54
|
+
setMode(existing?.mode || "three-dot");
|
|
55
|
+
setSelected(existing?.selectedFiles || []);
|
|
56
|
+
setTree(null);
|
|
57
|
+
}, [currentQuestion?.id]);
|
|
58
|
+
|
|
59
|
+
const applyBranchDefaults = useCallback(
|
|
60
|
+
(b: GitBranchesResponse) => {
|
|
61
|
+
const currentHead = b.head || "";
|
|
62
|
+
const recommendedBase =
|
|
63
|
+
b.defaultBranch ||
|
|
64
|
+
b.branches.find((branch) => branch !== currentHead) ||
|
|
65
|
+
currentHead ||
|
|
66
|
+
b.branches[0] ||
|
|
67
|
+
"";
|
|
68
|
+
setHeadRef((prev) => prev || currentHead);
|
|
69
|
+
setBaseRef((prev) => {
|
|
70
|
+
// On first open (or after the old bug saved base=head with no files),
|
|
71
|
+
// prefer main/master/origin default as the base and keep current branch as head.
|
|
72
|
+
if (!prev) return recommendedBase;
|
|
73
|
+
if (
|
|
74
|
+
prev === currentHead &&
|
|
75
|
+
recommendedBase &&
|
|
76
|
+
recommendedBase !== currentHead &&
|
|
77
|
+
selected.length === 0
|
|
78
|
+
) {
|
|
79
|
+
return recommendedBase;
|
|
80
|
+
}
|
|
81
|
+
return prev;
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
[selected.length],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Lazy-load branches the first time the section is opened.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!expanded) return;
|
|
90
|
+
if (branches) {
|
|
91
|
+
applyBranchDefaults(branches);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
fetchGitBranches()
|
|
95
|
+
.then((b) => {
|
|
96
|
+
setBranches(b);
|
|
97
|
+
applyBranchDefaults(b);
|
|
98
|
+
})
|
|
99
|
+
.catch((e) => setError(e?.message || "Failed to load branches"));
|
|
100
|
+
}, [expanded, branches, applyBranchDefaults]);
|
|
101
|
+
|
|
102
|
+
const loadTree = useCallback(async () => {
|
|
103
|
+
if (!baseRef) return;
|
|
104
|
+
setLoadingTree(true);
|
|
105
|
+
setError(null);
|
|
106
|
+
try {
|
|
107
|
+
const data = await fetchGitDiffTree({
|
|
108
|
+
base: baseRef,
|
|
109
|
+
head: mode === "working-tree" ? "" : headRef,
|
|
110
|
+
mode,
|
|
111
|
+
});
|
|
112
|
+
setTree(data);
|
|
113
|
+
} catch (e: any) {
|
|
114
|
+
setTree(null);
|
|
115
|
+
setError(e?.message || "Failed to load diff");
|
|
116
|
+
} finally {
|
|
117
|
+
setLoadingTree(false);
|
|
118
|
+
}
|
|
119
|
+
}, [baseRef, headRef, mode]);
|
|
120
|
+
|
|
121
|
+
// Auto-load when refs/mode change and the section is open.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!expanded) return;
|
|
124
|
+
if (!baseRef) return;
|
|
125
|
+
if (mode !== "working-tree" && !headRef) return;
|
|
126
|
+
loadTree();
|
|
127
|
+
}, [expanded, baseRef, headRef, mode]);
|
|
128
|
+
|
|
129
|
+
const persist = useCallback(
|
|
130
|
+
(next: Partial<GitDiffContext>) => {
|
|
131
|
+
if (!currentQuestion) return;
|
|
132
|
+
const ctx: GitDiffContext = {
|
|
133
|
+
baseRef,
|
|
134
|
+
headRef: mode === "working-tree" ? "" : headRef,
|
|
135
|
+
mode,
|
|
136
|
+
selectedFiles: selected,
|
|
137
|
+
...next,
|
|
138
|
+
};
|
|
139
|
+
// Treat empty selection as "clear" so we don't waste prompt tokens.
|
|
140
|
+
if (!ctx.baseRef || ctx.selectedFiles.length === 0) {
|
|
141
|
+
updateGitDiffContext(currentQuestion.id, null);
|
|
142
|
+
} else {
|
|
143
|
+
updateGitDiffContext(currentQuestion.id, ctx);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[currentQuestion, baseRef, headRef, mode, selected, updateGitDiffContext],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const toggleFile = useCallback(
|
|
150
|
+
(filePath: string) => {
|
|
151
|
+
setSelected((prev) => {
|
|
152
|
+
const next = prev.includes(filePath)
|
|
153
|
+
? prev.filter((f) => f !== filePath)
|
|
154
|
+
: [...prev, filePath];
|
|
155
|
+
persist({ selectedFiles: next });
|
|
156
|
+
return next;
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
[persist],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const clearSelection = useCallback(() => {
|
|
163
|
+
setSelected([]);
|
|
164
|
+
if (currentQuestion) updateGitDiffContext(currentQuestion.id, null);
|
|
165
|
+
}, [currentQuestion, updateGitDiffContext]);
|
|
166
|
+
|
|
167
|
+
const branchOptions = branches?.branches ?? [];
|
|
168
|
+
const tagOptions = branches?.tags ?? [];
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div className="border-t border-slate-800">
|
|
172
|
+
<button
|
|
173
|
+
onClick={() => setExpanded((v) => !v)}
|
|
174
|
+
className="w-full flex items-center gap-2 px-3 py-2 group"
|
|
175
|
+
>
|
|
176
|
+
{expanded ? (
|
|
177
|
+
<ChevronDown className="w-3 h-3 text-slate-600" />
|
|
178
|
+
) : (
|
|
179
|
+
<ChevronRight className="w-3 h-3 text-slate-600" />
|
|
180
|
+
)}
|
|
181
|
+
<GitBranch className="w-3 h-3 text-cyan-400/70 shrink-0" />
|
|
182
|
+
<span className="text-[10px] uppercase tracking-wider text-slate-600 flex-1 text-left">
|
|
183
|
+
Git Diff{selected.length > 0 ? ` (${selected.length})` : ""}
|
|
184
|
+
</span>
|
|
185
|
+
</button>
|
|
186
|
+
|
|
187
|
+
{expanded && (
|
|
188
|
+
<div className="px-3 pb-3 space-y-2">
|
|
189
|
+
{!currentQuestion && (
|
|
190
|
+
<p className="text-[10px] text-slate-600 italic">
|
|
191
|
+
Select a question first
|
|
192
|
+
</p>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{currentQuestion && branches && !branches.enabled && (
|
|
196
|
+
<p className="text-[10px] text-slate-600 italic">
|
|
197
|
+
Set <code>GIT_DIFF_DIR</code> (or <code>CODE_CONTEXT_DIR</code>)
|
|
198
|
+
to a git repo to enable diffs.
|
|
199
|
+
{branches.error ? ` (${branches.error})` : ""}
|
|
200
|
+
</p>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{currentQuestion && branches?.enabled && (
|
|
204
|
+
<>
|
|
205
|
+
<div className="space-y-1">
|
|
206
|
+
<label className="block text-[10px] uppercase tracking-wider text-slate-600">
|
|
207
|
+
Mode
|
|
208
|
+
</label>
|
|
209
|
+
<select
|
|
210
|
+
value={mode}
|
|
211
|
+
onChange={(e) => setMode(e.target.value as GitDiffMode)}
|
|
212
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
|
213
|
+
>
|
|
214
|
+
<option value="three-dot">
|
|
215
|
+
three-dot (base...head, since common ancestor)
|
|
216
|
+
</option>
|
|
217
|
+
<option value="two-dot">two-dot (base..head)</option>
|
|
218
|
+
<option value="working-tree">working tree vs base</option>
|
|
219
|
+
</select>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<div className="space-y-1">
|
|
223
|
+
<label className="block text-[10px] uppercase tracking-wider text-slate-600">
|
|
224
|
+
Base
|
|
225
|
+
</label>
|
|
226
|
+
<RefInput
|
|
227
|
+
value={baseRef}
|
|
228
|
+
onChange={setBaseRef}
|
|
229
|
+
branches={branchOptions}
|
|
230
|
+
tags={tagOptions}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{mode !== "working-tree" && (
|
|
235
|
+
<div className="space-y-1">
|
|
236
|
+
<label className="block text-[10px] uppercase tracking-wider text-slate-600">
|
|
237
|
+
Head
|
|
238
|
+
</label>
|
|
239
|
+
<RefInput
|
|
240
|
+
value={headRef}
|
|
241
|
+
onChange={setHeadRef}
|
|
242
|
+
branches={branchOptions}
|
|
243
|
+
tags={tagOptions}
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
|
|
248
|
+
<div className="flex items-center gap-2">
|
|
249
|
+
<button
|
|
250
|
+
onClick={loadTree}
|
|
251
|
+
disabled={
|
|
252
|
+
loadingTree ||
|
|
253
|
+
!baseRef ||
|
|
254
|
+
(mode !== "working-tree" && !headRef)
|
|
255
|
+
}
|
|
256
|
+
className="flex items-center gap-1 px-2 py-1 text-xs bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 rounded hover:bg-cyan-500/20 disabled:opacity-40"
|
|
257
|
+
>
|
|
258
|
+
<RefreshCcw className="w-3 h-3" />
|
|
259
|
+
{loadingTree ? "Loading…" : "Load diff"}
|
|
260
|
+
</button>
|
|
261
|
+
{selected.length > 0 && (
|
|
262
|
+
<button
|
|
263
|
+
onClick={clearSelection}
|
|
264
|
+
className="text-[10px] text-red-400/60 hover:text-red-400"
|
|
265
|
+
>
|
|
266
|
+
Clear selection
|
|
267
|
+
</button>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{error && <p className="text-[10px] text-red-400">{error}</p>}
|
|
272
|
+
|
|
273
|
+
{tree && (
|
|
274
|
+
<div className="space-y-0.5 max-h-72 overflow-y-auto border-t border-slate-800 pt-2">
|
|
275
|
+
{tree.files.length === 0 && (
|
|
276
|
+
<p className="text-[10px] text-slate-600 italic">
|
|
277
|
+
No changes between these refs.
|
|
278
|
+
</p>
|
|
279
|
+
)}
|
|
280
|
+
{tree.files.map((f) => {
|
|
281
|
+
const meta = STATUS_META[f.status] ?? STATUS_META["M"];
|
|
282
|
+
const isSelected = selected.includes(f.path);
|
|
283
|
+
return (
|
|
284
|
+
<div
|
|
285
|
+
key={f.path}
|
|
286
|
+
className={`flex items-center gap-1 text-xs group rounded px-1 py-0.5 ${
|
|
287
|
+
isSelected ? "bg-cyan-500/10" : ""
|
|
288
|
+
}`}
|
|
289
|
+
>
|
|
290
|
+
<button
|
|
291
|
+
onClick={() => toggleFile(f.path)}
|
|
292
|
+
className="shrink-0"
|
|
293
|
+
title={meta.label}
|
|
294
|
+
>
|
|
295
|
+
{isSelected ? (
|
|
296
|
+
<Check className="w-3 h-3 text-cyan-400" />
|
|
297
|
+
) : (
|
|
298
|
+
<span
|
|
299
|
+
className={`w-3 h-3 inline-flex items-center justify-center text-[10px] font-mono ${meta.color}`}
|
|
300
|
+
>
|
|
301
|
+
{f.status}
|
|
302
|
+
</span>
|
|
303
|
+
)}
|
|
304
|
+
</button>
|
|
305
|
+
<button
|
|
306
|
+
onClick={() => toggleFile(f.path)}
|
|
307
|
+
className={`flex-1 min-w-0 text-left truncate ${
|
|
308
|
+
isSelected ? "text-cyan-300" : "text-slate-400"
|
|
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
|
+
})}
|
|
333
|
+
{tree.truncated && (
|
|
334
|
+
<p className="text-[10px] text-amber-400/70 mt-1">
|
|
335
|
+
Truncated — too many changed files.
|
|
336
|
+
</p>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
</>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
{viewingFile && baseRef && (mode === "working-tree" || headRef) && (
|
|
346
|
+
<GitDiffViewerModal
|
|
347
|
+
ctx={{
|
|
348
|
+
baseRef,
|
|
349
|
+
headRef: mode === "working-tree" ? "" : headRef,
|
|
350
|
+
mode,
|
|
351
|
+
selectedFiles: selected,
|
|
352
|
+
}}
|
|
353
|
+
filePath={viewingFile}
|
|
354
|
+
onClose={() => setViewingFile(null)}
|
|
355
|
+
/>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Small free-text + datalist combo so the user can either pick a known branch
|
|
362
|
+
// or type any commit-ish (sha, tag, HEAD~3, etc.).
|
|
363
|
+
function RefInput({
|
|
364
|
+
value,
|
|
365
|
+
onChange,
|
|
366
|
+
branches,
|
|
367
|
+
tags,
|
|
368
|
+
}: {
|
|
369
|
+
value: string;
|
|
370
|
+
onChange: (v: string) => void;
|
|
371
|
+
branches: string[];
|
|
372
|
+
tags: string[];
|
|
373
|
+
}) {
|
|
374
|
+
const listId = `git-refs-${Math.random().toString(36).slice(2, 8)}`;
|
|
375
|
+
return (
|
|
376
|
+
<div className="flex gap-1">
|
|
377
|
+
<input
|
|
378
|
+
list={listId}
|
|
379
|
+
value={value}
|
|
380
|
+
onChange={(e) => onChange(e.target.value)}
|
|
381
|
+
placeholder="branch, tag, sha, HEAD~1…"
|
|
382
|
+
className="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500 font-mono"
|
|
383
|
+
/>
|
|
384
|
+
{value && (
|
|
385
|
+
<button
|
|
386
|
+
onClick={() => onChange("")}
|
|
387
|
+
className="px-1 text-slate-600 hover:text-red-400"
|
|
388
|
+
title="Clear"
|
|
389
|
+
>
|
|
390
|
+
<X className="w-3 h-3" />
|
|
391
|
+
</button>
|
|
392
|
+
)}
|
|
393
|
+
<datalist id={listId}>
|
|
394
|
+
{branches.map((b) => (
|
|
395
|
+
<option key={b} value={b} />
|
|
396
|
+
))}
|
|
397
|
+
{tags.map((t) => (
|
|
398
|
+
<option key={t} value={t} />
|
|
399
|
+
))}
|
|
400
|
+
</datalist>
|
|
401
|
+
</div>
|
|
402
|
+
);
|
|
403
|
+
}
|