pi-jscpd 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/CHANGELOG.md +99 -0
- package/CONTRIBUTING.md +144 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/SECURITY.md +93 -0
- package/docs/automatic-checkpoint.md +235 -0
- package/docs/compatibility.md +119 -0
- package/docs/effect-architecture.md +128 -0
- package/docs/fallow-coexistence.md +120 -0
- package/docs/overlay-interaction.md +347 -0
- package/docs/release.md +115 -0
- package/package.json +86 -0
- package/scripts/check-compatibility.mjs +103 -0
- package/skills/jscpd/SKILL.md +90 -0
- package/src/acknowledgements.ts +268 -0
- package/src/automatic.ts +396 -0
- package/src/baseline.ts +400 -0
- package/src/capability.ts +569 -0
- package/src/changed-files.ts +372 -0
- package/src/changed.ts +548 -0
- package/src/clone-identity.ts +373 -0
- package/src/config.ts +414 -0
- package/src/contract.ts +39 -0
- package/src/dispatch.ts +90 -0
- package/src/effect/clock.ts +10 -0
- package/src/effect/errors.ts +311 -0
- package/src/effect/filesystem.ts +240 -0
- package/src/effect/runtime-boundary.ts +25 -0
- package/src/effect/runtime-contract.ts +18 -0
- package/src/effect/services.ts +131 -0
- package/src/extension.ts +708 -0
- package/src/fallow.ts +479 -0
- package/src/finding-presentation.ts +73 -0
- package/src/index.ts +8 -0
- package/src/jscpd-report.ts +819 -0
- package/src/jscpd.ts +748 -0
- package/src/overlay.ts +1166 -0
- package/src/parser.ts +189 -0
- package/src/path-utils.ts +44 -0
- package/src/presentation.ts +232 -0
- package/src/process.ts +425 -0
- package/src/registry.ts +102 -0
- package/src/scan.ts +441 -0
- package/src/scheduler.ts +434 -0
- package/src/session-state.ts +229 -0
- package/src/status.ts +534 -0
- package/src/types.ts +334 -0
- package/src/value-utils.ts +14 -0
- package/src/verification.ts +220 -0
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type Focusable,
|
|
5
|
+
Input,
|
|
6
|
+
type KeybindingsManager,
|
|
7
|
+
matchesKey,
|
|
8
|
+
type TUI,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
wrapTextWithAnsi,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
import {
|
|
14
|
+
jscpdFindingDetailLines,
|
|
15
|
+
jscpdFindingGuidance,
|
|
16
|
+
jscpdFindingLocations,
|
|
17
|
+
} from "./finding-presentation.js";
|
|
18
|
+
import type {
|
|
19
|
+
JscpdChangedFinding,
|
|
20
|
+
JscpdCommand,
|
|
21
|
+
JscpdCommandInvocation,
|
|
22
|
+
JscpdExecutionContext,
|
|
23
|
+
JscpdExecutionResult,
|
|
24
|
+
JscpdPresentedFinding,
|
|
25
|
+
JscpdStatusResult,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
|
|
28
|
+
/** Promise execution is confined to the Pi UI adapter, never a domain service. */
|
|
29
|
+
export interface JscpdOverlayExecutor {
|
|
30
|
+
execute(
|
|
31
|
+
invocation: JscpdCommandInvocation,
|
|
32
|
+
context: JscpdExecutionContext,
|
|
33
|
+
): Promise<JscpdExecutionResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const FILTER_LIMIT = 256;
|
|
37
|
+
const JSCPD_OVERLAY_FINDING_LIMIT = 100;
|
|
38
|
+
const OVERLAY_FINDING_PAGE_SIZE = 10;
|
|
39
|
+
const PROMPT_FINDING_LIMIT = 20;
|
|
40
|
+
const PROMPT_CHARACTER_LIMIT = 12_000;
|
|
41
|
+
const FALLBACK_ACTIONS = "Use /jscpd changed, /jscpd scan, /jscpd off|on, or /jscpd help.";
|
|
42
|
+
const FALLBACK_PREFIX = "The /jscpd overlay requires Pi TUI mode.";
|
|
43
|
+
|
|
44
|
+
export interface JscpdOverlayLauncher {
|
|
45
|
+
open(context: ExtensionCommandContext): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface JscpdOverlayLauncherOptions {
|
|
49
|
+
readonly changedFileCount?: () => number;
|
|
50
|
+
readonly writeFallback?: (text: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface JscpdOverlayPromptResult {
|
|
54
|
+
readonly type: "prompt";
|
|
55
|
+
readonly prompt: string;
|
|
56
|
+
readonly findingCount: number;
|
|
57
|
+
readonly omittedSelectionCount: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface JscpdOverlayComponentOptions {
|
|
61
|
+
readonly tui: TUI;
|
|
62
|
+
readonly theme: Theme;
|
|
63
|
+
readonly keybindings: KeybindingsManager;
|
|
64
|
+
readonly executor: JscpdOverlayExecutor;
|
|
65
|
+
readonly cwd: string;
|
|
66
|
+
readonly signal?: AbortSignal;
|
|
67
|
+
readonly changedFileCount: () => number;
|
|
68
|
+
readonly done: (result: JscpdOverlayPromptResult | null) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type OverlayView = "overview" | "findings" | "help";
|
|
72
|
+
type OverlayPhase = "loading" | "ready" | "running" | "cancelling";
|
|
73
|
+
type ScanKind = "changed" | "scan";
|
|
74
|
+
type RunnableCommand = "status" | "changed" | "scan" | "on" | "off";
|
|
75
|
+
type OverlayAction = "changed" | "scan" | "findings" | "status" | "toggle" | "help";
|
|
76
|
+
type OverlayFinding = JscpdChangedFinding | JscpdPresentedFinding;
|
|
77
|
+
|
|
78
|
+
interface ActionItem {
|
|
79
|
+
readonly action: OverlayAction;
|
|
80
|
+
readonly label: string;
|
|
81
|
+
readonly description: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface FindingEntry {
|
|
85
|
+
readonly id: number;
|
|
86
|
+
readonly ordinal: number;
|
|
87
|
+
readonly finding: OverlayFinding;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createJscpdOverlayLauncher(
|
|
91
|
+
executor: JscpdOverlayExecutor,
|
|
92
|
+
options: JscpdOverlayLauncherOptions = {},
|
|
93
|
+
): JscpdOverlayLauncher {
|
|
94
|
+
const changedFileCount = options.changedFileCount ?? (() => 0);
|
|
95
|
+
const writeFallback =
|
|
96
|
+
options.writeFallback ?? ((text: string) => process.stderr.write(`${text}\n`));
|
|
97
|
+
return {
|
|
98
|
+
async open(context) {
|
|
99
|
+
if (context.mode !== "tui") {
|
|
100
|
+
await openNonTuiFallback(executor, context, writeFallback);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const result = await context.ui.custom<JscpdOverlayPromptResult | null>(
|
|
104
|
+
(tui, theme, keybindings, done) =>
|
|
105
|
+
new JscpdOverlayComponent({
|
|
106
|
+
tui,
|
|
107
|
+
theme,
|
|
108
|
+
keybindings,
|
|
109
|
+
executor,
|
|
110
|
+
cwd: context.cwd,
|
|
111
|
+
signal: context.signal,
|
|
112
|
+
changedFileCount,
|
|
113
|
+
done,
|
|
114
|
+
}),
|
|
115
|
+
{
|
|
116
|
+
overlay: true,
|
|
117
|
+
overlayOptions: {
|
|
118
|
+
anchor: "center",
|
|
119
|
+
width: "90%",
|
|
120
|
+
minWidth: 50,
|
|
121
|
+
maxHeight: "95%",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
if (result?.type !== "prompt") return;
|
|
126
|
+
context.ui.setEditorText(result.prompt);
|
|
127
|
+
const omitted = result.omittedSelectionCount
|
|
128
|
+
? ` ${counted(result.omittedSelectionCount, "additional selection")} omitted by the prompt limit.`
|
|
129
|
+
: "";
|
|
130
|
+
context.ui.notify(
|
|
131
|
+
`Loaded ${counted(result.findingCount, "jscpd duplicate block")} into the editor. Add comments, then submit when ready.${omitted}`,
|
|
132
|
+
"info",
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function openNonTuiFallback(
|
|
139
|
+
executor: JscpdOverlayExecutor,
|
|
140
|
+
context: ExtensionCommandContext,
|
|
141
|
+
writeFallback: (text: string) => void,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const status = await safeExecute(executor, "status", context.cwd, context.signal);
|
|
144
|
+
const text = [FALLBACK_PREFIX, executionMessage(status), FALLBACK_ACTIONS].join("\n");
|
|
145
|
+
if (context.mode === "rpc") {
|
|
146
|
+
context.ui.notify(text, statusLevel(status));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
writeFallback(text);
|
|
151
|
+
} catch {
|
|
152
|
+
// A closed diagnostic stream must not make a non-TUI command fail.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export class JscpdOverlayComponent implements Component, Focusable {
|
|
157
|
+
readonly #tui: TUI;
|
|
158
|
+
readonly #theme: Theme;
|
|
159
|
+
readonly #keybindings: KeybindingsManager;
|
|
160
|
+
readonly #executor: JscpdOverlayExecutor;
|
|
161
|
+
readonly #cwd: string;
|
|
162
|
+
readonly #outerSignal?: AbortSignal;
|
|
163
|
+
readonly #changedFileCount: () => number;
|
|
164
|
+
readonly #done: (result: JscpdOverlayPromptResult | null) => void;
|
|
165
|
+
readonly #filter = new Input();
|
|
166
|
+
#focused = false;
|
|
167
|
+
#view: OverlayView = "overview";
|
|
168
|
+
#phase: OverlayPhase = "loading";
|
|
169
|
+
#status?: JscpdStatusResult;
|
|
170
|
+
#result?: JscpdExecutionResult;
|
|
171
|
+
#actionIndex = 0;
|
|
172
|
+
#findingIndex = 0;
|
|
173
|
+
#scrollStart = 0;
|
|
174
|
+
#revealedFindingCount = OVERLAY_FINDING_PAGE_SIZE;
|
|
175
|
+
#expandedFinding?: number;
|
|
176
|
+
readonly #markedFindings = new Set<number>();
|
|
177
|
+
#filtering = false;
|
|
178
|
+
#searchBeforeEdit = "";
|
|
179
|
+
#active?: AbortController;
|
|
180
|
+
#activeCommand?: RunnableCommand;
|
|
181
|
+
#operationToken = 0;
|
|
182
|
+
#lastScan?: ScanKind;
|
|
183
|
+
#closeAfterRun = false;
|
|
184
|
+
#disposed = false;
|
|
185
|
+
#closed = false;
|
|
186
|
+
|
|
187
|
+
constructor(options: JscpdOverlayComponentOptions) {
|
|
188
|
+
this.#tui = options.tui;
|
|
189
|
+
this.#theme = options.theme;
|
|
190
|
+
this.#keybindings = options.keybindings;
|
|
191
|
+
this.#executor = options.executor;
|
|
192
|
+
this.#cwd = options.cwd;
|
|
193
|
+
this.#outerSignal = options.signal;
|
|
194
|
+
this.#changedFileCount = options.changedFileCount;
|
|
195
|
+
this.#done = options.done;
|
|
196
|
+
this.#outerSignal?.addEventListener("abort", this.#handleOuterAbort, { once: true });
|
|
197
|
+
void this.#run("status");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
get focused(): boolean {
|
|
201
|
+
return this.#focused;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
set focused(value: boolean) {
|
|
205
|
+
this.#focused = value;
|
|
206
|
+
this.#filter.focused = value && this.#filtering;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
handleInput(data: string): void {
|
|
210
|
+
if (this.#disposed) return;
|
|
211
|
+
if (this.#filtering) {
|
|
212
|
+
this.#handleFilterInput(data);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (this.#isBusy()) {
|
|
216
|
+
this.#handleBusyInput(data);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (this.#handleIdleGlobalInput(data)) return;
|
|
220
|
+
if (this.#handleViewShortcut(data)) return;
|
|
221
|
+
this.#handleNavigationInput(data);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
render(width: number): string[] {
|
|
225
|
+
const safeWidth = Math.max(1, width);
|
|
226
|
+
const terminalRows = Math.max(1, this.#tui.terminal.rows || 10);
|
|
227
|
+
const maxRows = Math.max(
|
|
228
|
+
1,
|
|
229
|
+
Math.min(terminalRows, Math.max(5, Math.floor(terminalRows * 0.95))),
|
|
230
|
+
);
|
|
231
|
+
if (safeWidth < 4 || maxRows < 4) {
|
|
232
|
+
return [truncateToWidth(`pi-jscpd · ${this.#compactTitle()}`, safeWidth, "")].slice(
|
|
233
|
+
0,
|
|
234
|
+
maxRows,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const innerWidth = safeWidth - 4;
|
|
239
|
+
const bodyRows = Math.max(1, maxRows - 4);
|
|
240
|
+
const body = this.#bodyLines(innerWidth, bodyRows).slice(0, bodyRows);
|
|
241
|
+
return [
|
|
242
|
+
this.#topBorder(safeWidth, this.#headerTitle()),
|
|
243
|
+
...body.map((line) => this.#frame(line, safeWidth)),
|
|
244
|
+
this.#separator(safeWidth),
|
|
245
|
+
this.#frame(this.#footer(), safeWidth),
|
|
246
|
+
this.#bottomBorder(safeWidth),
|
|
247
|
+
].slice(0, maxRows);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
invalidate(): void {
|
|
251
|
+
this.#filter.invalidate();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
dispose(): void {
|
|
255
|
+
if (this.#disposed) return;
|
|
256
|
+
this.#disposed = true;
|
|
257
|
+
this.#operationToken += 1;
|
|
258
|
+
this.#active?.abort();
|
|
259
|
+
this.#active = undefined;
|
|
260
|
+
this.#activeCommand = undefined;
|
|
261
|
+
this.#outerSignal?.removeEventListener("abort", this.#handleOuterAbort);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#handleOuterAbort = (): void => {
|
|
265
|
+
this.#closeAfterRun = true;
|
|
266
|
+
if (this.#active) this.#cancel(true);
|
|
267
|
+
else this.#close();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
async #run(command: RunnableCommand): Promise<void> {
|
|
271
|
+
if (this.#active || this.#disposed) return;
|
|
272
|
+
const controller = new AbortController();
|
|
273
|
+
const token = this.#beginOperation(command, controller);
|
|
274
|
+
const result = await safeExecute(
|
|
275
|
+
this.#executor,
|
|
276
|
+
command,
|
|
277
|
+
this.#cwd,
|
|
278
|
+
this.#combinedSignal(controller.signal),
|
|
279
|
+
JSCPD_OVERLAY_FINDING_LIMIT,
|
|
280
|
+
);
|
|
281
|
+
if (!this.#operationIsCurrent(token)) return;
|
|
282
|
+
this.#active = undefined;
|
|
283
|
+
this.#activeCommand = undefined;
|
|
284
|
+
this.#phase = "ready";
|
|
285
|
+
if (this.#closeAfterRun) {
|
|
286
|
+
this.#close();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.#acceptResult(command, result);
|
|
290
|
+
this.#renderNow();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#beginOperation(command: RunnableCommand, controller: AbortController): number {
|
|
294
|
+
const token = ++this.#operationToken;
|
|
295
|
+
this.#active = controller;
|
|
296
|
+
this.#activeCommand = command;
|
|
297
|
+
this.#phase = command === "status" && !this.#status ? "loading" : "running";
|
|
298
|
+
if (command === "scan" || command === "changed") this.#lastScan = command;
|
|
299
|
+
this.#renderNow();
|
|
300
|
+
return token;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#operationIsCurrent(token: number): boolean {
|
|
304
|
+
return !this.#disposed && token === this.#operationToken;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#acceptResult(command: RunnableCommand, result: JscpdExecutionResult): void {
|
|
308
|
+
if (command === "status") {
|
|
309
|
+
if (result.status === "status") this.#status = result;
|
|
310
|
+
else this.#result = result;
|
|
311
|
+
}
|
|
312
|
+
if (command === "changed" || command === "scan") {
|
|
313
|
+
this.#result = result;
|
|
314
|
+
this.#findingIndex = 0;
|
|
315
|
+
this.#scrollStart = 0;
|
|
316
|
+
this.#revealedFindingCount = OVERLAY_FINDING_PAGE_SIZE;
|
|
317
|
+
this.#expandedFinding = undefined;
|
|
318
|
+
this.#markedFindings.clear();
|
|
319
|
+
this.#filtering = false;
|
|
320
|
+
this.#filter.setValue("");
|
|
321
|
+
this.#view = resultFindings(result).length > 0 ? "findings" : "overview";
|
|
322
|
+
}
|
|
323
|
+
if (command === "on" || command === "off") void this.#run("status");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#combinedSignal(owned: AbortSignal): AbortSignal {
|
|
327
|
+
return this.#outerSignal ? AbortSignal.any([owned, this.#outerSignal]) : owned;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#cancel(closeAfterRun: boolean): void {
|
|
331
|
+
if (!this.#active) {
|
|
332
|
+
if (closeAfterRun) this.#close();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.#closeAfterRun ||= closeAfterRun;
|
|
336
|
+
this.#phase = "cancelling";
|
|
337
|
+
this.#active.abort();
|
|
338
|
+
this.#renderNow();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#close(result: JscpdOverlayPromptResult | null = null): void {
|
|
342
|
+
if (this.#closed) return;
|
|
343
|
+
this.#closed = true;
|
|
344
|
+
this.dispose();
|
|
345
|
+
this.#done(result);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#backOrClose(): void {
|
|
349
|
+
if (this.#view === "overview") {
|
|
350
|
+
this.#close();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
this.#view = "overview";
|
|
354
|
+
this.#expandedFinding = undefined;
|
|
355
|
+
this.#renderNow();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#isBusy(): boolean {
|
|
359
|
+
return this.#phase !== "ready";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#handleBusyInput(data: string): void {
|
|
363
|
+
if (this.#isCancel(data)) this.#cancel(false);
|
|
364
|
+
else if (matchesKey(data, "q") || matchesKey(data, "ctrl+c")) this.#cancel(true);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#handleIdleGlobalInput(data: string): boolean {
|
|
368
|
+
if (this.#isCancel(data)) {
|
|
369
|
+
this.#backOrClose();
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
if (matchesKey(data, "q") || matchesKey(data, "ctrl+c")) {
|
|
373
|
+
this.#close();
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
if (matchesKey(data, "?")) {
|
|
377
|
+
this.#view = "help";
|
|
378
|
+
this.#renderNow();
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
if (matchesKey(data, "shift+tab") && this.#view !== "overview") {
|
|
382
|
+
this.#view = "overview";
|
|
383
|
+
this.#renderNow();
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
if (matchesKey(data, "tab") && this.#view === "overview") {
|
|
387
|
+
if (resultFindings(this.#result).length > 0) {
|
|
388
|
+
this.#view = "findings";
|
|
389
|
+
this.#renderNow();
|
|
390
|
+
}
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#handleViewShortcut(data: string): boolean {
|
|
397
|
+
if (this.#view === "overview") return this.#handleOverviewShortcut(data);
|
|
398
|
+
if (this.#view === "findings") return this.#handleFindingsShortcut(data);
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
#handleOverviewShortcut(data: string): boolean {
|
|
403
|
+
if (matchesKey(data, "r")) {
|
|
404
|
+
void this.#run(this.#lastScan ?? "status");
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
if (matchesKey(data, "o")) {
|
|
408
|
+
void this.#run(this.#status?.mode === "disabled" ? "on" : "off");
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
if (matchesKey(data, "c") && this.#canScan()) {
|
|
412
|
+
void this.#run("changed");
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
if (matchesKey(data, "s") && this.#canScan()) {
|
|
416
|
+
void this.#run("scan");
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#handleFindingsShortcut(data: string): boolean {
|
|
423
|
+
if (matchesKey(data, "r")) {
|
|
424
|
+
void this.#run(this.#lastScan ?? "scan");
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
if (data === "L") {
|
|
428
|
+
this.#loadNextFindings();
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
if (matchesKey(data, "/")) {
|
|
432
|
+
this.#startFiltering();
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
if (matchesKey(data, "x")) {
|
|
436
|
+
this.#clearFilter();
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
if (matchesKey(data, "s") || matchesKey(data, "tab")) {
|
|
440
|
+
this.#toggleMarked();
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
if (data === "A") {
|
|
444
|
+
this.#toggleAllVisible();
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
if (matchesKey(data, "c")) {
|
|
448
|
+
this.#clearMarked();
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
if (matchesKey(data, "e") || matchesKey(data, "a")) {
|
|
452
|
+
this.#finishWithPrompt();
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#handleNavigationInput(data: string): void {
|
|
459
|
+
if (this.#isUp(data)) this.#move(-1);
|
|
460
|
+
else if (this.#isDown(data)) this.#move(1);
|
|
461
|
+
else if (this.#keybindings.matches(data, "tui.select.pageUp")) this.#move(-this.#pageSize());
|
|
462
|
+
else if (this.#keybindings.matches(data, "tui.select.pageDown")) this.#move(this.#pageSize());
|
|
463
|
+
else if (matchesKey(data, "home")) this.#moveToBoundary("start");
|
|
464
|
+
else if (matchesKey(data, "end")) this.#moveToBoundary("end");
|
|
465
|
+
else if (
|
|
466
|
+
this.#keybindings.matches(data, "tui.select.confirm") ||
|
|
467
|
+
matchesKey(data, "return") ||
|
|
468
|
+
matchesKey(data, "space") ||
|
|
469
|
+
matchesKey(data, "right") ||
|
|
470
|
+
matchesKey(data, "l")
|
|
471
|
+
)
|
|
472
|
+
this.#activate();
|
|
473
|
+
else if (matchesKey(data, "left") || matchesKey(data, "h")) this.#collapseCurrent();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
#startFiltering(): void {
|
|
477
|
+
this.#searchBeforeEdit = this.#filter.getValue();
|
|
478
|
+
this.#filtering = true;
|
|
479
|
+
this.#filter.focused = this.#focused;
|
|
480
|
+
this.#renderNow();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#handleFilterInput(data: string): void {
|
|
484
|
+
if (this.#isCancel(data)) {
|
|
485
|
+
this.#filter.setValue(this.#searchBeforeEdit);
|
|
486
|
+
this.#finishFilterEdit();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (matchesKey(data, "return")) {
|
|
490
|
+
this.#finishFilterEdit();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (matchesKey(data, "ctrl+u")) this.#filter.setValue("");
|
|
494
|
+
else this.#filter.handleInput(data);
|
|
495
|
+
const bounded = Array.from(this.#filter.getValue()).slice(0, FILTER_LIMIT).join("");
|
|
496
|
+
if (bounded !== this.#filter.getValue()) this.#filter.setValue(bounded);
|
|
497
|
+
this.#resetFindingViewport();
|
|
498
|
+
this.#renderNow();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#finishFilterEdit(): void {
|
|
502
|
+
this.#filtering = false;
|
|
503
|
+
this.#filter.focused = false;
|
|
504
|
+
this.#resetFindingViewport();
|
|
505
|
+
this.#renderNow();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#clearFilter(): void {
|
|
509
|
+
if (!this.#filter.getValue()) return;
|
|
510
|
+
this.#filter.setValue("");
|
|
511
|
+
this.#resetFindingViewport();
|
|
512
|
+
this.#renderNow();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#resetFindingViewport(): void {
|
|
516
|
+
this.#findingIndex = 0;
|
|
517
|
+
this.#scrollStart = 0;
|
|
518
|
+
this.#expandedFinding = undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#actions(): readonly ActionItem[] {
|
|
522
|
+
const actions: ActionItem[] = [];
|
|
523
|
+
if (this.#canScan()) {
|
|
524
|
+
actions.push(
|
|
525
|
+
{
|
|
526
|
+
action: "changed",
|
|
527
|
+
label: "Check session changes",
|
|
528
|
+
description: "new duplicate blocks in tracked edits",
|
|
529
|
+
},
|
|
530
|
+
{ action: "scan", label: "Scan project", description: "all current duplicate blocks" },
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
if (resultFindings(this.#result).length > 0) {
|
|
534
|
+
actions.push({
|
|
535
|
+
action: "findings",
|
|
536
|
+
label: `View ${counted(resultFindings(this.#result).length, "finding")}`,
|
|
537
|
+
description: "browse the current in-memory result",
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
actions.push(
|
|
541
|
+
{
|
|
542
|
+
action: "status",
|
|
543
|
+
label: "Refresh status",
|
|
544
|
+
description: "probe readiness without scanning",
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
action: "toggle",
|
|
548
|
+
label: this.#status?.mode === "disabled" ? "Enable for session" : "Disable for session",
|
|
549
|
+
description: "session only; no configuration write",
|
|
550
|
+
},
|
|
551
|
+
{ action: "help", label: "Help", description: "controls and safe next steps" },
|
|
552
|
+
);
|
|
553
|
+
this.#actionIndex = clamp(this.#actionIndex, 0, actions.length - 1);
|
|
554
|
+
return actions;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
#canScan(): boolean {
|
|
558
|
+
return this.#status?.mode === "enabled" && this.#status.capability.status === "available";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
#move(delta: number): void {
|
|
562
|
+
if (this.#view === "overview") {
|
|
563
|
+
const count = this.#actions().length;
|
|
564
|
+
this.#actionIndex = clamp(this.#actionIndex + delta, 0, Math.max(0, count - 1));
|
|
565
|
+
} else if (this.#view === "findings") {
|
|
566
|
+
let visible = this.#filteredEntries();
|
|
567
|
+
const target = this.#findingIndex + delta;
|
|
568
|
+
if (delta > 0 && target >= visible.length && this.#hasMoreCachedFindings()) {
|
|
569
|
+
this.#revealNextFindingPage();
|
|
570
|
+
visible = this.#filteredEntries();
|
|
571
|
+
}
|
|
572
|
+
this.#findingIndex = clamp(target, 0, Math.max(0, visible.length - 1));
|
|
573
|
+
}
|
|
574
|
+
this.#renderNow();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#moveToBoundary(boundary: "start" | "end"): void {
|
|
578
|
+
if (this.#view === "overview") {
|
|
579
|
+
this.#actionIndex = boundary === "start" ? 0 : Math.max(0, this.#actions().length - 1);
|
|
580
|
+
} else if (this.#view === "findings") {
|
|
581
|
+
this.#findingIndex =
|
|
582
|
+
boundary === "start" ? 0 : Math.max(0, this.#filteredEntries().length - 1);
|
|
583
|
+
}
|
|
584
|
+
this.#renderNow();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
#activate(): void {
|
|
588
|
+
if (this.#view === "overview") {
|
|
589
|
+
const selected = this.#actions()[this.#actionIndex];
|
|
590
|
+
if (selected) this.#activateAction(selected.action);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (this.#view === "findings") this.#toggleExpanded();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
#activateAction(action: OverlayAction): void {
|
|
597
|
+
if (action === "changed" || action === "scan" || action === "status") void this.#run(action);
|
|
598
|
+
else if (action === "findings") {
|
|
599
|
+
this.#view = "findings";
|
|
600
|
+
this.#renderNow();
|
|
601
|
+
} else if (action === "toggle") {
|
|
602
|
+
void this.#run(this.#status?.mode === "disabled" ? "on" : "off");
|
|
603
|
+
} else {
|
|
604
|
+
this.#view = "help";
|
|
605
|
+
this.#renderNow();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
#toggleExpanded(): void {
|
|
610
|
+
const current = this.#currentEntry();
|
|
611
|
+
if (!current) return;
|
|
612
|
+
this.#expandedFinding = this.#expandedFinding === current.id ? undefined : current.id;
|
|
613
|
+
this.#renderNow();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
#collapseCurrent(): void {
|
|
617
|
+
const current = this.#currentEntry();
|
|
618
|
+
if (!current || this.#expandedFinding !== current.id) return;
|
|
619
|
+
this.#expandedFinding = undefined;
|
|
620
|
+
this.#renderNow();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
#toggleMarked(): void {
|
|
624
|
+
const current = this.#currentEntry();
|
|
625
|
+
if (!current) return;
|
|
626
|
+
if (this.#markedFindings.has(current.id)) this.#markedFindings.delete(current.id);
|
|
627
|
+
else this.#markedFindings.add(current.id);
|
|
628
|
+
this.#renderNow();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
#toggleAllVisible(): void {
|
|
632
|
+
const visible = this.#filteredEntries();
|
|
633
|
+
if (visible.length === 0) return;
|
|
634
|
+
const shouldMark = !visible.every((entry) => this.#markedFindings.has(entry.id));
|
|
635
|
+
for (const entry of visible) {
|
|
636
|
+
if (shouldMark) this.#markedFindings.add(entry.id);
|
|
637
|
+
else this.#markedFindings.delete(entry.id);
|
|
638
|
+
}
|
|
639
|
+
this.#renderNow();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
#clearMarked(): void {
|
|
643
|
+
if (this.#markedFindings.size === 0) return;
|
|
644
|
+
this.#markedFindings.clear();
|
|
645
|
+
this.#renderNow();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
#finishWithPrompt(): void {
|
|
649
|
+
const selection = this.#selection();
|
|
650
|
+
if (selection.length === 0) return;
|
|
651
|
+
const prompt = buildJscpdOverlayPrompt(
|
|
652
|
+
selection.map(({ finding }) => finding),
|
|
653
|
+
this.#result?.status === "changed" ? "changed" : "project",
|
|
654
|
+
);
|
|
655
|
+
this.#close({
|
|
656
|
+
type: "prompt",
|
|
657
|
+
prompt: prompt.prompt,
|
|
658
|
+
findingCount: prompt.findingCount,
|
|
659
|
+
omittedSelectionCount: selection.length - prompt.findingCount,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
#selection(): readonly FindingEntry[] {
|
|
664
|
+
if (this.#markedFindings.size > 0) {
|
|
665
|
+
return this.#entries().filter((entry) => this.#markedFindings.has(entry.id));
|
|
666
|
+
}
|
|
667
|
+
const current = this.#currentEntry();
|
|
668
|
+
return current ? [current] : [];
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
#currentEntry(): FindingEntry | undefined {
|
|
672
|
+
return this.#filteredEntries()[this.#findingIndex];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
#entries(): readonly FindingEntry[] {
|
|
676
|
+
return this.#cachedEntries().slice(0, this.#revealedFindingCount);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
#cachedEntries(): readonly FindingEntry[] {
|
|
680
|
+
return resultFindings(this.#result)
|
|
681
|
+
.slice(0, JSCPD_OVERLAY_FINDING_LIMIT)
|
|
682
|
+
.map((finding, index) => ({ id: index, ordinal: index + 1, finding }));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
#hasMoreCachedFindings(): boolean {
|
|
686
|
+
return this.#entries().length < this.#cachedEntries().length;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
#revealNextFindingPage(): void {
|
|
690
|
+
this.#revealedFindingCount = Math.min(
|
|
691
|
+
this.#revealedFindingCount + OVERLAY_FINDING_PAGE_SIZE,
|
|
692
|
+
this.#cachedEntries().length,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
#loadNextFindings(): void {
|
|
697
|
+
if (!this.#hasMoreCachedFindings()) return;
|
|
698
|
+
this.#revealNextFindingPage();
|
|
699
|
+
this.#renderNow();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
#filteredEntries(): readonly FindingEntry[] {
|
|
703
|
+
const entries = this.#entries();
|
|
704
|
+
const query = this.#filter.getValue().trim().toLocaleLowerCase();
|
|
705
|
+
if (!query) return entries;
|
|
706
|
+
return entries.filter(({ finding }) =>
|
|
707
|
+
[finding.format, ...finding.occurrences.map(({ path }) => path)].some((value) =>
|
|
708
|
+
value.toLocaleLowerCase().includes(query),
|
|
709
|
+
),
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
#bodyLines(width: number, rowLimit: number): string[] {
|
|
714
|
+
if (this.#phase !== "ready") return this.#runningLines();
|
|
715
|
+
switch (this.#view) {
|
|
716
|
+
case "overview":
|
|
717
|
+
return this.#overviewLines(width, rowLimit);
|
|
718
|
+
case "findings":
|
|
719
|
+
return this.#findingLines(width, rowLimit);
|
|
720
|
+
case "help":
|
|
721
|
+
return this.#helpLines().slice(0, rowLimit);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
#runningLines(): string[] {
|
|
726
|
+
if (this.#phase === "cancelling") return ["Cancelling safely…", "Owned cleanup is bounded."];
|
|
727
|
+
if (this.#phase === "loading") return ["Loading status…", "Esc cancels and closes safely."];
|
|
728
|
+
const label =
|
|
729
|
+
this.#activeCommand === "scan"
|
|
730
|
+
? "Scanning project…"
|
|
731
|
+
: this.#activeCommand === "changed"
|
|
732
|
+
? "Checking session changes…"
|
|
733
|
+
: this.#activeCommand === "status"
|
|
734
|
+
? "Refreshing status…"
|
|
735
|
+
: "Updating session mode…";
|
|
736
|
+
return [label, "Esc cancels; no source files are modified."];
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
#overviewLines(width: number, rowLimit: number): string[] {
|
|
740
|
+
const intro = [
|
|
741
|
+
this.#statusLine(),
|
|
742
|
+
this.#configurationLine(),
|
|
743
|
+
this.#lastCheckLine(),
|
|
744
|
+
...(this.#result ? [this.#resultSummaryLine()] : []),
|
|
745
|
+
];
|
|
746
|
+
const actions = this.#actions().map((item, index) => this.#actionLine(item, index, width));
|
|
747
|
+
if (intro.length + actions.length + 1 <= rowLimit) return [...intro, "", ...actions];
|
|
748
|
+
|
|
749
|
+
const introCount = Math.min(intro.length, Math.max(1, rowLimit - 1));
|
|
750
|
+
const actionRows = Math.max(1, rowLimit - introCount);
|
|
751
|
+
const start = clamp(
|
|
752
|
+
this.#actionIndex - Math.floor(actionRows / 2),
|
|
753
|
+
0,
|
|
754
|
+
Math.max(0, actions.length - actionRows),
|
|
755
|
+
);
|
|
756
|
+
return [...intro.slice(0, introCount), ...actions.slice(start, start + actionRows)];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
#actionLine(item: ActionItem, index: number, width: number): string {
|
|
760
|
+
const selected = index === this.#actionIndex;
|
|
761
|
+
const marker = selected ? this.#theme.fg("accent", "❯") : this.#theme.fg("dim", " ");
|
|
762
|
+
const icon = selected ? this.#theme.fg("accent", "◆") : this.#theme.fg("dim", "◇");
|
|
763
|
+
const label = selected ? this.#theme.bold(item.label) : item.label;
|
|
764
|
+
const raw = `${marker} ${icon} ${label}${this.#theme.fg("dim", ` · ${item.description}`)}`;
|
|
765
|
+
return selected ? this.#theme.bg("selectedBg", truncateToWidth(raw, width)) : raw;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
#statusLine(): string {
|
|
769
|
+
if (!this.#status) return this.#theme.fg("warning", "● Status unavailable");
|
|
770
|
+
const capability = this.#status.capability;
|
|
771
|
+
const modeIcon = this.#status.mode === "enabled" ? "✓" : "○";
|
|
772
|
+
const modeColor = this.#status.mode === "enabled" ? "success" : "warning";
|
|
773
|
+
const binary =
|
|
774
|
+
capability.status === "available"
|
|
775
|
+
? `${capability.executable} ${capability.version}${capability.source === "bundled" ? " bundled" : ""}`
|
|
776
|
+
: capability.status === "missing"
|
|
777
|
+
? "jscpd v5 not found"
|
|
778
|
+
: `binary ${capability.status}`;
|
|
779
|
+
return `${this.#theme.fg(modeColor, modeIcon)} ${this.#theme.bold(this.#status.mode)}${this.#theme.fg("dim", ` (${this.#status.modeSource})`)} ${this.#pill(binary, capability.status === "available" ? "accent" : "warning")}`;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
#configurationLine(): string {
|
|
783
|
+
if (!this.#status) return "Configuration unavailable";
|
|
784
|
+
const config =
|
|
785
|
+
this.#status.configSource === "defaults"
|
|
786
|
+
? "built-in defaults"
|
|
787
|
+
: `${this.#status.configSource} configuration`;
|
|
788
|
+
const coexistence = this.#status.fallowAutomatic
|
|
789
|
+
? ` · Fallow overlap: jscpd automatic ${this.#status.fallowAutomatic}`
|
|
790
|
+
: "";
|
|
791
|
+
return `${this.#theme.fg("muted", "Configuration")} ${config}${this.#theme.fg("dim", ` · ${this.#safeChangedCount()} session-changed files${coexistence}`)}`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
#lastCheckLine(): string {
|
|
795
|
+
const last = this.#status?.lastCheck;
|
|
796
|
+
let value = "never";
|
|
797
|
+
if (last?.state === "findings") value = counted(last.clones, "duplicate block");
|
|
798
|
+
else if (last) value = last.state;
|
|
799
|
+
return `${this.#theme.fg("muted", "Last check")} ${this.#theme.fg(last?.state === "failed" ? "warning" : "text", value)}`;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
#resultSummaryLine(): string {
|
|
803
|
+
if (!this.#result) return "";
|
|
804
|
+
const first = executionMessage(this.#result).split("\n")[0] ?? "";
|
|
805
|
+
return `${this.#theme.fg("accent", "Current result")} ${first}`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
#findingLines(width: number, rowLimit: number): string[] {
|
|
809
|
+
const visible = this.#filteredEntries();
|
|
810
|
+
const prelude = [this.#findingContextLine(visible.length)];
|
|
811
|
+
if (this.#hasMoreCachedFindings()) {
|
|
812
|
+
const remaining = this.#cachedEntries().length - this.#entries().length;
|
|
813
|
+
prelude.push(
|
|
814
|
+
this.#theme.fg(
|
|
815
|
+
"accent",
|
|
816
|
+
`Load next ${Math.min(OVERLAY_FINDING_PAGE_SIZE, remaining)} / L · ${counted(remaining, "cached finding")} remain`,
|
|
817
|
+
),
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (this.#filtering || this.#filter.getValue()) prelude.push(this.#filterLine(width));
|
|
821
|
+
if (visible.length === 0)
|
|
822
|
+
return [...prelude, this.#theme.fg("warning", "No findings match the active search.")];
|
|
823
|
+
|
|
824
|
+
const current = visible[this.#findingIndex];
|
|
825
|
+
const expanded = current && this.#expandedFinding === current.id;
|
|
826
|
+
const detail = expanded ? this.#findingDetailLines(current, width) : [];
|
|
827
|
+
const availableRows = Math.max(1, rowLimit - prelude.length);
|
|
828
|
+
const rowsPerFinding = width < 64 ? 3 : 1;
|
|
829
|
+
const detailRows = Math.min(detail.length, Math.max(0, availableRows - rowsPerFinding - 1));
|
|
830
|
+
const listSlots = Math.max(rowsPerFinding, availableRows - detailRows);
|
|
831
|
+
const visibleRows = Math.max(1, Math.floor((listSlots - 2) / rowsPerFinding));
|
|
832
|
+
this.#ensureFindingVisible(visibleRows, visible.length);
|
|
833
|
+
const start = this.#scrollStart;
|
|
834
|
+
const end = Math.min(visible.length, start + visibleRows);
|
|
835
|
+
const rows: string[] = [...prelude];
|
|
836
|
+
if (start > 0) rows.push(this.#theme.fg("dim", `… ${counted(start, "earlier finding")}`));
|
|
837
|
+
for (let index = start; index < end; index += 1) {
|
|
838
|
+
const entry = visible[index];
|
|
839
|
+
if (!entry) continue;
|
|
840
|
+
rows.push(...this.#findingRows(entry, index, width));
|
|
841
|
+
if (expanded && entry.id === current.id) rows.push(...detail.slice(0, detailRows));
|
|
842
|
+
}
|
|
843
|
+
if (end < visible.length) {
|
|
844
|
+
rows.push(this.#theme.fg("dim", `… ${counted(visible.length - end, "later finding")}`));
|
|
845
|
+
}
|
|
846
|
+
return rows.slice(0, rowLimit);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
#findingContextLine(filteredCount: number): string {
|
|
850
|
+
const available = this.#entries().length;
|
|
851
|
+
const retained = this.#cachedEntries().length;
|
|
852
|
+
const total = resultTotal(this.#result);
|
|
853
|
+
const omitted = resultOmitted(this.#result);
|
|
854
|
+
const ambiguous = resultAmbiguous(this.#result);
|
|
855
|
+
const parts = [
|
|
856
|
+
`${filteredCount === available ? available : `${filteredCount}/${available}`} shown`,
|
|
857
|
+
retained > available ? `${retained} retained` : undefined,
|
|
858
|
+
`${total} total`,
|
|
859
|
+
omitted > 0
|
|
860
|
+
? resultHasOverlayCache(this.#result)
|
|
861
|
+
? `${omitted} beyond overlay cache`
|
|
862
|
+
: `${omitted} not retained (display limit)`
|
|
863
|
+
: undefined,
|
|
864
|
+
ambiguous > 0 ? `${ambiguous} unclassified` : undefined,
|
|
865
|
+
].filter(Boolean);
|
|
866
|
+
return parts
|
|
867
|
+
.map((part, index) => this.#pill(part as string, index === 0 ? "accent" : "muted"))
|
|
868
|
+
.join(this.#theme.fg("dim", " "));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
#filterLine(width: number): string {
|
|
872
|
+
const rendered = this.#filter.render(Math.max(1, width - 9))[0] ?? "";
|
|
873
|
+
return `${this.#theme.fg("accent", "Search")} ${rendered}`;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
#findingRows(entry: FindingEntry, visibleIndex: number, width: number): string[] {
|
|
877
|
+
const selected = visibleIndex === this.#findingIndex;
|
|
878
|
+
const marked = this.#markedFindings.has(entry.id);
|
|
879
|
+
const expanded = this.#expandedFinding === entry.id;
|
|
880
|
+
const marker = selected ? this.#theme.fg("accent", "❯") : this.#theme.fg("dim", " ");
|
|
881
|
+
const check = marked ? this.#theme.fg("success", "☑") : this.#theme.fg("dim", "☐");
|
|
882
|
+
const disclosure = expanded ? this.#theme.fg("warning", "▾") : this.#theme.fg("accent", "▸");
|
|
883
|
+
const [first, second] = jscpdFindingLocations(entry.finding);
|
|
884
|
+
const metadata = `${entry.finding.lines}L/${entry.finding.tokens}T ${entry.finding.format}`;
|
|
885
|
+
if (width < 64) {
|
|
886
|
+
return [
|
|
887
|
+
`${marker} ${check} ${disclosure} ${this.#theme.bold(`Duplicate ${entry.ordinal}`)}${this.#theme.fg("dim", ` · ${metadata}`)}`,
|
|
888
|
+
` ${this.#relationMark(first.label)} ${truncateToWidth(first.text, Math.max(1, width - 8))}`,
|
|
889
|
+
` ${this.#relationMark(second.label)} ${truncateToWidth(second.text, Math.max(1, width - 8))}`,
|
|
890
|
+
].map((line) =>
|
|
891
|
+
selected ? this.#theme.bg("selectedBg", truncateToWidth(line, width)) : line,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
const fixedWidth = 20 + visibleWidth(metadata);
|
|
895
|
+
const locationWidth = Math.max(3, Math.floor((width - fixedWidth) / 2));
|
|
896
|
+
const firstText = middleTruncate(first.text, locationWidth);
|
|
897
|
+
const secondText = middleTruncate(second.text, locationWidth);
|
|
898
|
+
const raw = `${marker} ${check} ${disclosure} ${this.#relationMark(first.label)} ${firstText}${this.#theme.fg("dim", " ↔ ")}${this.#relationMark(second.label)} ${secondText}${this.#theme.fg("dim", ` · ${metadata}`)}`;
|
|
899
|
+
const bounded = truncateToWidth(raw, width);
|
|
900
|
+
return [selected ? this.#theme.bg("selectedBg", bounded) : bounded];
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
#relationMark(label: string): string {
|
|
904
|
+
if (label === "new in this session") return this.#theme.fg("success", "N");
|
|
905
|
+
if (label === "existing match") return this.#theme.fg("muted", "E");
|
|
906
|
+
return this.#theme.fg("accent", "C");
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
#findingDetailLines(entry: FindingEntry, width: number): string[] {
|
|
910
|
+
const total = resultTotal(this.#result);
|
|
911
|
+
const scope = this.#result?.status === "changed" ? "changed" : "project";
|
|
912
|
+
const [heading, first, second, metadata] = jscpdFindingDetailLines(
|
|
913
|
+
entry.finding,
|
|
914
|
+
entry.ordinal,
|
|
915
|
+
total,
|
|
916
|
+
);
|
|
917
|
+
const context = [
|
|
918
|
+
resultOmitted(this.#result) > 0
|
|
919
|
+
? resultHasOverlayCache(this.#result)
|
|
920
|
+
? `${counted(resultOmitted(this.#result), "additional finding")} exceed the 100-finding overlay cache.`
|
|
921
|
+
: `${counted(resultOmitted(this.#result), "additional finding")} were not retained by the configured display limit.`
|
|
922
|
+
: undefined,
|
|
923
|
+
resultAmbiguous(this.#result) > 0
|
|
924
|
+
? `${counted(resultAmbiguous(this.#result), "duplicate block")} could not be classified safely.`
|
|
925
|
+
: undefined,
|
|
926
|
+
resultVerification(this.#result)?.message,
|
|
927
|
+
].filter(Boolean) as string[];
|
|
928
|
+
return [
|
|
929
|
+
this.#theme.fg("accent", ` ${heading}`),
|
|
930
|
+
...wrapTextWithAnsi(this.#theme.fg("text", ` ${first}`), width),
|
|
931
|
+
...wrapTextWithAnsi(this.#theme.fg("text", ` ${second}`), width),
|
|
932
|
+
this.#theme.fg("muted", ` ${metadata}`),
|
|
933
|
+
...context.flatMap((line) => wrapTextWithAnsi(this.#theme.fg("dim", ` ${line}`), width)),
|
|
934
|
+
...jscpdFindingGuidance(scope)
|
|
935
|
+
.slice(0, 2)
|
|
936
|
+
.flatMap((line) => wrapTextWithAnsi(this.#theme.fg("dim", ` ${line}`), width)),
|
|
937
|
+
];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
#ensureFindingVisible(listHeight: number, visibleCount: number): void {
|
|
941
|
+
if (this.#findingIndex < this.#scrollStart) this.#scrollStart = this.#findingIndex;
|
|
942
|
+
if (this.#findingIndex >= this.#scrollStart + listHeight) {
|
|
943
|
+
this.#scrollStart = this.#findingIndex - listHeight + 1;
|
|
944
|
+
}
|
|
945
|
+
this.#scrollStart = clamp(this.#scrollStart, 0, Math.max(0, visibleCount - listHeight));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
#helpLines(): string[] {
|
|
949
|
+
return [
|
|
950
|
+
`${this.#pill("↑↓/jk", "accent")} navigate ${this.#pill("home/end", "muted")} boundaries ${this.#pill("pgup/pgdn", "muted")} page`,
|
|
951
|
+
`${this.#pill("enter/space/→/l", "accent")} expand ${this.#pill("←/h", "muted")} collapse`,
|
|
952
|
+
`${this.#pill("/", "accent")} search paths/format ${this.#pill("x", "muted")} clear search ${this.#pill("L", "muted")} load next 10`,
|
|
953
|
+
`${this.#pill("s/tab", "accent")} select ${this.#pill("A", "muted")} all shown ${this.#pill("c", "muted")} clear selected`,
|
|
954
|
+
`${this.#pill("e/a", "accent")} load selected findings into the editor ${this.#pill("r", "muted")} rescan`,
|
|
955
|
+
`${this.#pill("shift+tab/esc", "muted")} overview ${this.#pill("q/ctrl+c", "muted")} close`,
|
|
956
|
+
"",
|
|
957
|
+
"Overview shortcuts: c checks session changes, s scans the project, o toggles this session, and r repeats the last scan or refreshes status.",
|
|
958
|
+
"Search is a bounded, case-insensitive literal match over both paths and format.",
|
|
959
|
+
"Loading findings only prefills Pi's editor. It never submits a prompt or changes source/configuration.",
|
|
960
|
+
"Duplication is advisory and may be intentional. Inspect both locations before changing code.",
|
|
961
|
+
"After normal edits and tests, rescan to compare with the prior matching explicit check.",
|
|
962
|
+
"For intentional duplication, update normal jscpd ignore/exclusion policy through the ordinary workflow.",
|
|
963
|
+
"Use /jscpd scan <target ...> for scoped paths. Reinstall pi-jscpd if the bundled analyzer is missing.",
|
|
964
|
+
];
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
#headerTitle(): string {
|
|
968
|
+
const brand = `${this.#theme.fg("accent", " ✦ ")}${this.#theme.bold("pi-jscpd")}`;
|
|
969
|
+
if (this.#phase !== "ready")
|
|
970
|
+
return `${brand}${this.#theme.fg("dim", ` · ${this.#compactTitle()}`)} `;
|
|
971
|
+
if (this.#view === "findings") {
|
|
972
|
+
const visible = this.#filteredEntries().length;
|
|
973
|
+
const retained = this.#cachedEntries().length;
|
|
974
|
+
return `${brand}${this.#theme.fg("dim", " · ")}${this.#pill(`${visible}/${retained} findings`, visible > 0 ? "accent" : "warning")} `;
|
|
975
|
+
}
|
|
976
|
+
if (this.#view === "help")
|
|
977
|
+
return `${brand}${this.#theme.fg("dim", " · ")}${this.#pill("help", "accent")} `;
|
|
978
|
+
const mode = this.#status?.mode ?? "loading";
|
|
979
|
+
return `${brand}${this.#theme.fg("dim", " · ")}${this.#pill(mode, mode === "enabled" ? "success" : "warning")} `;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
#compactTitle(): string {
|
|
983
|
+
if (this.#phase === "loading") return "loading";
|
|
984
|
+
if (this.#phase === "cancelling") return "cancelling";
|
|
985
|
+
if (this.#phase === "running") return this.#activeCommand ?? "working";
|
|
986
|
+
return this.#view;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
#footer(): string {
|
|
990
|
+
if (this.#phase !== "ready") return "Esc cancel · q cancel and close";
|
|
991
|
+
if (this.#filtering) return "Type to search · Enter apply · Esc cancel · Ctrl+U clear";
|
|
992
|
+
if (this.#view === "findings") {
|
|
993
|
+
const selected = this.#markedFindings.size
|
|
994
|
+
? `${this.#markedFindings.size} selected`
|
|
995
|
+
: "current finding";
|
|
996
|
+
const load = this.#hasMoreCachedFindings() ? " · L next 10" : "";
|
|
997
|
+
return `${selected} · ↑↓ navigate${load} · Enter expand · s select · e load · ? help · Esc back · q close`;
|
|
998
|
+
}
|
|
999
|
+
if (this.#view === "help") return "Esc or Shift+Tab overview · q close";
|
|
1000
|
+
return "↑↓ navigate · Enter select · c changes · s scan · ? help · q close";
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
#pill(text: string, color: "accent" | "muted" | "success" | "warning"): string {
|
|
1004
|
+
return this.#theme.fg(color, ` ${text} `);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
#topBorder(width: number, title: string): string {
|
|
1008
|
+
const clipped = truncateToWidth(title, Math.max(0, width - 2), "");
|
|
1009
|
+
const fill = Math.max(0, width - visibleWidth(clipped) - 2);
|
|
1010
|
+
return `${this.#theme.fg("borderAccent", "╭")}${clipped}${this.#theme.fg("borderAccent", `${"─".repeat(fill)}╮`)}`;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
#separator(width: number): string {
|
|
1014
|
+
return this.#theme.fg("borderAccent", `├${"─".repeat(Math.max(0, width - 2))}┤`);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
#bottomBorder(width: number): string {
|
|
1018
|
+
return this.#theme.fg("borderAccent", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
#frame(content: string, width: number): string {
|
|
1022
|
+
const innerWidth = Math.max(0, width - 4);
|
|
1023
|
+
const bounded = truncateToWidth(content, innerWidth);
|
|
1024
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(bounded)));
|
|
1025
|
+
return `${this.#theme.fg("borderAccent", "│ ")}${bounded}${padding}${this.#theme.fg("borderAccent", " │")}`;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
#isCancel(data: string): boolean {
|
|
1029
|
+
return this.#keybindings.matches(data, "tui.select.cancel") || matchesKey(data, "escape");
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
#isUp(data: string): boolean {
|
|
1033
|
+
return this.#keybindings.matches(data, "tui.select.up") || matchesKey(data, "k");
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
#isDown(data: string): boolean {
|
|
1037
|
+
return this.#keybindings.matches(data, "tui.select.down") || matchesKey(data, "j");
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
#pageSize(): number {
|
|
1041
|
+
return Math.max(1, Math.floor((this.#tui.terminal.rows || 10) * 0.4));
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
#safeChangedCount(): number {
|
|
1045
|
+
try {
|
|
1046
|
+
return Math.max(0, this.#changedFileCount());
|
|
1047
|
+
} catch {
|
|
1048
|
+
return 0;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
#renderNow(): void {
|
|
1053
|
+
if (!this.#disposed) this.#tui.requestRender();
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
export function buildJscpdOverlayPrompt(
|
|
1058
|
+
findings: readonly OverlayFinding[],
|
|
1059
|
+
scope: "changed" | "project",
|
|
1060
|
+
): { readonly prompt: string; readonly findingCount: number } {
|
|
1061
|
+
const selected = findings.slice(0, PROMPT_FINDING_LIMIT);
|
|
1062
|
+
const lines = [
|
|
1063
|
+
"Review the following jscpd duplicate blocks.",
|
|
1064
|
+
"Inspect both locations and surrounding behavior before deciding whether the duplication should be refactored or intentionally retained.",
|
|
1065
|
+
"Do not change source or configuration until you have explained the evidence and proposed the safest next step.",
|
|
1066
|
+
"",
|
|
1067
|
+
];
|
|
1068
|
+
let findingCount = 0;
|
|
1069
|
+
for (const [index, finding] of selected.entries()) {
|
|
1070
|
+
const block = [...jscpdFindingDetailLines(finding, index + 1, findings.length), ""];
|
|
1071
|
+
const candidate = [...lines, ...block].join("\n");
|
|
1072
|
+
if (Array.from(candidate).length > PROMPT_CHARACTER_LIMIT) break;
|
|
1073
|
+
lines.push(...block);
|
|
1074
|
+
findingCount += 1;
|
|
1075
|
+
}
|
|
1076
|
+
lines.push(...jscpdFindingGuidance(scope));
|
|
1077
|
+
const prompt = Array.from(lines.join("\n")).slice(0, PROMPT_CHARACTER_LIMIT).join("");
|
|
1078
|
+
return Object.freeze({ prompt, findingCount });
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async function safeExecute(
|
|
1082
|
+
executor: JscpdOverlayExecutor,
|
|
1083
|
+
command: JscpdCommand,
|
|
1084
|
+
cwd: string,
|
|
1085
|
+
signal?: AbortSignal,
|
|
1086
|
+
overlayFindingLimit?: number,
|
|
1087
|
+
): Promise<JscpdExecutionResult> {
|
|
1088
|
+
try {
|
|
1089
|
+
return await executor.execute(
|
|
1090
|
+
{ command, args: [] },
|
|
1091
|
+
{ cwd, signal, ...(overlayFindingLimit ? { overlayFindingLimit } : {}) },
|
|
1092
|
+
);
|
|
1093
|
+
} catch {
|
|
1094
|
+
return Object.freeze({
|
|
1095
|
+
status: "failed",
|
|
1096
|
+
reason: "process-failed",
|
|
1097
|
+
message: "The jscpd request failed safely; no source files were changed.",
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function resultFindings(result?: JscpdExecutionResult): readonly OverlayFinding[] {
|
|
1103
|
+
if (result?.status === "changed" || result?.status === "completed") {
|
|
1104
|
+
return result.overlayCache?.findings ?? result.findings;
|
|
1105
|
+
}
|
|
1106
|
+
return [];
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function resultTotal(result?: JscpdExecutionResult): number {
|
|
1110
|
+
if (result?.status === "completed") return result.summary.clones;
|
|
1111
|
+
if (result?.status === "changed") {
|
|
1112
|
+
return result.findings.length + result.omittedFindings + result.ambiguousFindings;
|
|
1113
|
+
}
|
|
1114
|
+
return 0;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function resultOmitted(result?: JscpdExecutionResult): number {
|
|
1118
|
+
if (result?.status === "changed" || result?.status === "completed") {
|
|
1119
|
+
return result.overlayCache?.omittedFindings ?? result.omittedFindings;
|
|
1120
|
+
}
|
|
1121
|
+
return 0;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function resultHasOverlayCache(result?: JscpdExecutionResult): boolean {
|
|
1125
|
+
return (
|
|
1126
|
+
(result?.status === "changed" || result?.status === "completed") &&
|
|
1127
|
+
result.overlayCache !== undefined
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function resultAmbiguous(result?: JscpdExecutionResult): number {
|
|
1132
|
+
return result?.status === "changed" ? result.ambiguousFindings : 0;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function resultVerification(result?: JscpdExecutionResult) {
|
|
1136
|
+
if (result?.status === "changed" || result?.status === "completed") {
|
|
1137
|
+
return result.verification;
|
|
1138
|
+
}
|
|
1139
|
+
return undefined;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function executionMessage(result: JscpdExecutionResult): string {
|
|
1143
|
+
return "terminalMessage" in result ? result.terminalMessage : result.message;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function statusLevel(result: JscpdExecutionResult): "info" | "warning" {
|
|
1147
|
+
return result.status === "status" ? "info" : "warning";
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function middleTruncate(value: string, width: number): string {
|
|
1151
|
+
if (width <= 0) return "";
|
|
1152
|
+
if (visibleWidth(value) <= width) return value;
|
|
1153
|
+
if (width === 1) return "…";
|
|
1154
|
+
const characters = Array.from(value);
|
|
1155
|
+
const leftCount = Math.ceil((width - 1) / 2);
|
|
1156
|
+
const rightCount = Math.floor((width - 1) / 2);
|
|
1157
|
+
return `${truncateToWidth(characters.slice(0, leftCount).join(""), leftCount, "")}…${truncateToWidth(characters.slice(-rightCount).join(""), rightCount, "")}`;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function counted(count: number, noun: string): string {
|
|
1161
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
1165
|
+
return Math.max(minimum, Math.min(value, maximum));
|
|
1166
|
+
}
|