@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
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ridit/lens",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Know Your Codebase.",
|
|
5
|
+
"author": "Ridit Jangra <riditjangra09@gmail.com> (https://ridit.space)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/ridit-jangra/Lens"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"bin": {
|
|
14
|
+
"lens": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "bun build src/index.tsx --target bun --outfile dist/index.js",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"chalk": "^5.6.2",
|
|
22
|
+
"commander": "^14.0.3",
|
|
23
|
+
"figures": "^6.1.0",
|
|
24
|
+
"ink": "^6.8.0",
|
|
25
|
+
"ink-spinner": "^5.0.0",
|
|
26
|
+
"ink-text-input": "^6.0.0",
|
|
27
|
+
"nanoid": "^5.1.6",
|
|
28
|
+
"react": "^19.2.4",
|
|
29
|
+
"react-devtools-core": "^7.0.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/bun": "latest",
|
|
33
|
+
"@types/react": "^19.2.14"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"typescript": "^5"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/colors.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ACCENT = "#DA7758";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import figures from "figures";
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { ChatRunner } from "../components/chat/ChatRunner";
|
|
7
|
+
import { ACCENT } from "../colors";
|
|
8
|
+
|
|
9
|
+
export const ChatCommand = ({ path: inputPath }: { path: string }) => {
|
|
10
|
+
const resolvedPath = path.resolve(inputPath);
|
|
11
|
+
|
|
12
|
+
if (!existsSync(resolvedPath)) {
|
|
13
|
+
return (
|
|
14
|
+
<Box marginTop={1}>
|
|
15
|
+
<Text color="red">
|
|
16
|
+
{figures.cross} Path not found: {resolvedPath}
|
|
17
|
+
</Text>
|
|
18
|
+
</Box>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return <ChatRunner repoPath={resolvedPath} />;
|
|
23
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import figures from "figures";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { nanoid } from "nanoid";
|
|
5
|
+
import { addProvider, loadConfig } from "../utils/config";
|
|
6
|
+
import { ProviderTypeStep } from "../components/provider/ProviderTypeStep";
|
|
7
|
+
import { ApiKeyStep } from "../components/provider/ApiKeyStep";
|
|
8
|
+
import { ModelStep } from "../components/provider/ModelStep";
|
|
9
|
+
import { RemoveProviderStep } from "../components/provider/RemoveProviderStep";
|
|
10
|
+
import type { Provider, ProviderType } from "../types/config";
|
|
11
|
+
|
|
12
|
+
type InitStage =
|
|
13
|
+
| { type: "menu" }
|
|
14
|
+
| { type: "provider-type" }
|
|
15
|
+
| { type: "api-key"; providerType: ProviderType }
|
|
16
|
+
| { type: "base-url"; providerType: ProviderType; apiKey: string }
|
|
17
|
+
| {
|
|
18
|
+
type: "model";
|
|
19
|
+
providerType: ProviderType;
|
|
20
|
+
apiKey: string;
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
}
|
|
23
|
+
| { type: "remove" }
|
|
24
|
+
| { type: "done"; provider: Provider };
|
|
25
|
+
|
|
26
|
+
const MENU_OPTIONS = [
|
|
27
|
+
{ label: "Add a provider", action: "provider-type" },
|
|
28
|
+
{ label: "Remove a provider", action: "remove" },
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export const InitCommand = () => {
|
|
32
|
+
const [stage, setStage] = useState<InitStage>({ type: "menu" });
|
|
33
|
+
const [completedSteps, setCompletedSteps] = useState<string[]>([]);
|
|
34
|
+
const [menuIndex, setMenuIndex] = useState(0);
|
|
35
|
+
|
|
36
|
+
const pushStep = (label: string) => setCompletedSteps((s) => [...s, label]);
|
|
37
|
+
|
|
38
|
+
useInput((input, key) => {
|
|
39
|
+
if (stage.type !== "menu") return;
|
|
40
|
+
if (key.upArrow) setMenuIndex((i) => Math.max(0, i - 1));
|
|
41
|
+
if (key.downArrow)
|
|
42
|
+
setMenuIndex((i) => Math.min(MENU_OPTIONS.length - 1, i + 1));
|
|
43
|
+
if (key.return) {
|
|
44
|
+
const action = MENU_OPTIONS[menuIndex]?.action;
|
|
45
|
+
if (action === "provider-type") setStage({ type: "provider-type" });
|
|
46
|
+
if (action === "remove") setStage({ type: "remove" });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (stage.type === "menu") {
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
return (
|
|
53
|
+
<Box flexDirection="column" gap={1}>
|
|
54
|
+
{completedSteps.map((s, i) => (
|
|
55
|
+
<Text key={i} color="green">
|
|
56
|
+
{figures.tick} {s}
|
|
57
|
+
</Text>
|
|
58
|
+
))}
|
|
59
|
+
<Text bold color="cyan">
|
|
60
|
+
Lens — provider setup
|
|
61
|
+
</Text>
|
|
62
|
+
{config.providers.length > 0 && (
|
|
63
|
+
<Text color="gray">
|
|
64
|
+
{figures.info} {config.providers.length} provider(s) configured
|
|
65
|
+
</Text>
|
|
66
|
+
)}
|
|
67
|
+
{MENU_OPTIONS.map((opt, i) => (
|
|
68
|
+
<Box key={opt.action} marginLeft={1}>
|
|
69
|
+
<Text color={i === menuIndex ? "cyan" : "white"}>
|
|
70
|
+
{i === menuIndex ? figures.arrowRight : " "}
|
|
71
|
+
{" "}
|
|
72
|
+
{opt.label}
|
|
73
|
+
</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
))}
|
|
76
|
+
<Text color="gray">↑↓ navigate · enter to select</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (stage.type === "remove") {
|
|
82
|
+
return (
|
|
83
|
+
<Box flexDirection="column" gap={1}>
|
|
84
|
+
{completedSteps.map((s, i) => (
|
|
85
|
+
<Text key={i} color="green">
|
|
86
|
+
{figures.tick} {s}
|
|
87
|
+
</Text>
|
|
88
|
+
))}
|
|
89
|
+
<RemoveProviderStep onDone={() => setStage({ type: "menu" })} />
|
|
90
|
+
</Box>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (stage.type === "provider-type") {
|
|
95
|
+
return (
|
|
96
|
+
<Box flexDirection="column" gap={1}>
|
|
97
|
+
{completedSteps.map((s, i) => (
|
|
98
|
+
<Text key={i} color="green">
|
|
99
|
+
{figures.tick} {s}
|
|
100
|
+
</Text>
|
|
101
|
+
))}
|
|
102
|
+
<ProviderTypeStep
|
|
103
|
+
onSelect={(providerType) => {
|
|
104
|
+
pushStep(`Provider: ${providerType}`);
|
|
105
|
+
setStage({ type: "api-key", providerType });
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
</Box>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (stage.type === "api-key") {
|
|
113
|
+
return (
|
|
114
|
+
<Box flexDirection="column" gap={1}>
|
|
115
|
+
{completedSteps.map((s, i) => (
|
|
116
|
+
<Text key={i} color="green">
|
|
117
|
+
{figures.tick} {s}
|
|
118
|
+
</Text>
|
|
119
|
+
))}
|
|
120
|
+
<ApiKeyStep
|
|
121
|
+
providerType={stage.providerType}
|
|
122
|
+
onSubmit={(value) => {
|
|
123
|
+
if (stage.providerType === "custom") {
|
|
124
|
+
const { apiKey, baseUrl } = value as {
|
|
125
|
+
apiKey: string;
|
|
126
|
+
baseUrl?: string;
|
|
127
|
+
};
|
|
128
|
+
pushStep("API key saved");
|
|
129
|
+
if (baseUrl) pushStep(`Base URL: ${baseUrl}`);
|
|
130
|
+
setStage({
|
|
131
|
+
type: "model",
|
|
132
|
+
providerType: stage.providerType,
|
|
133
|
+
apiKey,
|
|
134
|
+
baseUrl,
|
|
135
|
+
});
|
|
136
|
+
} else if (stage.providerType === "ollama") {
|
|
137
|
+
pushStep(`Base URL: ${value}`);
|
|
138
|
+
setStage({
|
|
139
|
+
type: "model",
|
|
140
|
+
providerType: stage.providerType,
|
|
141
|
+
apiKey: "",
|
|
142
|
+
baseUrl: value as string,
|
|
143
|
+
});
|
|
144
|
+
} else {
|
|
145
|
+
pushStep("API key saved");
|
|
146
|
+
setStage({
|
|
147
|
+
type: "model",
|
|
148
|
+
providerType: stage.providerType,
|
|
149
|
+
apiKey: value as string,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
</Box>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (stage.type === "base-url") {
|
|
159
|
+
return (
|
|
160
|
+
<Box flexDirection="column" gap={1}>
|
|
161
|
+
{completedSteps.map((s, i) => (
|
|
162
|
+
<Text key={i} color="green">
|
|
163
|
+
{figures.tick} {s}
|
|
164
|
+
</Text>
|
|
165
|
+
))}
|
|
166
|
+
<ApiKeyStep
|
|
167
|
+
providerType="ollama"
|
|
168
|
+
onSubmit={(baseUrl) => {
|
|
169
|
+
pushStep(`Base URL: ${baseUrl}`);
|
|
170
|
+
setStage({
|
|
171
|
+
type: "model",
|
|
172
|
+
providerType: stage.providerType,
|
|
173
|
+
apiKey: stage.apiKey,
|
|
174
|
+
baseUrl: baseUrl as string,
|
|
175
|
+
});
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
</Box>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (stage.type === "model") {
|
|
183
|
+
return (
|
|
184
|
+
<Box flexDirection="column" gap={1}>
|
|
185
|
+
{completedSteps.map((s, i) => (
|
|
186
|
+
<Text key={i} color="green">
|
|
187
|
+
{figures.tick} {s}
|
|
188
|
+
</Text>
|
|
189
|
+
))}
|
|
190
|
+
<ModelStep
|
|
191
|
+
providerType={stage.providerType}
|
|
192
|
+
onSelect={(model) => {
|
|
193
|
+
const provider: Provider = {
|
|
194
|
+
id: nanoid(8),
|
|
195
|
+
type: stage.providerType,
|
|
196
|
+
name: `${stage.providerType}-${model}`,
|
|
197
|
+
apiKey: stage.apiKey || undefined,
|
|
198
|
+
baseUrl: stage.baseUrl,
|
|
199
|
+
model,
|
|
200
|
+
};
|
|
201
|
+
addProvider(provider);
|
|
202
|
+
pushStep(`Model: ${model}`);
|
|
203
|
+
setStage({ type: "done", provider });
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
</Box>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Box flexDirection="column" gap={1}>
|
|
212
|
+
{completedSteps.map((s, i) => (
|
|
213
|
+
<Text key={i} color="green">
|
|
214
|
+
{figures.tick} {s}
|
|
215
|
+
</Text>
|
|
216
|
+
))}
|
|
217
|
+
<Text color="green">{figures.tick} Provider configured successfully</Text>
|
|
218
|
+
<Text color="gray">
|
|
219
|
+
{figures.info} Run <Text color="cyan">lens init</Text> again to manage
|
|
220
|
+
providers.
|
|
221
|
+
</Text>
|
|
222
|
+
</Box>
|
|
223
|
+
);
|
|
224
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import figures from "figures";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import { startCloneRepo } from "../utils/repo";
|
|
7
|
+
import { fetchFileTree, readImportantFiles } from "../utils/files";
|
|
8
|
+
import { StepRow } from "../components/repo/StepRow";
|
|
9
|
+
import { FileReviewer } from "../components/repo/FileReviewer";
|
|
10
|
+
import { RepoAnalysis } from "../components/repo/RepoAnalysis";
|
|
11
|
+
import type { Step, ImportantFile } from "../types/repo";
|
|
12
|
+
|
|
13
|
+
export const RepoCommand = ({ url }: { url: string }) => {
|
|
14
|
+
const [steps, setSteps] = useState<Step[]>([
|
|
15
|
+
{ type: "cloning", status: "pending" },
|
|
16
|
+
]);
|
|
17
|
+
const [importantFiles, setImportantFiles] = useState<ImportantFile[]>([]);
|
|
18
|
+
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
19
|
+
const [repoPath, setRepoPath] = useState<string>("");
|
|
20
|
+
const [reviewDone, setReviewDone] = useState(false);
|
|
21
|
+
|
|
22
|
+
const updateLastStep = (updated: Step) =>
|
|
23
|
+
setSteps((prev) => [...prev.slice(0, -1), updated]);
|
|
24
|
+
|
|
25
|
+
const pushStep = (step: Step) => setSteps((prev) => [...prev, step]);
|
|
26
|
+
|
|
27
|
+
const handleCloneSuccess = (rPath: string) => {
|
|
28
|
+
setRepoPath(rPath);
|
|
29
|
+
updateLastStep({ type: "cloning", status: "done" });
|
|
30
|
+
pushStep({ type: "fetching-tree", status: "pending" });
|
|
31
|
+
|
|
32
|
+
fetchFileTree(rPath)
|
|
33
|
+
.then((files) => {
|
|
34
|
+
updateLastStep({ type: "fetching-tree", status: "done" });
|
|
35
|
+
pushStep({ type: "reading-files", status: "pending" });
|
|
36
|
+
setFileTree(files);
|
|
37
|
+
const found = readImportantFiles(rPath, files);
|
|
38
|
+
setImportantFiles(found);
|
|
39
|
+
updateLastStep({ type: "reading-files", status: "done" });
|
|
40
|
+
})
|
|
41
|
+
.catch(() => updateLastStep({ type: "fetching-tree", status: "done" }));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
startCloneRepo(url).then((result) => {
|
|
46
|
+
if (result.done) {
|
|
47
|
+
const repoName = path
|
|
48
|
+
.basename(new URL(url).pathname)
|
|
49
|
+
.replace(/\.git$/, "");
|
|
50
|
+
handleCloneSuccess(path.join(os.tmpdir(), repoName));
|
|
51
|
+
} else if (result.folderExists) {
|
|
52
|
+
updateLastStep({
|
|
53
|
+
type: "folder-exists",
|
|
54
|
+
status: "pending",
|
|
55
|
+
repoPath: result.repoPath,
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
updateLastStep({
|
|
59
|
+
type: "error",
|
|
60
|
+
message: result.error ?? "Unknown error",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}, [url]);
|
|
65
|
+
|
|
66
|
+
useInput((input) => {
|
|
67
|
+
const last = steps[steps.length - 1];
|
|
68
|
+
if (last?.type !== "folder-exists") return;
|
|
69
|
+
const rPath = last.repoPath;
|
|
70
|
+
|
|
71
|
+
if (input === "y" || input === "Y") {
|
|
72
|
+
updateLastStep({ type: "cloning", status: "pending" });
|
|
73
|
+
startCloneRepo(url, { forceReclone: true }).then((result) => {
|
|
74
|
+
if (result.done) {
|
|
75
|
+
handleCloneSuccess(rPath);
|
|
76
|
+
} else if (!result.folderExists) {
|
|
77
|
+
updateLastStep({
|
|
78
|
+
type: "error",
|
|
79
|
+
message: result.error ?? "Unknown error",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (input === "n" || input === "N") handleCloneSuccess(rPath);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const allDone =
|
|
89
|
+
steps[steps.length - 1]?.type === "reading-files" &&
|
|
90
|
+
(steps[steps.length - 1] as Extract<Step, { type: "reading-files" }>)
|
|
91
|
+
.status === "done";
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Box flexDirection="column">
|
|
95
|
+
{steps.map((step, i) => (
|
|
96
|
+
<StepRow key={i} step={step} />
|
|
97
|
+
))}
|
|
98
|
+
|
|
99
|
+
{allDone && !reviewDone && importantFiles.length > 0 && (
|
|
100
|
+
<FileReviewer
|
|
101
|
+
files={importantFiles}
|
|
102
|
+
onDone={() => setReviewDone(true)}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{allDone && importantFiles.length === 0 && !reviewDone && (
|
|
107
|
+
<Text color="gray">{figures.info} No important files found</Text>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{(reviewDone || (allDone && importantFiles.length === 0)) && (
|
|
111
|
+
<RepoAnalysis
|
|
112
|
+
repoUrl={url}
|
|
113
|
+
repoPath={repoPath}
|
|
114
|
+
fileTree={fileTree}
|
|
115
|
+
files={importantFiles}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
</Box>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import figures from "figures";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
7
|
+
import { fetchFileTree, readImportantFiles } from "../utils/files";
|
|
8
|
+
import { computeStats, formatNumber, topLanguages } from "../utils/stats";
|
|
9
|
+
import { RepoAnalysis } from "../components/repo/RepoAnalysis";
|
|
10
|
+
import { LensFileMenu } from "../components/repo/LensFileMenu";
|
|
11
|
+
import {
|
|
12
|
+
lensFileExists,
|
|
13
|
+
readLensFile,
|
|
14
|
+
lensFileToAnalysisResult,
|
|
15
|
+
} from "../utils/lensfile";
|
|
16
|
+
import type { ImportantFile } from "../types/repo";
|
|
17
|
+
import type { CodeStats } from "../utils/stats";
|
|
18
|
+
import type { LensMenuChoice } from "../components/repo/LensFileMenu";
|
|
19
|
+
|
|
20
|
+
type ReviewStage =
|
|
21
|
+
| { type: "scanning" }
|
|
22
|
+
| {
|
|
23
|
+
type: "lens-menu";
|
|
24
|
+
fileTree: string[];
|
|
25
|
+
files: ImportantFile[];
|
|
26
|
+
stats: CodeStats;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
type: "stats";
|
|
30
|
+
stats: CodeStats;
|
|
31
|
+
files: ImportantFile[];
|
|
32
|
+
fileTree: string[];
|
|
33
|
+
}
|
|
34
|
+
| { type: "error"; message: string };
|
|
35
|
+
|
|
36
|
+
function StatRow({ label, value }: { label: string; value: string }) {
|
|
37
|
+
const PAD = 20;
|
|
38
|
+
return (
|
|
39
|
+
<Box>
|
|
40
|
+
<Text color="gray">{label.padEnd(PAD, " ")}</Text>
|
|
41
|
+
<Text color="white" bold>
|
|
42
|
+
{value}
|
|
43
|
+
</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Divider() {
|
|
49
|
+
return <Text color="gray">{"─".repeat(36)}</Text>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SKIP_DIRS = new Set([
|
|
53
|
+
"node_modules",
|
|
54
|
+
".git",
|
|
55
|
+
"dist",
|
|
56
|
+
"build",
|
|
57
|
+
".next",
|
|
58
|
+
"out",
|
|
59
|
+
"coverage",
|
|
60
|
+
"__pycache__",
|
|
61
|
+
".venv",
|
|
62
|
+
"venv",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
function parseGitignore(dir: string): string[] {
|
|
66
|
+
const p = path.join(dir, ".gitignore");
|
|
67
|
+
if (!existsSync(p)) return [];
|
|
68
|
+
try {
|
|
69
|
+
return readFileSync(p, "utf-8")
|
|
70
|
+
.split("\n")
|
|
71
|
+
.map((l) => l.trim())
|
|
72
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function matchesGitignore(
|
|
79
|
+
patterns: string[],
|
|
80
|
+
relPath: string,
|
|
81
|
+
isDir: boolean,
|
|
82
|
+
): boolean {
|
|
83
|
+
const name = path.basename(relPath);
|
|
84
|
+
for (const pattern of patterns) {
|
|
85
|
+
if (pattern.endsWith("/")) {
|
|
86
|
+
if (isDir && name === pattern.slice(0, -1)) return true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (pattern.startsWith("!")) continue;
|
|
90
|
+
if (pattern.includes("*")) {
|
|
91
|
+
const regex = new RegExp(
|
|
92
|
+
"^" +
|
|
93
|
+
pattern
|
|
94
|
+
.replace(/\./g, "\\.")
|
|
95
|
+
.replace(/\*\*/g, ".*")
|
|
96
|
+
.replace(/\*/g, "[^/]*") +
|
|
97
|
+
"$",
|
|
98
|
+
);
|
|
99
|
+
if (regex.test(name) || regex.test(relPath)) return true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
name === pattern ||
|
|
104
|
+
relPath === pattern ||
|
|
105
|
+
relPath.startsWith(pattern + "/")
|
|
106
|
+
)
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function walkDir(dir: string, base = dir, patterns?: string[]): string[] {
|
|
113
|
+
const p = patterns ?? parseGitignore(base);
|
|
114
|
+
const results: string[] = [];
|
|
115
|
+
let entries: string[];
|
|
116
|
+
try {
|
|
117
|
+
entries = readdirSync(dir, { encoding: "utf-8" });
|
|
118
|
+
} catch {
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
123
|
+
const full = path.join(dir, entry);
|
|
124
|
+
const rel = path.relative(base, full).replace(/\\/g, "/");
|
|
125
|
+
let isDir = false;
|
|
126
|
+
try {
|
|
127
|
+
isDir = statSync(full).isDirectory();
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (matchesGitignore(p, rel, isDir)) continue;
|
|
132
|
+
if (isDir) results.push(...walkDir(full, base, p));
|
|
133
|
+
else results.push(rel);
|
|
134
|
+
}
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function StatsPanel({
|
|
139
|
+
resolvedPath,
|
|
140
|
+
stats,
|
|
141
|
+
}: {
|
|
142
|
+
resolvedPath: string;
|
|
143
|
+
stats: CodeStats;
|
|
144
|
+
}) {
|
|
145
|
+
const langs = topLanguages(stats.languages);
|
|
146
|
+
return (
|
|
147
|
+
<Box flexDirection="column" marginTop={1} gap={0}>
|
|
148
|
+
<Text bold color="cyan">
|
|
149
|
+
{figures.hamburger} {path.basename(resolvedPath)}
|
|
150
|
+
</Text>
|
|
151
|
+
<Divider />
|
|
152
|
+
<StatRow label="Lines of Code" value={formatNumber(stats.codeLines)} />
|
|
153
|
+
<StatRow label="Total Lines" value={formatNumber(stats.totalLines)} />
|
|
154
|
+
<StatRow label="Files" value={formatNumber(stats.totalFiles)} />
|
|
155
|
+
<StatRow label="Languages" value={langs || "—"} />
|
|
156
|
+
<StatRow label="Functions" value={formatNumber(stats.functions)} />
|
|
157
|
+
<StatRow label="Classes" value={formatNumber(stats.classes)} />
|
|
158
|
+
<StatRow label="Comment Lines" value={formatNumber(stats.commentLines)} />
|
|
159
|
+
<StatRow label="Blank Lines" value={formatNumber(stats.blankLines)} />
|
|
160
|
+
<Divider />
|
|
161
|
+
</Box>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const ReviewCommand = ({
|
|
166
|
+
path: inputPath,
|
|
167
|
+
onExit,
|
|
168
|
+
}: {
|
|
169
|
+
path: string;
|
|
170
|
+
onExit?: () => void;
|
|
171
|
+
}) => {
|
|
172
|
+
const [stage, setStage] = useState<ReviewStage>({ type: "scanning" });
|
|
173
|
+
|
|
174
|
+
const [preloadedResult, setPreloadedResult] = useState<
|
|
175
|
+
import("../types/repo").AnalysisResult | null
|
|
176
|
+
>(null);
|
|
177
|
+
const resolvedPath = path.resolve(inputPath);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!existsSync(resolvedPath)) {
|
|
181
|
+
setStage({ type: "error", message: `Path not found: ${resolvedPath}` });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fetchFileTree(resolvedPath)
|
|
186
|
+
.catch(() => walkDir(resolvedPath))
|
|
187
|
+
.then((fileTree) => {
|
|
188
|
+
const stats = computeStats(resolvedPath, fileTree);
|
|
189
|
+
const files = readImportantFiles(resolvedPath, fileTree);
|
|
190
|
+
|
|
191
|
+
if (lensFileExists(resolvedPath)) {
|
|
192
|
+
setStage({ type: "lens-menu", fileTree, files, stats });
|
|
193
|
+
} else {
|
|
194
|
+
setStage({ type: "stats", stats, files, fileTree });
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
.catch((err: unknown) =>
|
|
198
|
+
setStage({
|
|
199
|
+
type: "error",
|
|
200
|
+
message: err instanceof Error ? err.message : "Failed to scan",
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}, [resolvedPath]);
|
|
204
|
+
|
|
205
|
+
const handleLensChoice = (
|
|
206
|
+
choice: LensMenuChoice,
|
|
207
|
+
fileTree: string[],
|
|
208
|
+
files: ImportantFile[],
|
|
209
|
+
stats: CodeStats,
|
|
210
|
+
) => {
|
|
211
|
+
const lf = readLensFile(resolvedPath);
|
|
212
|
+
|
|
213
|
+
if (choice === "use-cached" && lf) {
|
|
214
|
+
setPreloadedResult(lensFileToAnalysisResult(lf));
|
|
215
|
+
setStage({ type: "stats", stats, files, fileTree });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (choice === "fix-issues" && lf) {
|
|
220
|
+
setPreloadedResult(lensFileToAnalysisResult(lf));
|
|
221
|
+
setStage({ type: "stats", stats, files, fileTree });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (choice === "security" && lf) {
|
|
226
|
+
setPreloadedResult(lensFileToAnalysisResult(lf));
|
|
227
|
+
setStage({ type: "stats", stats, files, fileTree });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setStage({ type: "stats", stats, files, fileTree });
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (stage.type === "scanning") {
|
|
235
|
+
return (
|
|
236
|
+
<Box marginTop={1} gap={1}>
|
|
237
|
+
<Text color="cyan">{figures.pointer}</Text>
|
|
238
|
+
<Text>Scanning codebase...</Text>
|
|
239
|
+
</Box>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (stage.type === "error") {
|
|
244
|
+
return (
|
|
245
|
+
<Box marginTop={1}>
|
|
246
|
+
<Text color="red">
|
|
247
|
+
{figures.cross} {stage.message}
|
|
248
|
+
</Text>
|
|
249
|
+
</Box>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (stage.type === "lens-menu") {
|
|
254
|
+
const lf = readLensFile(resolvedPath);
|
|
255
|
+
if (!lf) {
|
|
256
|
+
setStage({
|
|
257
|
+
type: "stats",
|
|
258
|
+
stats: stage.stats,
|
|
259
|
+
files: stage.files,
|
|
260
|
+
fileTree: stage.fileTree,
|
|
261
|
+
});
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const { fileTree, files, stats } = stage;
|
|
265
|
+
return (
|
|
266
|
+
<Box flexDirection="column" gap={1}>
|
|
267
|
+
<StatsPanel resolvedPath={resolvedPath} stats={stats} />
|
|
268
|
+
<LensFileMenu
|
|
269
|
+
repoPath={resolvedPath}
|
|
270
|
+
lensFile={lf}
|
|
271
|
+
onChoice={(choice) =>
|
|
272
|
+
handleLensChoice(choice, fileTree, files, stats)
|
|
273
|
+
}
|
|
274
|
+
/>
|
|
275
|
+
</Box>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const { stats, files, fileTree } = stage;
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<Box flexDirection="column" gap={1}>
|
|
283
|
+
<StatsPanel resolvedPath={resolvedPath} stats={stats} />
|
|
284
|
+
<RepoAnalysis
|
|
285
|
+
repoUrl={resolvedPath}
|
|
286
|
+
repoPath={resolvedPath}
|
|
287
|
+
fileTree={fileTree}
|
|
288
|
+
files={files}
|
|
289
|
+
preloadedResult={preloadedResult ?? undefined}
|
|
290
|
+
onExit={onExit}
|
|
291
|
+
/>
|
|
292
|
+
</Box>
|
|
293
|
+
);
|
|
294
|
+
};
|