@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,396 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import figures from "figures";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { writeFileSync, existsSync, mkdirSync } from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { ACCENT } from "../../colors";
|
|
9
|
+
import { callModelRaw } from "../../utils/ai";
|
|
10
|
+
import { DiffViewer, buildDiffs } from "../repo/DiffViewer";
|
|
11
|
+
import { ProviderPicker } from "../repo/ProviderPicker";
|
|
12
|
+
import type { DiffLine, FilePatch } from "../repo/DiffViewer";
|
|
13
|
+
import type { Provider } from "../../types/config";
|
|
14
|
+
import type { ImportantFile } from "../../types/repo";
|
|
15
|
+
import { fetchFileTree, readImportantFiles } from "../../utils/files";
|
|
16
|
+
import { readFileSync, readdirSync, statSync } from "fs";
|
|
17
|
+
import { useThinkingPhrase } from "../../utils/thinking";
|
|
18
|
+
|
|
19
|
+
type Stage =
|
|
20
|
+
| { type: "picking-provider" }
|
|
21
|
+
| { type: "reading-files" }
|
|
22
|
+
| { type: "thinking" }
|
|
23
|
+
| {
|
|
24
|
+
type: "preview";
|
|
25
|
+
plan: PromptPlan;
|
|
26
|
+
diffLines: DiffLine[][];
|
|
27
|
+
scrollOffset: number;
|
|
28
|
+
}
|
|
29
|
+
| { type: "applying" }
|
|
30
|
+
| { type: "done"; applied: AppliedFile[] }
|
|
31
|
+
| {
|
|
32
|
+
type: "viewing-file";
|
|
33
|
+
file: AppliedFile;
|
|
34
|
+
diffLines: DiffLine[];
|
|
35
|
+
scrollOffset: number;
|
|
36
|
+
}
|
|
37
|
+
| { type: "error"; message: string };
|
|
38
|
+
|
|
39
|
+
type PromptPlan = {
|
|
40
|
+
summary: string;
|
|
41
|
+
patches: FilePatch[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type AppliedFile = {
|
|
45
|
+
path: string;
|
|
46
|
+
isNew: boolean;
|
|
47
|
+
patch: FilePatch;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function buildPrompt(userPrompt: string, files: ImportantFile[]): string {
|
|
51
|
+
const fileList = files
|
|
52
|
+
.map((f) => `### ${f.path}\n\`\`\`\n${f.content.slice(0, 3000)}\n\`\`\``)
|
|
53
|
+
.join("\n\n");
|
|
54
|
+
|
|
55
|
+
return `You are a senior software engineer working on a codebase. The user has made the following request:
|
|
56
|
+
|
|
57
|
+
"${userPrompt}"
|
|
58
|
+
|
|
59
|
+
Here are the relevant files in the codebase:
|
|
60
|
+
|
|
61
|
+
${fileList}
|
|
62
|
+
|
|
63
|
+
Fulfill the user's request by providing the complete new content for any files that need to be created or modified.
|
|
64
|
+
|
|
65
|
+
Respond ONLY with a JSON object (no markdown, no explanation) with this exact shape:
|
|
66
|
+
{
|
|
67
|
+
"summary": "2-3 sentence explanation of what you did and why",
|
|
68
|
+
"patches": [
|
|
69
|
+
{
|
|
70
|
+
"path": "relative/path/to/file.ts",
|
|
71
|
+
"content": "complete new file content here",
|
|
72
|
+
"isNew": false
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Rules:
|
|
78
|
+
- Always provide the COMPLETE file content, not diffs or partial content
|
|
79
|
+
- isNew should be true only if you are creating a brand new file
|
|
80
|
+
- Only include files that actually need changes
|
|
81
|
+
- Keep changes focused on fulfilling the request
|
|
82
|
+
- Do not change unrelated code
|
|
83
|
+
- If the request is impossible or unclear, return an empty patches array with an explanation in summary`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ThinkingAboutStep({ prompt }: { prompt: string }) {
|
|
87
|
+
const phrase = useThinkingPhrase(true, "task");
|
|
88
|
+
return (
|
|
89
|
+
<Box gap={1}>
|
|
90
|
+
<Text color={ACCENT}>
|
|
91
|
+
<Spinner />
|
|
92
|
+
</Text>
|
|
93
|
+
<Text color={ACCENT}>{phrase}</Text>
|
|
94
|
+
<Text color="gray">"{prompt}"</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyPatches(repoPath: string, plan: PromptPlan): AppliedFile[] {
|
|
100
|
+
const applied: AppliedFile[] = [];
|
|
101
|
+
for (const patch of plan.patches) {
|
|
102
|
+
const fullPath = path.join(repoPath, patch.path);
|
|
103
|
+
const dir = path.dirname(fullPath);
|
|
104
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
105
|
+
writeFileSync(fullPath, patch.content, "utf-8");
|
|
106
|
+
applied.push({ path: patch.path, isNew: patch.isNew, patch });
|
|
107
|
+
}
|
|
108
|
+
return applied;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const SKIP_DIRS = new Set([
|
|
112
|
+
"node_modules",
|
|
113
|
+
".git",
|
|
114
|
+
"dist",
|
|
115
|
+
"build",
|
|
116
|
+
".next",
|
|
117
|
+
"out",
|
|
118
|
+
"coverage",
|
|
119
|
+
"__pycache__",
|
|
120
|
+
".venv",
|
|
121
|
+
"venv",
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
function walkDir(dir: string, base = dir): string[] {
|
|
125
|
+
const results: string[] = [];
|
|
126
|
+
let entries: string[];
|
|
127
|
+
try {
|
|
128
|
+
entries = readdirSync(dir, { encoding: "utf-8" });
|
|
129
|
+
} catch {
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
134
|
+
const full = path.join(dir, entry);
|
|
135
|
+
const rel = path.relative(base, full).replace(/\\/g, "/");
|
|
136
|
+
let isDir = false;
|
|
137
|
+
try {
|
|
138
|
+
isDir = statSync(full).isDirectory();
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (isDir) results.push(...walkDir(full, base));
|
|
143
|
+
else results.push(rel);
|
|
144
|
+
}
|
|
145
|
+
return results;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const PromptRunner = ({
|
|
149
|
+
repoPath,
|
|
150
|
+
userPrompt,
|
|
151
|
+
}: {
|
|
152
|
+
repoPath: string;
|
|
153
|
+
userPrompt: string;
|
|
154
|
+
}) => {
|
|
155
|
+
const [stage, setStage] = useState<Stage>({ type: "picking-provider" });
|
|
156
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
157
|
+
const [files, setFiles] = useState<ImportantFile[]>([]);
|
|
158
|
+
|
|
159
|
+
const handleProviderDone = (provider: Provider) => {
|
|
160
|
+
setStage({ type: "reading-files" });
|
|
161
|
+
|
|
162
|
+
fetchFileTree(repoPath)
|
|
163
|
+
.catch(() => walkDir(repoPath))
|
|
164
|
+
.then((fileTree) => {
|
|
165
|
+
const importantFiles = readImportantFiles(repoPath, fileTree);
|
|
166
|
+
setFiles(importantFiles);
|
|
167
|
+
setStage({ type: "thinking" });
|
|
168
|
+
return callModelRaw(provider, buildPrompt(userPrompt, importantFiles));
|
|
169
|
+
})
|
|
170
|
+
.then((text) => {
|
|
171
|
+
const cleaned = text.replace(/```json|```/g, "").trim();
|
|
172
|
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
173
|
+
if (!match) throw new Error("No JSON in model response");
|
|
174
|
+
const plan = JSON.parse(match[0]) as PromptPlan;
|
|
175
|
+
|
|
176
|
+
if (plan.patches.length === 0) {
|
|
177
|
+
setStage({
|
|
178
|
+
type: "error",
|
|
179
|
+
message: plan.summary || "Model made no changes.",
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const diffLines = buildDiffs(repoPath, plan.patches);
|
|
185
|
+
setStage({ type: "preview", plan, diffLines, scrollOffset: 0 });
|
|
186
|
+
})
|
|
187
|
+
.catch((err: unknown) =>
|
|
188
|
+
setStage({
|
|
189
|
+
type: "error",
|
|
190
|
+
message: err instanceof Error ? err.message : "Something went wrong",
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
useInput((_, key) => {
|
|
196
|
+
if (stage.type === "preview") {
|
|
197
|
+
if (key.upArrow)
|
|
198
|
+
setStage({
|
|
199
|
+
...stage,
|
|
200
|
+
scrollOffset: Math.max(0, stage.scrollOffset - 1),
|
|
201
|
+
});
|
|
202
|
+
if (key.downArrow)
|
|
203
|
+
setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
|
|
204
|
+
if (key.escape) {
|
|
205
|
+
process.exit(0);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (key.return) {
|
|
209
|
+
setStage({ type: "applying" });
|
|
210
|
+
try {
|
|
211
|
+
const applied = applyPatches(repoPath, stage.plan);
|
|
212
|
+
setStage({ type: "done", applied });
|
|
213
|
+
} catch (err: unknown) {
|
|
214
|
+
setStage({
|
|
215
|
+
type: "error",
|
|
216
|
+
message:
|
|
217
|
+
err instanceof Error ? err.message : "Failed to write files",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (stage.type === "done") {
|
|
225
|
+
if (key.escape) {
|
|
226
|
+
process.exit(0);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
|
|
230
|
+
if (key.downArrow)
|
|
231
|
+
setSelectedIndex((i) => Math.min(stage.applied.length - 1, i + 1));
|
|
232
|
+
if (key.return) {
|
|
233
|
+
const file = stage.applied[selectedIndex];
|
|
234
|
+
if (!file) return;
|
|
235
|
+
const diffLines = buildDiffs(repoPath, [file.patch])[0] ?? [];
|
|
236
|
+
setStage({ type: "viewing-file", file, diffLines, scrollOffset: 0 });
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (stage.type === "viewing-file") {
|
|
242
|
+
if (key.upArrow)
|
|
243
|
+
setStage({
|
|
244
|
+
...stage,
|
|
245
|
+
scrollOffset: Math.max(0, stage.scrollOffset - 1),
|
|
246
|
+
});
|
|
247
|
+
if (key.downArrow)
|
|
248
|
+
setStage({ ...stage, scrollOffset: stage.scrollOffset + 1 });
|
|
249
|
+
if (key.escape || key.return) {
|
|
250
|
+
setStage((prev) => {
|
|
251
|
+
if (prev.type !== "viewing-file") return prev;
|
|
252
|
+
|
|
253
|
+
process.exit(0);
|
|
254
|
+
return prev;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (stage.type === "error") {
|
|
261
|
+
if (key.return || key.escape) process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (stage.type === "picking-provider") {
|
|
266
|
+
return <ProviderPicker onDone={handleProviderDone} />;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (stage.type === "reading-files") {
|
|
270
|
+
return (
|
|
271
|
+
<Box marginTop={1} gap={1}>
|
|
272
|
+
<Text color={ACCENT}>
|
|
273
|
+
<Spinner />
|
|
274
|
+
</Text>
|
|
275
|
+
<Text>Reading codebase...</Text>
|
|
276
|
+
</Box>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (stage.type === "thinking") {
|
|
281
|
+
return (
|
|
282
|
+
<Box flexDirection="column" marginTop={1} gap={1}>
|
|
283
|
+
<ThinkingAboutStep prompt={userPrompt} />
|
|
284
|
+
{files.length > 0 && (
|
|
285
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
286
|
+
<Text color="gray">Using {files.length} files as context</Text>
|
|
287
|
+
</Box>
|
|
288
|
+
)}
|
|
289
|
+
</Box>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (stage.type === "preview") {
|
|
294
|
+
const { plan, diffLines, scrollOffset } = stage;
|
|
295
|
+
return (
|
|
296
|
+
<Box flexDirection="column" marginTop={1} gap={1}>
|
|
297
|
+
<Text bold color="cyan">
|
|
298
|
+
{figures.info} Proposed Changes
|
|
299
|
+
</Text>
|
|
300
|
+
<Text color="white">{plan.summary}</Text>
|
|
301
|
+
<Box flexDirection="column" marginTop={1}>
|
|
302
|
+
<Text color="gray">{plan.patches.length} file(s) to change:</Text>
|
|
303
|
+
{plan.patches.map((p) => (
|
|
304
|
+
<Text key={p.path} color={p.isNew ? "green" : "yellow"}>
|
|
305
|
+
{" "}
|
|
306
|
+
{p.isNew ? figures.tick : figures.bullet} {p.path}
|
|
307
|
+
{p.isNew && <Text color="gray"> (new)</Text>}
|
|
308
|
+
</Text>
|
|
309
|
+
))}
|
|
310
|
+
</Box>
|
|
311
|
+
<DiffViewer
|
|
312
|
+
patches={plan.patches}
|
|
313
|
+
diffs={diffLines}
|
|
314
|
+
scrollOffset={scrollOffset}
|
|
315
|
+
/>
|
|
316
|
+
<Text color="gray">↑↓ scroll · enter to apply · esc to cancel</Text>
|
|
317
|
+
</Box>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (stage.type === "applying") {
|
|
322
|
+
return (
|
|
323
|
+
<Box marginTop={1} gap={1}>
|
|
324
|
+
<Text color={ACCENT}>
|
|
325
|
+
<Spinner />
|
|
326
|
+
</Text>
|
|
327
|
+
<Text>Applying changes...</Text>
|
|
328
|
+
</Box>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (stage.type === "done") {
|
|
333
|
+
return (
|
|
334
|
+
<Box flexDirection="column" marginTop={1} gap={1}>
|
|
335
|
+
<Text bold color="green">
|
|
336
|
+
{figures.tick} Done
|
|
337
|
+
</Text>
|
|
338
|
+
<Text color="gray">{stage.applied.length} file(s) written</Text>
|
|
339
|
+
{stage.applied.map((f, i) => {
|
|
340
|
+
const isSelected = i === selectedIndex;
|
|
341
|
+
return (
|
|
342
|
+
<Box key={f.path} marginLeft={1}>
|
|
343
|
+
<Text color={isSelected ? "cyan" : "green"}>
|
|
344
|
+
{isSelected
|
|
345
|
+
? figures.arrowRight
|
|
346
|
+
: f.isNew
|
|
347
|
+
? figures.tick
|
|
348
|
+
: figures.bullet}{" "}
|
|
349
|
+
{f.path}
|
|
350
|
+
{f.isNew && <Text color="gray"> (new)</Text>}
|
|
351
|
+
{isSelected && <Text color="gray"> · enter to view diff</Text>}
|
|
352
|
+
</Text>
|
|
353
|
+
</Box>
|
|
354
|
+
);
|
|
355
|
+
})}
|
|
356
|
+
<Text color="gray">↑↓ navigate · enter to view diff · esc to exit</Text>
|
|
357
|
+
</Box>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (stage.type === "viewing-file") {
|
|
362
|
+
const { file, diffLines, scrollOffset } = stage;
|
|
363
|
+
return (
|
|
364
|
+
<Box flexDirection="column" marginTop={1} gap={1}>
|
|
365
|
+
<Box gap={1}>
|
|
366
|
+
<Text bold color="cyan">
|
|
367
|
+
{figures.info}
|
|
368
|
+
</Text>
|
|
369
|
+
<Text bold color="cyan">
|
|
370
|
+
{file.path}
|
|
371
|
+
</Text>
|
|
372
|
+
<Text color="gray">{file.isNew ? "(new file)" : "(modified)"}</Text>
|
|
373
|
+
</Box>
|
|
374
|
+
<DiffViewer
|
|
375
|
+
patches={[file.patch]}
|
|
376
|
+
diffs={[diffLines]}
|
|
377
|
+
scrollOffset={scrollOffset}
|
|
378
|
+
/>
|
|
379
|
+
<Text color="gray">↑↓ scroll · esc or enter to exit</Text>
|
|
380
|
+
</Box>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (stage.type === "error") {
|
|
385
|
+
return (
|
|
386
|
+
<Box flexDirection="column" marginTop={1} gap={1}>
|
|
387
|
+
<Text color="red">
|
|
388
|
+
{figures.cross} {stage.message}
|
|
389
|
+
</Text>
|
|
390
|
+
<Text color="gray">enter or esc to exit</Text>
|
|
391
|
+
</Box>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return null;
|
|
396
|
+
};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import type { Commit, DiffFile } from "../../utils/git";
|
|
4
|
+
|
|
5
|
+
const ACCENT = "#FF8C00";
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
commit: Commit | null;
|
|
9
|
+
diff: DiffFile[];
|
|
10
|
+
diffLoading: boolean;
|
|
11
|
+
diffScrollOffset: number;
|
|
12
|
+
showFullDiff: boolean;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function formatFullDate(dateStr: string): string {
|
|
18
|
+
try {
|
|
19
|
+
const d = new Date(dateStr);
|
|
20
|
+
return d.toLocaleDateString("en-US", {
|
|
21
|
+
weekday: "short",
|
|
22
|
+
year: "numeric",
|
|
23
|
+
month: "short",
|
|
24
|
+
day: "numeric",
|
|
25
|
+
hour: "2-digit",
|
|
26
|
+
minute: "2-digit",
|
|
27
|
+
});
|
|
28
|
+
} catch {
|
|
29
|
+
return dateStr;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function statusIcon(status: DiffFile["status"]): {
|
|
34
|
+
icon: string;
|
|
35
|
+
color: string;
|
|
36
|
+
} {
|
|
37
|
+
switch (status) {
|
|
38
|
+
case "added":
|
|
39
|
+
return { icon: "+", color: "green" };
|
|
40
|
+
case "deleted":
|
|
41
|
+
return { icon: "-", color: "red" };
|
|
42
|
+
case "renamed":
|
|
43
|
+
return { icon: "→", color: "yellow" };
|
|
44
|
+
default:
|
|
45
|
+
return { icon: "~", color: "cyan" };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function CommitDetail({
|
|
50
|
+
commit,
|
|
51
|
+
diff,
|
|
52
|
+
diffLoading,
|
|
53
|
+
diffScrollOffset,
|
|
54
|
+
showFullDiff,
|
|
55
|
+
width,
|
|
56
|
+
height,
|
|
57
|
+
}: Props) {
|
|
58
|
+
if (!commit) {
|
|
59
|
+
return (
|
|
60
|
+
<Box
|
|
61
|
+
width={width}
|
|
62
|
+
height={height}
|
|
63
|
+
flexDirection="column"
|
|
64
|
+
alignItems="center"
|
|
65
|
+
justifyContent="center"
|
|
66
|
+
>
|
|
67
|
+
<Text color="gray" dimColor>
|
|
68
|
+
select a commit to view details
|
|
69
|
+
</Text>
|
|
70
|
+
</Box>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const divider = "─".repeat(Math.max(0, width - 2));
|
|
75
|
+
|
|
76
|
+
// Build all diff lines for scrolling
|
|
77
|
+
const allDiffLines: Array<{
|
|
78
|
+
type: string;
|
|
79
|
+
content: string;
|
|
80
|
+
fileHeader?: string;
|
|
81
|
+
}> = [];
|
|
82
|
+
for (const file of diff) {
|
|
83
|
+
const { icon, color } = statusIcon(file.status);
|
|
84
|
+
allDiffLines.push({
|
|
85
|
+
type: "fileheader",
|
|
86
|
+
content: `${icon} ${file.path}`,
|
|
87
|
+
fileHeader: color,
|
|
88
|
+
});
|
|
89
|
+
allDiffLines.push({
|
|
90
|
+
type: "filestat",
|
|
91
|
+
content: ` +${file.insertions} -${file.deletions}`,
|
|
92
|
+
});
|
|
93
|
+
if (showFullDiff) {
|
|
94
|
+
for (const line of file.lines) {
|
|
95
|
+
allDiffLines.push({ type: line.type, content: line.content });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const visibleDiffLines = allDiffLines.slice(
|
|
101
|
+
diffScrollOffset,
|
|
102
|
+
diffScrollOffset + Math.max(1, height - 18),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Box width={width} flexDirection="column">
|
|
107
|
+
{/* ── Commit header ── */}
|
|
108
|
+
<Box paddingX={1} marginBottom={1}>
|
|
109
|
+
<Text color="gray" dimColor>
|
|
110
|
+
{divider}
|
|
111
|
+
</Text>
|
|
112
|
+
</Box>
|
|
113
|
+
|
|
114
|
+
<Box paddingX={1} gap={2}>
|
|
115
|
+
<Text color={ACCENT} bold>
|
|
116
|
+
◉ {commit.shortHash}
|
|
117
|
+
</Text>
|
|
118
|
+
{commit.parents.length > 1 && <Text color="magenta">merge commit</Text>}
|
|
119
|
+
{commit.refs && (
|
|
120
|
+
<Text color="yellow">
|
|
121
|
+
{commit.refs
|
|
122
|
+
.split(",")
|
|
123
|
+
.map((r) => r.trim())
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.slice(0, 2)
|
|
126
|
+
.join(" ")}
|
|
127
|
+
</Text>
|
|
128
|
+
)}
|
|
129
|
+
</Box>
|
|
130
|
+
|
|
131
|
+
{/* message */}
|
|
132
|
+
<Box paddingX={1} marginTop={1}>
|
|
133
|
+
<Text color="white" bold wrap="wrap">
|
|
134
|
+
{commit.message}
|
|
135
|
+
</Text>
|
|
136
|
+
</Box>
|
|
137
|
+
{commit.body && (
|
|
138
|
+
<Box paddingX={1} marginTop={1}>
|
|
139
|
+
<Text color="gray" wrap="wrap">
|
|
140
|
+
{commit.body}
|
|
141
|
+
</Text>
|
|
142
|
+
</Box>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{/* meta */}
|
|
146
|
+
<Box paddingX={1} marginTop={1} flexDirection="column" gap={0}>
|
|
147
|
+
<Box gap={2}>
|
|
148
|
+
<Text color="gray" dimColor>
|
|
149
|
+
author
|
|
150
|
+
</Text>
|
|
151
|
+
<Text color="cyan">{commit.author}</Text>
|
|
152
|
+
<Text color="gray" dimColor>
|
|
153
|
+
<{commit.email}>
|
|
154
|
+
</Text>
|
|
155
|
+
</Box>
|
|
156
|
+
<Box gap={2}>
|
|
157
|
+
<Text color="gray" dimColor>
|
|
158
|
+
date{" "}
|
|
159
|
+
</Text>
|
|
160
|
+
<Text color="white">{formatFullDate(commit.date)}</Text>
|
|
161
|
+
<Text color="gray" dimColor>
|
|
162
|
+
({commit.relativeDate})
|
|
163
|
+
</Text>
|
|
164
|
+
</Box>
|
|
165
|
+
{commit.parents.length > 0 && (
|
|
166
|
+
<Box gap={2}>
|
|
167
|
+
<Text color="gray" dimColor>
|
|
168
|
+
parent
|
|
169
|
+
</Text>
|
|
170
|
+
<Text color="gray">
|
|
171
|
+
{commit.parents.map((p) => p.slice(0, 7)).join(", ")}
|
|
172
|
+
</Text>
|
|
173
|
+
</Box>
|
|
174
|
+
)}
|
|
175
|
+
</Box>
|
|
176
|
+
|
|
177
|
+
{/* stats bar */}
|
|
178
|
+
<Box paddingX={1} marginTop={1} gap={3}>
|
|
179
|
+
<Text color="green">+{commit.insertions} insertions</Text>
|
|
180
|
+
<Text color="red">-{commit.deletions} deletions</Text>
|
|
181
|
+
<Text color="gray" dimColor>
|
|
182
|
+
{commit.filesChanged} file{commit.filesChanged !== 1 ? "s" : ""}{" "}
|
|
183
|
+
changed
|
|
184
|
+
</Text>
|
|
185
|
+
</Box>
|
|
186
|
+
|
|
187
|
+
{/* ── Diff section ── */}
|
|
188
|
+
<Box paddingX={1} marginTop={1}>
|
|
189
|
+
<Text color="gray" dimColor>
|
|
190
|
+
{divider}
|
|
191
|
+
</Text>
|
|
192
|
+
</Box>
|
|
193
|
+
|
|
194
|
+
<Box paddingX={1} marginBottom={1} gap={2}>
|
|
195
|
+
<Text color={ACCENT}>CHANGES</Text>
|
|
196
|
+
<Text color="gray" dimColor>
|
|
197
|
+
{showFullDiff ? "[d] collapse diff" : "[d] expand diff"}
|
|
198
|
+
</Text>
|
|
199
|
+
{diffLoading && (
|
|
200
|
+
<Text color="gray" dimColor>
|
|
201
|
+
loading…
|
|
202
|
+
</Text>
|
|
203
|
+
)}
|
|
204
|
+
</Box>
|
|
205
|
+
|
|
206
|
+
{/* diff lines */}
|
|
207
|
+
{visibleDiffLines.map((line, i) => {
|
|
208
|
+
if (line.type === "fileheader") {
|
|
209
|
+
return (
|
|
210
|
+
<Box key={i} paddingX={1}>
|
|
211
|
+
<Text color={line.fileHeader ?? "white"} bold>
|
|
212
|
+
{line.content}
|
|
213
|
+
</Text>
|
|
214
|
+
</Box>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (line.type === "filestat") {
|
|
218
|
+
return (
|
|
219
|
+
<Box key={i} paddingX={1}>
|
|
220
|
+
<Text color="gray" dimColor>
|
|
221
|
+
{line.content}
|
|
222
|
+
</Text>
|
|
223
|
+
</Box>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (line.type === "header") {
|
|
227
|
+
return (
|
|
228
|
+
<Box key={i} paddingX={1}>
|
|
229
|
+
<Text color="cyan" dimColor>
|
|
230
|
+
{line.content.slice(0, width - 4)}
|
|
231
|
+
</Text>
|
|
232
|
+
</Box>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (line.type === "add") {
|
|
236
|
+
return (
|
|
237
|
+
<Box key={i} paddingX={1}>
|
|
238
|
+
<Text color="green">
|
|
239
|
+
{"+"}
|
|
240
|
+
{line.content.slice(0, width - 5)}
|
|
241
|
+
</Text>
|
|
242
|
+
</Box>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (line.type === "remove") {
|
|
246
|
+
return (
|
|
247
|
+
<Box key={i} paddingX={1}>
|
|
248
|
+
<Text color="red">
|
|
249
|
+
{"-"}
|
|
250
|
+
{line.content.slice(0, width - 5)}
|
|
251
|
+
</Text>
|
|
252
|
+
</Box>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return (
|
|
256
|
+
<Box key={i} paddingX={1}>
|
|
257
|
+
<Text color="gray" dimColor>
|
|
258
|
+
{" "}
|
|
259
|
+
{line.content.slice(0, width - 5)}
|
|
260
|
+
</Text>
|
|
261
|
+
</Box>
|
|
262
|
+
);
|
|
263
|
+
})}
|
|
264
|
+
|
|
265
|
+
{allDiffLines.length > visibleDiffLines.length + diffScrollOffset && (
|
|
266
|
+
<Box paddingX={1} marginTop={1}>
|
|
267
|
+
<Text color="gray" dimColor>
|
|
268
|
+
↓ scroll diff with shift+↑↓
|
|
269
|
+
</Text>
|
|
270
|
+
</Box>
|
|
271
|
+
)}
|
|
272
|
+
</Box>
|
|
273
|
+
);
|
|
274
|
+
}
|