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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- <FolderOpen className="w-3.5 h-3.5 text-slate-600" />
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
+ }