@ridit/lens 0.1.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/LENS.md +25 -0
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/dist/index.js +49363 -0
- package/package.json +38 -0
- package/src/colors.ts +1 -0
- package/src/commands/chat.tsx +23 -0
- package/src/commands/provider.tsx +224 -0
- package/src/commands/repo.tsx +120 -0
- package/src/commands/review.tsx +294 -0
- package/src/commands/task.tsx +36 -0
- package/src/commands/timeline.tsx +22 -0
- package/src/components/chat/ChatMessage.tsx +176 -0
- package/src/components/chat/ChatOverlays.tsx +329 -0
- package/src/components/chat/ChatRunner.tsx +732 -0
- package/src/components/provider/ApiKeyStep.tsx +243 -0
- package/src/components/provider/ModelStep.tsx +73 -0
- package/src/components/provider/ProviderTypeStep.tsx +54 -0
- package/src/components/provider/RemoveProviderStep.tsx +83 -0
- package/src/components/repo/DiffViewer.tsx +175 -0
- package/src/components/repo/FileReviewer.tsx +70 -0
- package/src/components/repo/FileViewer.tsx +60 -0
- package/src/components/repo/IssueFixer.tsx +666 -0
- package/src/components/repo/LensFileMenu.tsx +122 -0
- package/src/components/repo/NoProviderPrompt.tsx +28 -0
- package/src/components/repo/PreviewRunner.tsx +217 -0
- package/src/components/repo/ProviderPicker.tsx +76 -0
- package/src/components/repo/RepoAnalysis.tsx +343 -0
- package/src/components/repo/StepRow.tsx +69 -0
- package/src/components/task/TaskRunner.tsx +396 -0
- package/src/components/timeline/CommitDetail.tsx +274 -0
- package/src/components/timeline/CommitList.tsx +174 -0
- package/src/components/timeline/TimelineChat.tsx +167 -0
- package/src/components/timeline/TimelineRunner.tsx +1209 -0
- package/src/index.tsx +60 -0
- package/src/types/chat.ts +69 -0
- package/src/types/config.ts +20 -0
- package/src/types/repo.ts +42 -0
- package/src/utils/ai.ts +233 -0
- package/src/utils/chat.ts +833 -0
- package/src/utils/config.ts +61 -0
- package/src/utils/files.ts +104 -0
- package/src/utils/git.ts +155 -0
- package/src/utils/history.ts +86 -0
- package/src/utils/lensfile.ts +77 -0
- package/src/utils/llm.ts +81 -0
- package/src/utils/preview.ts +119 -0
- package/src/utils/repo.ts +69 -0
- package/src/utils/stats.ts +174 -0
- package/src/utils/thinking.tsx +191 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, Static, useInput } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { ProviderPicker } from "../repo/ProviderPicker";
|
|
6
|
+
import {
|
|
7
|
+
fetchCommits,
|
|
8
|
+
fetchDiff,
|
|
9
|
+
isGitRepo,
|
|
10
|
+
summarizeTimeline,
|
|
11
|
+
} from "../../utils/git";
|
|
12
|
+
import { callChat } from "../../utils/chat";
|
|
13
|
+
import type { Commit, DiffFile } from "../../utils/git";
|
|
14
|
+
import type { Provider } from "../../types/config";
|
|
15
|
+
|
|
16
|
+
const ACCENT = "#FF8C00";
|
|
17
|
+
const W = () => process.stdout.columns ?? 100;
|
|
18
|
+
|
|
19
|
+
// ── git tool helpers ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function gitRun(cmd: string, cwd: string): { ok: boolean; out: string } {
|
|
22
|
+
try {
|
|
23
|
+
const out = execSync(cmd, {
|
|
24
|
+
cwd,
|
|
25
|
+
encoding: "utf-8",
|
|
26
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
27
|
+
timeout: 60_000,
|
|
28
|
+
}).trim();
|
|
29
|
+
return { ok: true, out: out || "(done)" };
|
|
30
|
+
} catch (e: any) {
|
|
31
|
+
const msg =
|
|
32
|
+
[e.stdout, e.stderr].filter(Boolean).join("\n").trim() || e.message;
|
|
33
|
+
return { ok: false, out: msg };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getUnstagedDiff(cwd: string): string {
|
|
38
|
+
// includes both tracked changes and new untracked files
|
|
39
|
+
const tracked = gitRun("git diff HEAD", cwd).out;
|
|
40
|
+
const untracked = gitRun(`git ls-files --others --exclude-standard`, cwd).out;
|
|
41
|
+
|
|
42
|
+
const untrackedContent = untracked
|
|
43
|
+
.split("\n")
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.slice(0, 10)
|
|
46
|
+
.map((f) => {
|
|
47
|
+
try {
|
|
48
|
+
const content = execSync(
|
|
49
|
+
`git show :0 "${f}" 2>/dev/null || type "${f}"`,
|
|
50
|
+
{
|
|
51
|
+
cwd,
|
|
52
|
+
encoding: "utf-8",
|
|
53
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
.trim()
|
|
57
|
+
.slice(0, 500);
|
|
58
|
+
return `=== new file: ${f} ===\n${content}`;
|
|
59
|
+
} catch {
|
|
60
|
+
return `=== new file: ${f} ===`;
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.join("\n\n");
|
|
64
|
+
|
|
65
|
+
return [tracked.slice(0, 4000), untrackedContent]
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join("\n\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function generateCommitMessage(
|
|
71
|
+
provider: Provider,
|
|
72
|
+
diff: string,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
const system = `You are a commit message generator. Given a git diff, write a concise, imperative commit message.
|
|
75
|
+
Rules:
|
|
76
|
+
- First line: short summary, max 72 chars, imperative mood ("add", "fix", "update", not "added")
|
|
77
|
+
- If needed, one blank line then a short body (2-3 lines max)
|
|
78
|
+
- No markdown, no bullet points, no code blocks
|
|
79
|
+
- Output ONLY the commit message, nothing else`;
|
|
80
|
+
|
|
81
|
+
const msgs = [
|
|
82
|
+
{
|
|
83
|
+
role: "user" as const,
|
|
84
|
+
content: `Write a commit message for this diff:\n\n${diff}`,
|
|
85
|
+
type: "text" as const,
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
const raw = await callChat(provider, system, msgs as any);
|
|
89
|
+
return typeof raw === "string" ? raw.trim() : "update files";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── tiny helpers ──────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function shortDate(d: string) {
|
|
95
|
+
try {
|
|
96
|
+
return new Date(d).toLocaleDateString("en-US", {
|
|
97
|
+
month: "short",
|
|
98
|
+
day: "numeric",
|
|
99
|
+
year: "2-digit",
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
return d.slice(0, 10);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function trunc(s: string, n: number) {
|
|
107
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bar(ins: number, del: number): string {
|
|
111
|
+
const total = ins + del;
|
|
112
|
+
if (!total) return "";
|
|
113
|
+
const w = 10;
|
|
114
|
+
const addW = Math.round((ins / total) * w);
|
|
115
|
+
return "+" + "█".repeat(addW) + "░".repeat(w - addW) + "-";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── CommitRow ─────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function CommitRow({
|
|
121
|
+
commit,
|
|
122
|
+
index,
|
|
123
|
+
isSelected,
|
|
124
|
+
showDiff,
|
|
125
|
+
diff,
|
|
126
|
+
diffScroll,
|
|
127
|
+
onRevert,
|
|
128
|
+
}: {
|
|
129
|
+
commit: Commit;
|
|
130
|
+
index: number;
|
|
131
|
+
isSelected: boolean;
|
|
132
|
+
showDiff: boolean;
|
|
133
|
+
diff: DiffFile[];
|
|
134
|
+
diffScroll: number;
|
|
135
|
+
onRevert: () => void;
|
|
136
|
+
}) {
|
|
137
|
+
const w = W();
|
|
138
|
+
const isMerge = commit.parents.length > 1;
|
|
139
|
+
const node = isMerge ? "⎇" : index === 0 ? "◉" : "●";
|
|
140
|
+
|
|
141
|
+
const refLabels = commit.refs
|
|
142
|
+
.split(",")
|
|
143
|
+
.map((r) => r.trim())
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.map((r) =>
|
|
146
|
+
r.startsWith("HEAD -> ")
|
|
147
|
+
? r.slice(8)
|
|
148
|
+
: r.startsWith("tag: ")
|
|
149
|
+
? `v${r.slice(5)}`
|
|
150
|
+
: r,
|
|
151
|
+
)
|
|
152
|
+
.slice(0, 2);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Box flexDirection="column">
|
|
156
|
+
<Box gap={1}>
|
|
157
|
+
<Text color={isSelected ? ACCENT : "gray"}>
|
|
158
|
+
{isSelected ? "▶" : " "}
|
|
159
|
+
</Text>
|
|
160
|
+
<Text color={isSelected ? ACCENT : isMerge ? "magenta" : "gray"}>
|
|
161
|
+
{node}
|
|
162
|
+
</Text>
|
|
163
|
+
<Text color="gray" dimColor={!isSelected}>
|
|
164
|
+
{commit.shortHash}
|
|
165
|
+
</Text>
|
|
166
|
+
<Text color="cyan" dimColor={!isSelected}>
|
|
167
|
+
{shortDate(commit.date)}
|
|
168
|
+
</Text>
|
|
169
|
+
{refLabels.map((r) => (
|
|
170
|
+
<Text key={r} color="yellow">
|
|
171
|
+
[{r}]
|
|
172
|
+
</Text>
|
|
173
|
+
))}
|
|
174
|
+
<Text
|
|
175
|
+
color={isSelected ? "white" : "gray"}
|
|
176
|
+
bold={isSelected}
|
|
177
|
+
wrap="truncate"
|
|
178
|
+
>
|
|
179
|
+
{trunc(commit.message, w - 36)}
|
|
180
|
+
</Text>
|
|
181
|
+
</Box>
|
|
182
|
+
|
|
183
|
+
{isSelected && !showDiff && (
|
|
184
|
+
<Box flexDirection="column" marginLeft={4} marginBottom={1}>
|
|
185
|
+
<Box gap={2}>
|
|
186
|
+
<Text color="gray" dimColor>
|
|
187
|
+
{commit.author}
|
|
188
|
+
</Text>
|
|
189
|
+
<Text color="gray" dimColor>
|
|
190
|
+
{commit.relativeDate}
|
|
191
|
+
</Text>
|
|
192
|
+
{commit.filesChanged > 0 && (
|
|
193
|
+
<>
|
|
194
|
+
<Text color="green">+{commit.insertions}</Text>
|
|
195
|
+
<Text color="red">-{commit.deletions}</Text>
|
|
196
|
+
<Text color="gray" dimColor>
|
|
197
|
+
{commit.filesChanged} file
|
|
198
|
+
{commit.filesChanged !== 1 ? "s" : ""}
|
|
199
|
+
</Text>
|
|
200
|
+
<Text color="gray" dimColor>
|
|
201
|
+
{bar(commit.insertions, commit.deletions)}
|
|
202
|
+
</Text>
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
</Box>
|
|
206
|
+
{commit.body ? (
|
|
207
|
+
<Text color="gray" dimColor wrap="wrap">
|
|
208
|
+
{trunc(commit.body, w - 8)}
|
|
209
|
+
</Text>
|
|
210
|
+
) : null}
|
|
211
|
+
<Box gap={3} marginTop={1}>
|
|
212
|
+
<Text color="gray" dimColor>
|
|
213
|
+
enter diff
|
|
214
|
+
</Text>
|
|
215
|
+
<Text color="red" dimColor>
|
|
216
|
+
x revert
|
|
217
|
+
</Text>
|
|
218
|
+
</Box>
|
|
219
|
+
</Box>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{isSelected && showDiff && (
|
|
223
|
+
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
|
|
224
|
+
<Box gap={3} marginBottom={1}>
|
|
225
|
+
<Text color={ACCENT} bold>
|
|
226
|
+
DIFF
|
|
227
|
+
</Text>
|
|
228
|
+
<Text color="gray" dimColor>
|
|
229
|
+
{commit.shortHash} — {trunc(commit.message, 50)}
|
|
230
|
+
</Text>
|
|
231
|
+
<Text color="red" dimColor>
|
|
232
|
+
x revert
|
|
233
|
+
</Text>
|
|
234
|
+
<Text color="gray" dimColor>
|
|
235
|
+
esc close
|
|
236
|
+
</Text>
|
|
237
|
+
</Box>
|
|
238
|
+
<DiffPanel
|
|
239
|
+
files={diff}
|
|
240
|
+
scrollOffset={diffScroll}
|
|
241
|
+
maxLines={Math.max(8, (process.stdout.rows ?? 30) - 12)}
|
|
242
|
+
/>
|
|
243
|
+
<Text color="gray" dimColor>
|
|
244
|
+
↑↓ scroll · esc close
|
|
245
|
+
</Text>
|
|
246
|
+
</Box>
|
|
247
|
+
)}
|
|
248
|
+
</Box>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── DiffPanel ─────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
function DiffPanel({
|
|
255
|
+
files,
|
|
256
|
+
scrollOffset,
|
|
257
|
+
maxLines,
|
|
258
|
+
}: {
|
|
259
|
+
files: DiffFile[];
|
|
260
|
+
scrollOffset: number;
|
|
261
|
+
maxLines: number;
|
|
262
|
+
}) {
|
|
263
|
+
const w = W() - 6;
|
|
264
|
+
|
|
265
|
+
type RLine =
|
|
266
|
+
| {
|
|
267
|
+
k: "file";
|
|
268
|
+
path: string;
|
|
269
|
+
ins: number;
|
|
270
|
+
del: number;
|
|
271
|
+
status: DiffFile["status"];
|
|
272
|
+
}
|
|
273
|
+
| { k: "hunk" | "add" | "rem" | "ctx"; content: string };
|
|
274
|
+
|
|
275
|
+
const all: RLine[] = [];
|
|
276
|
+
for (const f of files) {
|
|
277
|
+
const icon =
|
|
278
|
+
f.status === "added"
|
|
279
|
+
? "+"
|
|
280
|
+
: f.status === "deleted"
|
|
281
|
+
? "-"
|
|
282
|
+
: f.status === "renamed"
|
|
283
|
+
? "→"
|
|
284
|
+
: "~";
|
|
285
|
+
all.push({
|
|
286
|
+
k: "file",
|
|
287
|
+
path: `${icon} ${f.path}`,
|
|
288
|
+
ins: f.insertions,
|
|
289
|
+
del: f.deletions,
|
|
290
|
+
status: f.status,
|
|
291
|
+
});
|
|
292
|
+
for (const l of f.lines) {
|
|
293
|
+
if (l.type === "header") all.push({ k: "hunk", content: l.content });
|
|
294
|
+
else if (l.type === "add") all.push({ k: "add", content: l.content });
|
|
295
|
+
else if (l.type === "remove") all.push({ k: "rem", content: l.content });
|
|
296
|
+
else all.push({ k: "ctx", content: l.content });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!all.length)
|
|
301
|
+
return (
|
|
302
|
+
<Text color="gray" dimColor>
|
|
303
|
+
{" "}
|
|
304
|
+
no diff available
|
|
305
|
+
</Text>
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const visible = all.slice(scrollOffset, scrollOffset + maxLines);
|
|
309
|
+
const hasMore = all.length > scrollOffset + maxLines;
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<Box flexDirection="column">
|
|
313
|
+
{visible.map((line, i) => {
|
|
314
|
+
if (line.k === "file") {
|
|
315
|
+
const color =
|
|
316
|
+
line.status === "added"
|
|
317
|
+
? "green"
|
|
318
|
+
: line.status === "deleted"
|
|
319
|
+
? "red"
|
|
320
|
+
: line.status === "renamed"
|
|
321
|
+
? "yellow"
|
|
322
|
+
: "cyan";
|
|
323
|
+
return (
|
|
324
|
+
<Box key={i} gap={2} marginTop={i > 0 ? 1 : 0}>
|
|
325
|
+
<Text color={color} bold>
|
|
326
|
+
{trunc(line.path, w)}
|
|
327
|
+
</Text>
|
|
328
|
+
<Text color="green">+{line.ins}</Text>
|
|
329
|
+
<Text color="red">-{line.del}</Text>
|
|
330
|
+
</Box>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (line.k === "hunk")
|
|
334
|
+
return (
|
|
335
|
+
<Text key={i} color="cyan" dimColor>
|
|
336
|
+
{trunc(line.content, w)}
|
|
337
|
+
</Text>
|
|
338
|
+
);
|
|
339
|
+
if (line.k === "add")
|
|
340
|
+
return (
|
|
341
|
+
<Text key={i} color="green">
|
|
342
|
+
{"+"}
|
|
343
|
+
{trunc(line.content, w - 1)}
|
|
344
|
+
</Text>
|
|
345
|
+
);
|
|
346
|
+
if (line.k === "rem")
|
|
347
|
+
return (
|
|
348
|
+
<Text key={i} color="red">
|
|
349
|
+
{"-"}
|
|
350
|
+
{trunc(line.content, w - 1)}
|
|
351
|
+
</Text>
|
|
352
|
+
);
|
|
353
|
+
return (
|
|
354
|
+
<Text key={i} color="gray" dimColor>
|
|
355
|
+
{" "}
|
|
356
|
+
{trunc(line.content, w - 1)}
|
|
357
|
+
</Text>
|
|
358
|
+
);
|
|
359
|
+
})}
|
|
360
|
+
{hasMore && (
|
|
361
|
+
<Text color="gray" dimColor>
|
|
362
|
+
{" "}
|
|
363
|
+
… {all.length - scrollOffset - maxLines} more lines
|
|
364
|
+
</Text>
|
|
365
|
+
)}
|
|
366
|
+
</Box>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── RevertConfirm overlay ─────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
function RevertConfirm({
|
|
373
|
+
commit,
|
|
374
|
+
repoPath,
|
|
375
|
+
onDone,
|
|
376
|
+
}: {
|
|
377
|
+
commit: Commit;
|
|
378
|
+
repoPath: string;
|
|
379
|
+
onDone: (msg: string | null) => void;
|
|
380
|
+
}) {
|
|
381
|
+
const [status, setStatus] = useState<"confirm" | "running" | "done">(
|
|
382
|
+
"confirm",
|
|
383
|
+
);
|
|
384
|
+
const [result, setResult] = useState("");
|
|
385
|
+
|
|
386
|
+
useInput((input, key) => {
|
|
387
|
+
if (status !== "confirm") return;
|
|
388
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
389
|
+
setStatus("running");
|
|
390
|
+
// use revert (safe — creates a new commit, doesn't rewrite history)
|
|
391
|
+
const r = gitRun(`git revert --no-edit "${commit.hash}"`, repoPath);
|
|
392
|
+
setResult(r.out);
|
|
393
|
+
setStatus("done");
|
|
394
|
+
setTimeout(
|
|
395
|
+
() => onDone(r.ok ? `Reverted ${commit.shortHash}` : null),
|
|
396
|
+
1200,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (input === "n" || input === "N" || key.escape) onDone(null);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const w = W();
|
|
403
|
+
return (
|
|
404
|
+
<Box flexDirection="column" marginTop={1}>
|
|
405
|
+
<Text color="gray" dimColor>
|
|
406
|
+
{"─".repeat(w)}
|
|
407
|
+
</Text>
|
|
408
|
+
{status === "confirm" && (
|
|
409
|
+
<Box flexDirection="column" paddingX={1} gap={1}>
|
|
410
|
+
<Box gap={1}>
|
|
411
|
+
<Text color="red">!</Text>
|
|
412
|
+
<Text color="white">revert </Text>
|
|
413
|
+
<Text color={ACCENT}>{commit.shortHash}</Text>
|
|
414
|
+
<Text color="gray" dimColor>
|
|
415
|
+
— {trunc(commit.message, 50)}
|
|
416
|
+
</Text>
|
|
417
|
+
</Box>
|
|
418
|
+
<Text color="gray" dimColor>
|
|
419
|
+
{" "}
|
|
420
|
+
this creates a new "revert" commit — git history is preserved
|
|
421
|
+
</Text>
|
|
422
|
+
<Box gap={2} marginTop={1}>
|
|
423
|
+
<Text color="green">y/enter confirm</Text>
|
|
424
|
+
<Text color="gray" dimColor>
|
|
425
|
+
n/esc cancel
|
|
426
|
+
</Text>
|
|
427
|
+
</Box>
|
|
428
|
+
</Box>
|
|
429
|
+
)}
|
|
430
|
+
{status === "running" && (
|
|
431
|
+
<Box paddingX={1} gap={1}>
|
|
432
|
+
<Text color={ACCENT}>*</Text>
|
|
433
|
+
<Text color="gray" dimColor>
|
|
434
|
+
reverting…
|
|
435
|
+
</Text>
|
|
436
|
+
</Box>
|
|
437
|
+
)}
|
|
438
|
+
{status === "done" && (
|
|
439
|
+
<Box paddingX={1} gap={1}>
|
|
440
|
+
<Text
|
|
441
|
+
color={
|
|
442
|
+
result.startsWith("Error") || result.includes("error")
|
|
443
|
+
? "red"
|
|
444
|
+
: "green"
|
|
445
|
+
}
|
|
446
|
+
>
|
|
447
|
+
{result.startsWith("Error") ? "✗" : "✓"}
|
|
448
|
+
</Text>
|
|
449
|
+
<Text color="white" wrap="wrap">
|
|
450
|
+
{trunc(result, W() - 6)}
|
|
451
|
+
</Text>
|
|
452
|
+
</Box>
|
|
453
|
+
)}
|
|
454
|
+
</Box>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── CommitPanel — stage + commit unstaged changes ─────────────────────────────
|
|
459
|
+
|
|
460
|
+
type CommitPanelState =
|
|
461
|
+
| { phase: "scanning" }
|
|
462
|
+
| { phase: "no-changes" }
|
|
463
|
+
| { phase: "generating"; diff: string }
|
|
464
|
+
| { phase: "review"; diff: string; message: string }
|
|
465
|
+
| { phase: "editing"; diff: string; message: string }
|
|
466
|
+
| { phase: "committing"; message: string }
|
|
467
|
+
| { phase: "done"; result: string }
|
|
468
|
+
| { phase: "error"; message: string };
|
|
469
|
+
|
|
470
|
+
function CommitPanel({
|
|
471
|
+
repoPath,
|
|
472
|
+
provider,
|
|
473
|
+
onDone,
|
|
474
|
+
}: {
|
|
475
|
+
repoPath: string;
|
|
476
|
+
provider: Provider;
|
|
477
|
+
onDone: (msg: string | null) => void;
|
|
478
|
+
}) {
|
|
479
|
+
const [state, setState] = useState<CommitPanelState>({ phase: "scanning" });
|
|
480
|
+
|
|
481
|
+
// scan + generate on mount
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
const diff = getUnstagedDiff(repoPath);
|
|
484
|
+
if (!diff.trim() || diff === "(done)") {
|
|
485
|
+
setState({ phase: "no-changes" });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
setState({ phase: "generating", diff });
|
|
489
|
+
generateCommitMessage(provider, diff)
|
|
490
|
+
.then((msg) => setState({ phase: "review", diff, message: msg }))
|
|
491
|
+
.catch((e) => setState({ phase: "error", message: String(e) }));
|
|
492
|
+
}, []);
|
|
493
|
+
|
|
494
|
+
useInput((input, key) => {
|
|
495
|
+
if (
|
|
496
|
+
state.phase === "no-changes" ||
|
|
497
|
+
state.phase === "scanning" ||
|
|
498
|
+
state.phase === "generating"
|
|
499
|
+
) {
|
|
500
|
+
if (key.escape || input === "n" || input === "N") onDone(null);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (state.phase === "review") {
|
|
505
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
506
|
+
// commit
|
|
507
|
+
setState({ phase: "committing", message: state.message });
|
|
508
|
+
const add = gitRun("git add -A", repoPath);
|
|
509
|
+
if (!add.ok) {
|
|
510
|
+
setState({ phase: "error", message: add.out });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const commit = gitRun(
|
|
514
|
+
`git commit -m ${JSON.stringify(state.message)}`,
|
|
515
|
+
repoPath,
|
|
516
|
+
);
|
|
517
|
+
setState({
|
|
518
|
+
phase: "done",
|
|
519
|
+
result: commit.ok ? commit.out : `Error: ${commit.out}`,
|
|
520
|
+
});
|
|
521
|
+
setTimeout(() => onDone(commit.ok ? state.message : null), 1500);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (input === "e" || input === "E") {
|
|
525
|
+
setState({
|
|
526
|
+
phase: "editing",
|
|
527
|
+
diff: state.diff,
|
|
528
|
+
message: state.message,
|
|
529
|
+
});
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (input === "n" || input === "N" || key.escape) {
|
|
533
|
+
onDone(null);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (state.phase === "editing") {
|
|
539
|
+
if (key.escape) {
|
|
540
|
+
setState({ phase: "review", diff: state.diff, message: state.message });
|
|
541
|
+
}
|
|
542
|
+
// TextInput handles the rest
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (state.phase === "done" || state.phase === "error") {
|
|
546
|
+
if (key.return || key.escape) onDone(null);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const w = W();
|
|
551
|
+
const divider = "─".repeat(w);
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
<Box flexDirection="column" marginTop={1}>
|
|
555
|
+
<Text color="gray" dimColor>
|
|
556
|
+
{divider}
|
|
557
|
+
</Text>
|
|
558
|
+
<Box paddingX={1} marginBottom={1} gap={2}>
|
|
559
|
+
<Text color={ACCENT} bold>
|
|
560
|
+
COMMIT CHANGES
|
|
561
|
+
</Text>
|
|
562
|
+
</Box>
|
|
563
|
+
|
|
564
|
+
{state.phase === "scanning" && (
|
|
565
|
+
<Box paddingX={1} gap={1}>
|
|
566
|
+
<Text color={ACCENT}>*</Text>
|
|
567
|
+
<Text color="gray" dimColor>
|
|
568
|
+
scanning for changes…
|
|
569
|
+
</Text>
|
|
570
|
+
</Box>
|
|
571
|
+
)}
|
|
572
|
+
|
|
573
|
+
{state.phase === "no-changes" && (
|
|
574
|
+
<Box paddingX={1} flexDirection="column" gap={1}>
|
|
575
|
+
<Box gap={1}>
|
|
576
|
+
<Text color="yellow">!</Text>
|
|
577
|
+
<Text color="white">no uncommitted changes found</Text>
|
|
578
|
+
</Box>
|
|
579
|
+
<Text color="gray" dimColor>
|
|
580
|
+
{" "}
|
|
581
|
+
esc to close
|
|
582
|
+
</Text>
|
|
583
|
+
</Box>
|
|
584
|
+
)}
|
|
585
|
+
|
|
586
|
+
{state.phase === "generating" && (
|
|
587
|
+
<Box paddingX={1} gap={1}>
|
|
588
|
+
<Text color={ACCENT}>*</Text>
|
|
589
|
+
<Text color="gray" dimColor>
|
|
590
|
+
generating commit message…
|
|
591
|
+
</Text>
|
|
592
|
+
</Box>
|
|
593
|
+
)}
|
|
594
|
+
|
|
595
|
+
{(state.phase === "review" || state.phase === "editing") && (
|
|
596
|
+
<Box paddingX={1} flexDirection="column" gap={1}>
|
|
597
|
+
{/* show a compact diff summary */}
|
|
598
|
+
<Box gap={1}>
|
|
599
|
+
<Text color="gray" dimColor>
|
|
600
|
+
diff preview:
|
|
601
|
+
</Text>
|
|
602
|
+
<Text color="gray" dimColor>
|
|
603
|
+
{trunc(state.diff.split("\n")[0] ?? "", w - 20)}
|
|
604
|
+
</Text>
|
|
605
|
+
</Box>
|
|
606
|
+
<Box gap={1} marginTop={1}>
|
|
607
|
+
<Text color="gray" dimColor>
|
|
608
|
+
message:
|
|
609
|
+
</Text>
|
|
610
|
+
</Box>
|
|
611
|
+
|
|
612
|
+
{state.phase === "review" && (
|
|
613
|
+
<Box paddingLeft={2} flexDirection="column">
|
|
614
|
+
<Text color="white" bold wrap="wrap">
|
|
615
|
+
{state.message}
|
|
616
|
+
</Text>
|
|
617
|
+
<Box gap={3} marginTop={1}>
|
|
618
|
+
<Text color="green">y/enter commit</Text>
|
|
619
|
+
<Text color="cyan">e edit</Text>
|
|
620
|
+
<Text color="gray" dimColor>
|
|
621
|
+
n/esc cancel
|
|
622
|
+
</Text>
|
|
623
|
+
</Box>
|
|
624
|
+
</Box>
|
|
625
|
+
)}
|
|
626
|
+
|
|
627
|
+
{state.phase === "editing" && (
|
|
628
|
+
<Box paddingLeft={2} flexDirection="column" gap={1}>
|
|
629
|
+
<TextInput
|
|
630
|
+
value={state.message}
|
|
631
|
+
onChange={(msg) =>
|
|
632
|
+
setState({ phase: "editing", diff: state.diff, message: msg })
|
|
633
|
+
}
|
|
634
|
+
onSubmit={(msg) =>
|
|
635
|
+
setState({ phase: "review", diff: state.diff, message: msg })
|
|
636
|
+
}
|
|
637
|
+
/>
|
|
638
|
+
<Text color="gray" dimColor>
|
|
639
|
+
enter to confirm · esc to cancel edit
|
|
640
|
+
</Text>
|
|
641
|
+
</Box>
|
|
642
|
+
)}
|
|
643
|
+
</Box>
|
|
644
|
+
)}
|
|
645
|
+
|
|
646
|
+
{state.phase === "committing" && (
|
|
647
|
+
<Box paddingX={1} gap={1}>
|
|
648
|
+
<Text color={ACCENT}>*</Text>
|
|
649
|
+
<Text color="gray" dimColor>
|
|
650
|
+
committing…
|
|
651
|
+
</Text>
|
|
652
|
+
</Box>
|
|
653
|
+
)}
|
|
654
|
+
|
|
655
|
+
{state.phase === "done" && (
|
|
656
|
+
<Box paddingX={1} gap={1}>
|
|
657
|
+
<Text color="green">✓</Text>
|
|
658
|
+
<Text color="white" wrap="wrap">
|
|
659
|
+
{trunc(state.result, w - 6)}
|
|
660
|
+
</Text>
|
|
661
|
+
</Box>
|
|
662
|
+
)}
|
|
663
|
+
|
|
664
|
+
{state.phase === "error" && (
|
|
665
|
+
<Box paddingX={1} flexDirection="column" gap={1}>
|
|
666
|
+
<Box gap={1}>
|
|
667
|
+
<Text color="red">✗</Text>
|
|
668
|
+
<Text color="white" wrap="wrap">
|
|
669
|
+
{trunc(state.message, w - 6)}
|
|
670
|
+
</Text>
|
|
671
|
+
</Box>
|
|
672
|
+
<Text color="gray" dimColor>
|
|
673
|
+
{" "}
|
|
674
|
+
enter/esc to close
|
|
675
|
+
</Text>
|
|
676
|
+
</Box>
|
|
677
|
+
)}
|
|
678
|
+
</Box>
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── AskPanel ──────────────────────────────────────────────────────────────────
|
|
683
|
+
// No Static here — Static always floats to the top of Ink output regardless of
|
|
684
|
+
// where it is placed in the tree. We use plain state arrays instead so messages
|
|
685
|
+
// render in document flow, below the commit list.
|
|
686
|
+
|
|
687
|
+
type ChatMsg =
|
|
688
|
+
| { role: "user"; content: string }
|
|
689
|
+
| { role: "assistant"; content: string }
|
|
690
|
+
| { role: "thinking" };
|
|
691
|
+
|
|
692
|
+
function AskPanel({
|
|
693
|
+
commits,
|
|
694
|
+
provider,
|
|
695
|
+
onCommit,
|
|
696
|
+
}: {
|
|
697
|
+
commits: Commit[];
|
|
698
|
+
provider: Provider;
|
|
699
|
+
onCommit: () => void;
|
|
700
|
+
}) {
|
|
701
|
+
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
|
702
|
+
const [input, setInput] = useState("");
|
|
703
|
+
const [thinking, setThinking] = useState(false);
|
|
704
|
+
const [history, setHistory] = useState<
|
|
705
|
+
{ role: "user" | "assistant"; content: string; type: "text" }[]
|
|
706
|
+
>([]);
|
|
707
|
+
|
|
708
|
+
// keywords that mean "commit my changes" in any language
|
|
709
|
+
const COMMIT_TRIGGERS = [
|
|
710
|
+
/commit/i,
|
|
711
|
+
/stage/i,
|
|
712
|
+
/push changes/i,
|
|
713
|
+
// hinglish / hindi
|
|
714
|
+
/commit kr/i,
|
|
715
|
+
/commit kar/i,
|
|
716
|
+
/changes commit/i,
|
|
717
|
+
/changes save/i,
|
|
718
|
+
/save changes/i,
|
|
719
|
+
/badlav.*commit/i,
|
|
720
|
+
];
|
|
721
|
+
|
|
722
|
+
const systemPrompt = `You are a git history analyst embedded in a terminal git timeline viewer.
|
|
723
|
+
You can ONLY answer questions about the git history shown below.
|
|
724
|
+
You CANNOT run commands, execute git operations, or modify files.
|
|
725
|
+
If the user asks to commit, stage, push, or make any git change — reply with exactly: DELEGATE_COMMIT
|
|
726
|
+
Plain text answers only. No markdown. No code blocks. No backticks. Be concise.
|
|
727
|
+
|
|
728
|
+
${summarizeTimeline(commits)}`;
|
|
729
|
+
|
|
730
|
+
const ask = async (q: string) => {
|
|
731
|
+
if (!q.trim() || thinking) return;
|
|
732
|
+
|
|
733
|
+
// client-side check first — catch obvious commit intents without an API call
|
|
734
|
+
if (COMMIT_TRIGGERS.some((re) => re.test(q))) {
|
|
735
|
+
setMessages((prev) => [...prev, { role: "user", content: q }]);
|
|
736
|
+
setInput("");
|
|
737
|
+
onCommit();
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const nextHistory = [
|
|
742
|
+
...history,
|
|
743
|
+
{ role: "user" as const, content: q, type: "text" as const },
|
|
744
|
+
];
|
|
745
|
+
setMessages((prev) => [
|
|
746
|
+
...prev,
|
|
747
|
+
{ role: "user", content: q },
|
|
748
|
+
{ role: "thinking" },
|
|
749
|
+
]);
|
|
750
|
+
setThinking(true);
|
|
751
|
+
setInput("");
|
|
752
|
+
try {
|
|
753
|
+
const raw = await callChat(provider, systemPrompt, nextHistory as any);
|
|
754
|
+
const answer = typeof raw === "string" ? raw.trim() : "(no response)";
|
|
755
|
+
|
|
756
|
+
// model-side delegation signal
|
|
757
|
+
if (
|
|
758
|
+
answer === "DELEGATE_COMMIT" ||
|
|
759
|
+
answer.startsWith("DELEGATE_COMMIT")
|
|
760
|
+
) {
|
|
761
|
+
setMessages((prev) => prev.filter((m) => m.role !== "thinking"));
|
|
762
|
+
setThinking(false);
|
|
763
|
+
onCommit();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// strip any accidental markdown/code blocks the model snuck in
|
|
768
|
+
const clean = answer
|
|
769
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
770
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
771
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
772
|
+
.trim();
|
|
773
|
+
|
|
774
|
+
setMessages((prev) => [
|
|
775
|
+
...prev.filter((m) => m.role !== "thinking"),
|
|
776
|
+
{ role: "assistant", content: clean },
|
|
777
|
+
]);
|
|
778
|
+
setHistory([
|
|
779
|
+
...nextHistory,
|
|
780
|
+
{ role: "assistant", content: clean, type: "text" },
|
|
781
|
+
]);
|
|
782
|
+
} catch (e) {
|
|
783
|
+
setMessages((prev) => [
|
|
784
|
+
...prev.filter((m) => m.role !== "thinking"),
|
|
785
|
+
{ role: "assistant", content: `Error: ${String(e)}` },
|
|
786
|
+
]);
|
|
787
|
+
} finally {
|
|
788
|
+
setThinking(false);
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
const w = W();
|
|
793
|
+
|
|
794
|
+
return (
|
|
795
|
+
<Box flexDirection="column" marginTop={1}>
|
|
796
|
+
<Text color="gray" dimColor>
|
|
797
|
+
{"─".repeat(w)}
|
|
798
|
+
</Text>
|
|
799
|
+
|
|
800
|
+
{/* plain array render — stays in document flow below the commit list */}
|
|
801
|
+
{messages.map((msg, i) => {
|
|
802
|
+
if (msg.role === "thinking")
|
|
803
|
+
return (
|
|
804
|
+
<Box key={i} paddingX={1} gap={1}>
|
|
805
|
+
<Text color={ACCENT}>*</Text>
|
|
806
|
+
<Text color="gray" dimColor>
|
|
807
|
+
thinking…
|
|
808
|
+
</Text>
|
|
809
|
+
</Box>
|
|
810
|
+
);
|
|
811
|
+
if (msg.role === "user")
|
|
812
|
+
return (
|
|
813
|
+
<Box key={i} paddingX={1} gap={1}>
|
|
814
|
+
<Text color="gray">{">"}</Text>
|
|
815
|
+
<Text color="white">{msg.content}</Text>
|
|
816
|
+
</Box>
|
|
817
|
+
);
|
|
818
|
+
return (
|
|
819
|
+
<Box key={i} paddingX={1} gap={1} marginBottom={1}>
|
|
820
|
+
<Text color={ACCENT}>{"*"}</Text>
|
|
821
|
+
<Text color="white" wrap="wrap">
|
|
822
|
+
{msg.content}
|
|
823
|
+
</Text>
|
|
824
|
+
</Box>
|
|
825
|
+
);
|
|
826
|
+
})}
|
|
827
|
+
|
|
828
|
+
{/* input always at the bottom of the panel */}
|
|
829
|
+
<Box paddingX={1} gap={1}>
|
|
830
|
+
<Text color={ACCENT}>{"?"}</Text>
|
|
831
|
+
{!thinking ? (
|
|
832
|
+
<TextInput
|
|
833
|
+
value={input}
|
|
834
|
+
onChange={setInput}
|
|
835
|
+
onSubmit={ask}
|
|
836
|
+
placeholder="ask about the history…"
|
|
837
|
+
/>
|
|
838
|
+
) : (
|
|
839
|
+
<Text color="gray" dimColor>
|
|
840
|
+
thinking…
|
|
841
|
+
</Text>
|
|
842
|
+
)}
|
|
843
|
+
</Box>
|
|
844
|
+
</Box>
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ── TimelineRunner ────────────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
type UIMode =
|
|
851
|
+
| { type: "browse" }
|
|
852
|
+
| { type: "search"; query: string }
|
|
853
|
+
| { type: "ask" }
|
|
854
|
+
| { type: "revert"; commit: Commit }
|
|
855
|
+
| { type: "commit" };
|
|
856
|
+
|
|
857
|
+
type StatusMsg = { id: number; text: string; ok: boolean };
|
|
858
|
+
let sid = 0;
|
|
859
|
+
|
|
860
|
+
export function TimelineRunner({
|
|
861
|
+
repoPath,
|
|
862
|
+
onExit,
|
|
863
|
+
}: {
|
|
864
|
+
repoPath: string;
|
|
865
|
+
onExit?: () => void;
|
|
866
|
+
}) {
|
|
867
|
+
const [provider, setProvider] = useState<Provider | null>(null);
|
|
868
|
+
const [commits, setCommits] = useState<Commit[]>([]);
|
|
869
|
+
const [filtered, setFiltered] = useState<Commit[]>([]);
|
|
870
|
+
const [loading, setLoading] = useState(true);
|
|
871
|
+
const [error, setError] = useState<string | null>(null);
|
|
872
|
+
|
|
873
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
874
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
875
|
+
const [showDiff, setShowDiff] = useState(false);
|
|
876
|
+
const [diff, setDiff] = useState<DiffFile[]>([]);
|
|
877
|
+
const [diffLoading, setDiffLoading] = useState(false);
|
|
878
|
+
const [diffScroll, setDiffScroll] = useState(0);
|
|
879
|
+
const [lastDiffHash, setLastDiffHash] = useState<string | null>(null);
|
|
880
|
+
|
|
881
|
+
const [mode, setMode] = useState<UIMode>({ type: "browse" });
|
|
882
|
+
const [statusMsgs, setStatusMsgs] = useState<StatusMsg[]>([]);
|
|
883
|
+
|
|
884
|
+
const termHeight = process.stdout.rows ?? 30;
|
|
885
|
+
const visibleCount = Math.max(4, termHeight - 6);
|
|
886
|
+
|
|
887
|
+
const addStatus = (text: string, ok: boolean) =>
|
|
888
|
+
setStatusMsgs((prev) => [...prev, { id: ++sid, text, ok }]);
|
|
889
|
+
|
|
890
|
+
const reloadCommits = () => {
|
|
891
|
+
const loaded = fetchCommits(repoPath, 300);
|
|
892
|
+
setCommits(loaded);
|
|
893
|
+
setFiltered(loaded);
|
|
894
|
+
setSelectedIdx(0);
|
|
895
|
+
setScrollOffset(0);
|
|
896
|
+
setShowDiff(false);
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
useEffect(() => {
|
|
900
|
+
if (!isGitRepo(repoPath)) {
|
|
901
|
+
setError("Not a git repository.");
|
|
902
|
+
setLoading(false);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const loaded = fetchCommits(repoPath, 300);
|
|
906
|
+
if (!loaded.length) {
|
|
907
|
+
setError("No commits found.");
|
|
908
|
+
setLoading(false);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
setCommits(loaded);
|
|
912
|
+
setFiltered(loaded);
|
|
913
|
+
setLoading(false);
|
|
914
|
+
}, [repoPath]);
|
|
915
|
+
|
|
916
|
+
useEffect(() => {
|
|
917
|
+
if (mode.type !== "search" || !mode.query) {
|
|
918
|
+
setFiltered(commits);
|
|
919
|
+
} else {
|
|
920
|
+
const q = mode.query.toLowerCase();
|
|
921
|
+
setFiltered(
|
|
922
|
+
commits.filter(
|
|
923
|
+
(c) =>
|
|
924
|
+
c.message.toLowerCase().includes(q) ||
|
|
925
|
+
c.author.toLowerCase().includes(q) ||
|
|
926
|
+
c.shortHash.includes(q),
|
|
927
|
+
),
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
setSelectedIdx(0);
|
|
931
|
+
setScrollOffset(0);
|
|
932
|
+
}, [mode, commits]);
|
|
933
|
+
|
|
934
|
+
const selected = filtered[selectedIdx] ?? null;
|
|
935
|
+
|
|
936
|
+
useEffect(() => {
|
|
937
|
+
if (!selected || selected.hash === lastDiffHash) return;
|
|
938
|
+
setDiff([]);
|
|
939
|
+
setDiffScroll(0);
|
|
940
|
+
setLastDiffHash(selected.hash);
|
|
941
|
+
if (showDiff) {
|
|
942
|
+
setDiffLoading(true);
|
|
943
|
+
setTimeout(() => {
|
|
944
|
+
setDiff(fetchDiff(repoPath, selected.hash));
|
|
945
|
+
setDiffLoading(false);
|
|
946
|
+
}, 0);
|
|
947
|
+
}
|
|
948
|
+
}, [selected?.hash]);
|
|
949
|
+
|
|
950
|
+
useEffect(() => {
|
|
951
|
+
if (!showDiff || !selected) return;
|
|
952
|
+
if (selected.hash === lastDiffHash && diff.length) return;
|
|
953
|
+
setDiffLoading(true);
|
|
954
|
+
setLastDiffHash(selected.hash);
|
|
955
|
+
setTimeout(() => {
|
|
956
|
+
setDiff(fetchDiff(repoPath, selected.hash));
|
|
957
|
+
setDiffLoading(false);
|
|
958
|
+
}, 0);
|
|
959
|
+
}, [showDiff]);
|
|
960
|
+
|
|
961
|
+
useInput((input, key) => {
|
|
962
|
+
if (key.ctrl && input === "c") {
|
|
963
|
+
if (onExit) onExit();
|
|
964
|
+
else process.exit(0);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// overlays consume all input except ctrl+c
|
|
968
|
+
if (
|
|
969
|
+
mode.type === "ask" ||
|
|
970
|
+
mode.type === "revert" ||
|
|
971
|
+
mode.type === "commit"
|
|
972
|
+
) {
|
|
973
|
+
if (key.escape) setMode({ type: "browse" });
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (mode.type === "search") {
|
|
978
|
+
if (key.escape) setMode({ type: "browse" });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// diff open
|
|
983
|
+
if (showDiff) {
|
|
984
|
+
if (key.escape || input === "d") {
|
|
985
|
+
setShowDiff(false);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (key.upArrow) {
|
|
989
|
+
setDiffScroll((o) => Math.max(0, o - 1));
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (key.downArrow) {
|
|
993
|
+
setDiffScroll((o) => o + 1);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (input === "x" || input === "X") {
|
|
997
|
+
if (selected) setMode({ type: "revert", commit: selected });
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (key.escape) {
|
|
1004
|
+
setShowDiff(false);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if ((input === "q" || input === "Q") && onExit) {
|
|
1008
|
+
onExit();
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (input === "/") {
|
|
1012
|
+
setMode({ type: "search", query: "" });
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (input === "?") {
|
|
1016
|
+
setMode({ type: "ask" });
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (input === "c" || input === "C") {
|
|
1020
|
+
setMode({ type: "commit" });
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (key.return && selected) {
|
|
1025
|
+
setShowDiff(true);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (input === "x" || input === "X") {
|
|
1030
|
+
if (selected) setMode({ type: "revert", commit: selected });
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (key.upArrow) {
|
|
1035
|
+
const next = Math.max(0, selectedIdx - 1);
|
|
1036
|
+
setSelectedIdx(next);
|
|
1037
|
+
setShowDiff(false);
|
|
1038
|
+
if (next < scrollOffset) setScrollOffset(next);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (key.downArrow) {
|
|
1043
|
+
const next = Math.min(filtered.length - 1, selectedIdx + 1);
|
|
1044
|
+
setSelectedIdx(next);
|
|
1045
|
+
setShowDiff(false);
|
|
1046
|
+
if (next >= scrollOffset + visibleCount)
|
|
1047
|
+
setScrollOffset(next - visibleCount + 1);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
if (!provider) return <ProviderPicker onDone={setProvider} />;
|
|
1053
|
+
if (loading)
|
|
1054
|
+
return (
|
|
1055
|
+
<Box gap={1} marginTop={1}>
|
|
1056
|
+
<Text color={ACCENT}>*</Text>
|
|
1057
|
+
<Text color="gray">loading commits…</Text>
|
|
1058
|
+
</Box>
|
|
1059
|
+
);
|
|
1060
|
+
if (error)
|
|
1061
|
+
return (
|
|
1062
|
+
<Box gap={1} marginTop={1}>
|
|
1063
|
+
<Text color="red">✗</Text>
|
|
1064
|
+
<Text color="white">{error}</Text>
|
|
1065
|
+
</Box>
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
const w = W();
|
|
1069
|
+
const isSearching = mode.type === "search";
|
|
1070
|
+
const isAsking = mode.type === "ask";
|
|
1071
|
+
const isReverting = mode.type === "revert";
|
|
1072
|
+
const isCommitting = mode.type === "commit";
|
|
1073
|
+
const searchQuery = isSearching ? mode.query : "";
|
|
1074
|
+
const visible = filtered.slice(scrollOffset, scrollOffset + visibleCount);
|
|
1075
|
+
|
|
1076
|
+
const shortcutHint = showDiff
|
|
1077
|
+
? "↑↓ scroll · x revert · esc/d close"
|
|
1078
|
+
: isSearching
|
|
1079
|
+
? "type to filter · enter confirm · esc cancel"
|
|
1080
|
+
: isAsking
|
|
1081
|
+
? "type question · enter send · esc close"
|
|
1082
|
+
: isReverting || isCommitting
|
|
1083
|
+
? "see prompt above · esc cancel"
|
|
1084
|
+
: `↑↓ navigate · enter diff · x revert · c commit · / search · ? ask${onExit ? " · q back" : " · ^C exit"}`;
|
|
1085
|
+
|
|
1086
|
+
return (
|
|
1087
|
+
<Box flexDirection="column">
|
|
1088
|
+
{/* header */}
|
|
1089
|
+
<Box gap={2} marginBottom={1}>
|
|
1090
|
+
<Text color={ACCENT} bold>
|
|
1091
|
+
◈ TIMELINE
|
|
1092
|
+
</Text>
|
|
1093
|
+
<Text color="gray" dimColor>
|
|
1094
|
+
{repoPath}
|
|
1095
|
+
</Text>
|
|
1096
|
+
{isSearching && <Text color="yellow">/ {searchQuery || "…"}</Text>}
|
|
1097
|
+
{isSearching && filtered.length !== commits.length && (
|
|
1098
|
+
<Text color="gray" dimColor>
|
|
1099
|
+
{filtered.length} matches
|
|
1100
|
+
</Text>
|
|
1101
|
+
)}
|
|
1102
|
+
</Box>
|
|
1103
|
+
|
|
1104
|
+
{/* status messages (Static — no re-render) */}
|
|
1105
|
+
<Static items={statusMsgs}>
|
|
1106
|
+
{(msg) => (
|
|
1107
|
+
<Box key={msg.id} paddingX={1} gap={1}>
|
|
1108
|
+
<Text color={msg.ok ? "green" : "red"}>{msg.ok ? "✓" : "✗"}</Text>
|
|
1109
|
+
<Text color={msg.ok ? "white" : "red"}>{msg.text}</Text>
|
|
1110
|
+
</Box>
|
|
1111
|
+
)}
|
|
1112
|
+
</Static>
|
|
1113
|
+
|
|
1114
|
+
{/* search bar */}
|
|
1115
|
+
{isSearching && (
|
|
1116
|
+
<Box gap={1} marginBottom={1}>
|
|
1117
|
+
<Text color={ACCENT}>{"/"}</Text>
|
|
1118
|
+
<TextInput
|
|
1119
|
+
value={searchQuery}
|
|
1120
|
+
onChange={(q) => setMode({ type: "search", query: q })}
|
|
1121
|
+
onSubmit={() => setMode({ type: "browse" })}
|
|
1122
|
+
placeholder="filter commits…"
|
|
1123
|
+
/>
|
|
1124
|
+
</Box>
|
|
1125
|
+
)}
|
|
1126
|
+
|
|
1127
|
+
{/* commit list */}
|
|
1128
|
+
{visible.map((commit, i) => {
|
|
1129
|
+
const absIdx = scrollOffset + i;
|
|
1130
|
+
const isSel = absIdx === selectedIdx;
|
|
1131
|
+
return (
|
|
1132
|
+
<CommitRow
|
|
1133
|
+
key={commit.hash}
|
|
1134
|
+
commit={commit}
|
|
1135
|
+
index={absIdx}
|
|
1136
|
+
isSelected={isSel}
|
|
1137
|
+
showDiff={isSel && showDiff}
|
|
1138
|
+
diff={isSel ? diff : []}
|
|
1139
|
+
diffScroll={diffScroll}
|
|
1140
|
+
onRevert={() => setMode({ type: "revert", commit })}
|
|
1141
|
+
/>
|
|
1142
|
+
);
|
|
1143
|
+
})}
|
|
1144
|
+
|
|
1145
|
+
{(scrollOffset > 0 || scrollOffset + visibleCount < filtered.length) && (
|
|
1146
|
+
<Box gap={3} marginTop={1}>
|
|
1147
|
+
{scrollOffset > 0 && (
|
|
1148
|
+
<Text color="gray" dimColor>
|
|
1149
|
+
↑ {scrollOffset} above
|
|
1150
|
+
</Text>
|
|
1151
|
+
)}
|
|
1152
|
+
{scrollOffset + visibleCount < filtered.length && (
|
|
1153
|
+
<Text color="gray" dimColor>
|
|
1154
|
+
↓ {filtered.length - scrollOffset - visibleCount} below
|
|
1155
|
+
</Text>
|
|
1156
|
+
)}
|
|
1157
|
+
</Box>
|
|
1158
|
+
)}
|
|
1159
|
+
|
|
1160
|
+
{/* revert overlay */}
|
|
1161
|
+
{isReverting && mode.type === "revert" && (
|
|
1162
|
+
<RevertConfirm
|
|
1163
|
+
commit={mode.commit}
|
|
1164
|
+
repoPath={repoPath}
|
|
1165
|
+
onDone={(msg) => {
|
|
1166
|
+
setMode({ type: "browse" });
|
|
1167
|
+
if (msg) {
|
|
1168
|
+
addStatus(msg, true);
|
|
1169
|
+
reloadCommits();
|
|
1170
|
+
} else addStatus("revert cancelled", false);
|
|
1171
|
+
}}
|
|
1172
|
+
/>
|
|
1173
|
+
)}
|
|
1174
|
+
|
|
1175
|
+
{/* commit overlay */}
|
|
1176
|
+
{isCommitting && provider && (
|
|
1177
|
+
<CommitPanel
|
|
1178
|
+
repoPath={repoPath}
|
|
1179
|
+
provider={provider}
|
|
1180
|
+
onDone={(msg) => {
|
|
1181
|
+
setMode({ type: "browse" });
|
|
1182
|
+
if (msg) {
|
|
1183
|
+
addStatus(`committed: ${trunc(msg, 60)}`, true);
|
|
1184
|
+
reloadCommits();
|
|
1185
|
+
}
|
|
1186
|
+
}}
|
|
1187
|
+
/>
|
|
1188
|
+
)}
|
|
1189
|
+
|
|
1190
|
+
{/* ask panel */}
|
|
1191
|
+
{isAsking && provider && (
|
|
1192
|
+
<AskPanel
|
|
1193
|
+
commits={commits}
|
|
1194
|
+
provider={provider}
|
|
1195
|
+
onCommit={() => {
|
|
1196
|
+
setMode({ type: "commit" });
|
|
1197
|
+
}}
|
|
1198
|
+
/>
|
|
1199
|
+
)}
|
|
1200
|
+
|
|
1201
|
+
{/* shortcut bar */}
|
|
1202
|
+
<Box marginTop={1}>
|
|
1203
|
+
<Text color="gray" dimColor>
|
|
1204
|
+
{shortcutHint}
|
|
1205
|
+
</Text>
|
|
1206
|
+
</Box>
|
|
1207
|
+
</Box>
|
|
1208
|
+
);
|
|
1209
|
+
}
|