@jonathan0823/pi-sonarqube 0.3.1 → 0.3.2
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/README.md +3 -3
- package/dist/api.d.ts +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +27 -11
- package/dist/api.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +25 -7
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +50 -11
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ui.d.ts +2 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +130 -91
- package/dist/ui.js.map +1 -1
- package/package.json +3 -1
- package/src/api.ts +27 -10
- package/src/commands.ts +26 -7
- package/src/index.ts +51 -10
- package/src/types.ts +3 -0
- package/src/ui.ts +169 -128
package/src/commands.ts
CHANGED
|
@@ -231,10 +231,21 @@ export function formatMetricsOutput(metrics: {
|
|
|
231
231
|
measures?: SonarDuplicationMeasures;
|
|
232
232
|
issueCounts?: IssueSeverityCounts;
|
|
233
233
|
issueQualityCounts?: IssueQualityCounts;
|
|
234
|
+
cleanCodeMode?: "STANDARD" | "MQR";
|
|
234
235
|
}): string {
|
|
235
236
|
const lines: string[] = [`Metrics for ${metrics.projectKey}`];
|
|
236
237
|
|
|
237
238
|
if (metrics.measures) {
|
|
239
|
+
if (metrics.measures.coverage === undefined) {
|
|
240
|
+
lines.push("Coverage: n/a");
|
|
241
|
+
} else {
|
|
242
|
+
const pct = metrics.measures.coverage.toFixed(1);
|
|
243
|
+
const covered = (metrics.measures.linesToCover ?? 0) - (metrics.measures.uncoveredLines ?? 0);
|
|
244
|
+
const uncovered = metrics.measures.uncoveredLines ?? 0;
|
|
245
|
+
const total = metrics.measures.linesToCover ?? 0;
|
|
246
|
+
lines.push(`Coverage: ${pct}% covered=${covered} uncovered=${uncovered} lines=${total}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
238
249
|
const density = metrics.measures.duplicatedLinesDensity.toFixed(1);
|
|
239
250
|
lines.push(
|
|
240
251
|
metrics.measures.duplicatedBlocks > 0
|
|
@@ -244,13 +255,21 @@ export function formatMetricsOutput(metrics: {
|
|
|
244
255
|
}
|
|
245
256
|
|
|
246
257
|
if (metrics.issueCounts) {
|
|
247
|
-
const counts =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
258
|
+
const counts = metrics.cleanCodeMode === "MQR"
|
|
259
|
+
? [
|
|
260
|
+
`BLOCKER ${metrics.issueCounts.blocker}`,
|
|
261
|
+
`HIGH ${metrics.issueCounts.critical}`,
|
|
262
|
+
`MEDIUM ${metrics.issueCounts.major}`,
|
|
263
|
+
`LOW ${metrics.issueCounts.minor}`,
|
|
264
|
+
`INFO ${metrics.issueCounts.info}`,
|
|
265
|
+
]
|
|
266
|
+
: [
|
|
267
|
+
`BLOCKER ${metrics.issueCounts.blocker}`,
|
|
268
|
+
`CRITICAL ${metrics.issueCounts.critical}`,
|
|
269
|
+
`MAJOR ${metrics.issueCounts.major}`,
|
|
270
|
+
`MINOR ${metrics.issueCounts.minor}`,
|
|
271
|
+
`INFO ${metrics.issueCounts.info}`,
|
|
272
|
+
];
|
|
254
273
|
lines.push(`Issues: ${counts.join(" ")}`);
|
|
255
274
|
}
|
|
256
275
|
|
package/src/index.ts
CHANGED
|
@@ -51,7 +51,6 @@ import {
|
|
|
51
51
|
formatSummary,
|
|
52
52
|
formatMetricsOutput,
|
|
53
53
|
formatDuplicationsList,
|
|
54
|
-
formatDuplicationBlockDetail,
|
|
55
54
|
formatReport,
|
|
56
55
|
helpText,
|
|
57
56
|
targetLabel,
|
|
@@ -62,6 +61,7 @@ import {
|
|
|
62
61
|
startAnalysisUi,
|
|
63
62
|
showIssueBrowser,
|
|
64
63
|
buildIssuePreview,
|
|
64
|
+
buildDuplicationPreview,
|
|
65
65
|
openIssuePreview,
|
|
66
66
|
resolveTargetState,
|
|
67
67
|
showDuplicationBrowser,
|
|
@@ -533,9 +533,10 @@ async function commandMetrics(
|
|
|
533
533
|
targetInput?: string,
|
|
534
534
|
): Promise<void> {
|
|
535
535
|
const config = await resolveConfig(ctx, targetInput);
|
|
536
|
+
const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
|
|
536
537
|
const [measures, issueCounts, qualityCounts] = await Promise.all([
|
|
537
538
|
fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
538
|
-
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
539
|
+
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
|
|
539
540
|
fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
540
541
|
]);
|
|
541
542
|
if (!measures && !issueCounts && !qualityCounts) {
|
|
@@ -544,7 +545,7 @@ async function commandMetrics(
|
|
|
544
545
|
}
|
|
545
546
|
return;
|
|
546
547
|
}
|
|
547
|
-
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts });
|
|
548
|
+
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
|
|
548
549
|
if (ctx.hasUI) {
|
|
549
550
|
ctx.ui.notify(text, "info");
|
|
550
551
|
}
|
|
@@ -594,7 +595,7 @@ async function showDuplicationDrillDown(
|
|
|
594
595
|
return;
|
|
595
596
|
}
|
|
596
597
|
const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
|
|
597
|
-
const text =
|
|
598
|
+
const text = await buildDuplicationPreview(config.baseDir, file.filePath, groups);
|
|
598
599
|
if (ctx.mode === "tui") {
|
|
599
600
|
await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
|
|
600
601
|
} else if (ctx.hasUI) {
|
|
@@ -615,7 +616,7 @@ async function showDuplicationListOrBrowser(
|
|
|
615
616
|
if (choice == null) return;
|
|
616
617
|
const file = files[choice];
|
|
617
618
|
const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
|
|
618
|
-
await ctx.ui.editor(`Duplications in ${file.filePath}`,
|
|
619
|
+
await ctx.ui.editor(`Duplications in ${file.filePath}`, await buildDuplicationPreview(config.baseDir, file.filePath, groups));
|
|
619
620
|
}
|
|
620
621
|
|
|
621
622
|
async function commandIssuesOrOpen(
|
|
@@ -701,8 +702,47 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
|
|
|
701
702
|
details: { error: `No duplications found for ${config.projectKey}.` },
|
|
702
703
|
};
|
|
703
704
|
}
|
|
704
|
-
|
|
705
|
-
|
|
705
|
+
|
|
706
|
+
const topFiles = files.slice(0, 5);
|
|
707
|
+
const topContexts = await Promise.all(
|
|
708
|
+
topFiles.map(async (file) => {
|
|
709
|
+
try {
|
|
710
|
+
const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
|
|
711
|
+
const preview = await buildDuplicationPreview(config.baseDir, file.filePath, groups);
|
|
712
|
+
return {
|
|
713
|
+
filePath: file.filePath,
|
|
714
|
+
duplicatedBlocks: file.duplicatedBlocks,
|
|
715
|
+
duplicatedLines: file.duplicatedLines,
|
|
716
|
+
preview,
|
|
717
|
+
};
|
|
718
|
+
} catch (error) {
|
|
719
|
+
return {
|
|
720
|
+
filePath: file.filePath,
|
|
721
|
+
duplicatedBlocks: file.duplicatedBlocks,
|
|
722
|
+
duplicatedLines: file.duplicatedLines,
|
|
723
|
+
preview: `Duplications in ${file.filePath}\n\n<failed to load duplicated lines: ${sonarErrorMessage(error)}>`,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}),
|
|
727
|
+
);
|
|
728
|
+
const remaining = files.length - topContexts.length;
|
|
729
|
+
const content: Array<{ type: "text"; text: string }> = [
|
|
730
|
+
{ type: "text", text: `Duplicated files for ${config.projectKey}: ${files.length}. Showing top ${topContexts.length}.` },
|
|
731
|
+
...topContexts.map((item, index) => ({
|
|
732
|
+
type: "text" as const,
|
|
733
|
+
text: `#${index + 1} ${item.filePath} blocks=${item.duplicatedBlocks} lines=${item.duplicatedLines}\n${item.preview}`,
|
|
734
|
+
})),
|
|
735
|
+
...(remaining > 0 ? [{ type: "text" as const, text: `+ ${remaining} more file(s) not shown` }] : []),
|
|
736
|
+
];
|
|
737
|
+
return {
|
|
738
|
+
content,
|
|
739
|
+
details: {
|
|
740
|
+
projectKey: config.projectKey,
|
|
741
|
+
totalFiles: files.length,
|
|
742
|
+
topFiles: topContexts,
|
|
743
|
+
moreFiles: remaining,
|
|
744
|
+
},
|
|
745
|
+
};
|
|
706
746
|
}
|
|
707
747
|
|
|
708
748
|
async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
@@ -715,9 +755,10 @@ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ conten
|
|
|
715
755
|
details: { error: "Project not configured." },
|
|
716
756
|
};
|
|
717
757
|
}
|
|
758
|
+
const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
|
|
718
759
|
const [measures, issueCounts, qualityCounts] = await Promise.all([
|
|
719
760
|
fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
720
|
-
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
761
|
+
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
|
|
721
762
|
fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
722
763
|
]);
|
|
723
764
|
if (!measures && !issueCounts && !qualityCounts) {
|
|
@@ -726,8 +767,8 @@ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ conten
|
|
|
726
767
|
details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
|
|
727
768
|
};
|
|
728
769
|
}
|
|
729
|
-
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts });
|
|
730
|
-
return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts } };
|
|
770
|
+
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
|
|
771
|
+
return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode } };
|
|
731
772
|
}
|
|
732
773
|
|
|
733
774
|
// ── Re-exports for public API ───────────────────────────────────────────────
|
package/src/types.ts
CHANGED
package/src/ui.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Theme, ExtensionContext, ExtensionCommandContext } from "@earendil
|
|
|
2
2
|
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
|
-
import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication } from "./types.js";
|
|
5
|
+
import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication, DuplicationBlockGroup } from "./types.js";
|
|
6
6
|
import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters, fetchCleanCodeMode } from "./api.js";
|
|
7
7
|
import { resolveConfig, resolveTarget } from "./config.js";
|
|
8
8
|
|
|
@@ -26,6 +26,54 @@ export async function buildIssuePreview(baseDir: string, issue: SonarIssue, radi
|
|
|
26
26
|
return rendered.join("\n");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export async function buildDuplicationPreview(
|
|
30
|
+
baseDir: string,
|
|
31
|
+
filePath: string,
|
|
32
|
+
groups: DuplicationBlockGroup[],
|
|
33
|
+
): Promise<string> {
|
|
34
|
+
const cache = new Map<string, string>();
|
|
35
|
+
const loadLines = async (relativePath: string): Promise<string[]> => {
|
|
36
|
+
const absolutePath = resolve(baseDir, relativePath);
|
|
37
|
+
const cached = cache.get(absolutePath);
|
|
38
|
+
if (cached !== undefined) return cached.split(/\r?\n/);
|
|
39
|
+
const content = await readFile(absolutePath, "utf8");
|
|
40
|
+
cache.set(absolutePath, content);
|
|
41
|
+
return content.split(/\r?\n/);
|
|
42
|
+
};
|
|
43
|
+
const renderRange = (lines: string[], from: number, size: number): string[] => {
|
|
44
|
+
const start = Math.max(1, from);
|
|
45
|
+
const end = Math.min(lines.length, from + size - 1);
|
|
46
|
+
const width = String(end).length;
|
|
47
|
+
const rendered: string[] = [];
|
|
48
|
+
for (let line = start; line <= end; line++) {
|
|
49
|
+
const num = String(line).padStart(width, " ");
|
|
50
|
+
rendered.push(`${num} | ${lines[line - 1] ?? ""}`);
|
|
51
|
+
}
|
|
52
|
+
return rendered;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const rendered: string[] = [`Duplications in ${filePath}`];
|
|
56
|
+
for (const [groupIndex, group] of groups.entries()) {
|
|
57
|
+
rendered.push("", `Block ${groupIndex + 1}:`);
|
|
58
|
+
for (const block of group.blocks) {
|
|
59
|
+
try {
|
|
60
|
+
const lines = await loadLines(block.filePath);
|
|
61
|
+
const end = block.from + block.size - 1;
|
|
62
|
+
rendered.push(` ${block.filePath}:${block.from}-${end}`);
|
|
63
|
+
for (const line of renderRange(lines, block.from, block.size)) {
|
|
64
|
+
rendered.push(` ${line}`);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
rendered.push(
|
|
68
|
+
` ${block.filePath}:${block.from}-${block.from + block.size - 1}`,
|
|
69
|
+
" <unable to read duplicated lines>",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return rendered.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
29
77
|
// ── Analysis UI ─────────────────────────────────────────────────────────────
|
|
30
78
|
|
|
31
79
|
const ANALYSIS_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
@@ -81,95 +129,35 @@ export class IssueBrowser {
|
|
|
81
129
|
invalidate(): void {} // NOSONAR - required by TUI interface
|
|
82
130
|
|
|
83
131
|
handleInput(data: string): void {
|
|
84
|
-
|
|
85
|
-
this.done(null);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
if (matchesKey(data, Key.up)) {
|
|
89
|
-
this.selected = Math.max(0, this.selected - 1);
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
if (matchesKey(data, Key.down)) {
|
|
93
|
-
this.selected = Math.min(this.state.issues.length - 1, this.selected + 1);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
if (matchesKey(data, Key.enter)) {
|
|
97
|
-
this.done(this.selected);
|
|
98
|
-
}
|
|
132
|
+
this.selected = handleListBrowserInput(data, this.selected, this.state.issues.length, this.done);
|
|
99
133
|
}
|
|
100
134
|
|
|
101
135
|
render(width: number): string[] {
|
|
102
|
-
const lines: string[] = [];
|
|
103
|
-
const title = this.theme.fg("accent", this.theme.bold(" SonarQube Issues "));
|
|
104
136
|
const filterSuffix = this.state.filters ? ` • ${issueFilterLabel(this.state.filters)}` : "";
|
|
105
|
-
|
|
106
|
-
"
|
|
107
|
-
`${this.state.projectKey} • ${this.state.totalIssues} issue(s)${filterSuffix}`,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"",
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
),
|
|
130
|
-
);
|
|
131
|
-
return lines;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (start > 0) {
|
|
135
|
-
lines.push(truncateToWidth(this.theme.fg("dim", `... ${start} more above`), width));
|
|
136
|
-
}
|
|
137
|
-
for (const [offset, issue] of visibleIssues.entries()) {
|
|
138
|
-
const issueIndex = start + offset;
|
|
139
|
-
const isSelected = issueIndex === this.selected;
|
|
140
|
-
const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
141
|
-
const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
|
|
142
|
-
const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
|
|
143
|
-
const severity = severityColor(this.theme, issue.severity);
|
|
144
|
-
const summary = [
|
|
145
|
-
marker,
|
|
146
|
-
String(issueIndex + 1).padStart(2, " "),
|
|
147
|
-
".",
|
|
148
|
-
severity,
|
|
149
|
-
this.theme.fg("accent", location),
|
|
150
|
-
this.theme.fg("muted", rule),
|
|
151
|
-
this.theme.fg("text", `— ${issue.message}`),
|
|
152
|
-
].join(" ");
|
|
153
|
-
lines.push(truncateToWidth(summary, width));
|
|
154
|
-
}
|
|
155
|
-
if (end < this.state.issues.length) {
|
|
156
|
-
lines.push(
|
|
157
|
-
truncateToWidth(
|
|
158
|
-
this.theme.fg("dim", `... ${this.state.issues.length - end} more below`),
|
|
159
|
-
width,
|
|
160
|
-
),
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
lines.push(
|
|
165
|
-
"",
|
|
166
|
-
truncateToWidth(
|
|
167
|
-
this.theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"),
|
|
168
|
-
width,
|
|
169
|
-
),
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
return lines;
|
|
137
|
+
return renderListBrowser(this.theme, width, {
|
|
138
|
+
title: "SonarQube Issues",
|
|
139
|
+
subtitle: `${this.state.projectKey} • ${this.state.totalIssues} issue(s)${filterSuffix}`,
|
|
140
|
+
items: this.state.issues,
|
|
141
|
+
selected: this.selected,
|
|
142
|
+
pageSize: 20,
|
|
143
|
+
emptyMessage: "No open issues found.",
|
|
144
|
+
footer: "Up/Down to move, Enter to preview, Esc to close",
|
|
145
|
+
renderItem: (issue, issueIndex, isSelected) => {
|
|
146
|
+
const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
147
|
+
const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
|
|
148
|
+
const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
|
|
149
|
+
const severity = severityColor(this.theme, issue.severity);
|
|
150
|
+
return [
|
|
151
|
+
marker,
|
|
152
|
+
String(issueIndex + 1).padStart(2, " "),
|
|
153
|
+
".",
|
|
154
|
+
severity,
|
|
155
|
+
this.theme.fg("accent", location),
|
|
156
|
+
this.theme.fg("muted", rule),
|
|
157
|
+
this.theme.fg("text", `— ${issue.message}`),
|
|
158
|
+
].join(" ");
|
|
159
|
+
},
|
|
160
|
+
});
|
|
173
161
|
}
|
|
174
162
|
}
|
|
175
163
|
|
|
@@ -189,6 +177,86 @@ function severityColor(theme: Theme, severity: string): string {
|
|
|
189
177
|
}
|
|
190
178
|
}
|
|
191
179
|
|
|
180
|
+
function handleListBrowserInput(
|
|
181
|
+
data: string,
|
|
182
|
+
selected: number,
|
|
183
|
+
count: number,
|
|
184
|
+
done: (result: number | null) => void,
|
|
185
|
+
): number {
|
|
186
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
|
|
187
|
+
done(null);
|
|
188
|
+
return selected;
|
|
189
|
+
}
|
|
190
|
+
if (matchesKey(data, Key.up)) {
|
|
191
|
+
return Math.max(0, selected - 1);
|
|
192
|
+
}
|
|
193
|
+
if (matchesKey(data, Key.down)) {
|
|
194
|
+
return Math.min(Math.max(0, count - 1), selected + 1);
|
|
195
|
+
}
|
|
196
|
+
if (matchesKey(data, Key.enter)) {
|
|
197
|
+
done(selected);
|
|
198
|
+
}
|
|
199
|
+
return selected;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getBrowserWindow(selected: number, total: number, pageSize: number): { start: number; end: number } {
|
|
203
|
+
const halfWindow = Math.floor(pageSize / 2);
|
|
204
|
+
const maxStart = Math.max(0, total - pageSize);
|
|
205
|
+
const start = Math.max(0, Math.min(selected - halfWindow, maxStart));
|
|
206
|
+
const end = Math.min(total, start + pageSize);
|
|
207
|
+
return { start, end };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface ListBrowserRenderConfig<T> {
|
|
211
|
+
title: string;
|
|
212
|
+
subtitle: string;
|
|
213
|
+
items: readonly T[];
|
|
214
|
+
selected: number;
|
|
215
|
+
pageSize: number;
|
|
216
|
+
emptyMessage: string;
|
|
217
|
+
footer: string;
|
|
218
|
+
renderItem: (item: T, index: number, isSelected: boolean) => string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderListBrowser<T>(
|
|
222
|
+
theme: Theme,
|
|
223
|
+
width: number,
|
|
224
|
+
config: ListBrowserRenderConfig<T>,
|
|
225
|
+
): string[] {
|
|
226
|
+
const lines: string[] = [
|
|
227
|
+
truncateToWidth(theme.fg("accent", theme.bold(` ${config.title} `)), width),
|
|
228
|
+
truncateToWidth(theme.fg("dim", config.subtitle), width),
|
|
229
|
+
"",
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
const pageSize = Math.min(config.pageSize, config.items.length);
|
|
233
|
+
const { start, end } = getBrowserWindow(config.selected, config.items.length, pageSize);
|
|
234
|
+
const visible = config.items.slice(start, end);
|
|
235
|
+
|
|
236
|
+
if (visible.length === 0) {
|
|
237
|
+
lines.push(
|
|
238
|
+
truncateToWidth(theme.fg("success", config.emptyMessage), width),
|
|
239
|
+
"",
|
|
240
|
+
truncateToWidth(theme.fg("dim", config.footer), width),
|
|
241
|
+
);
|
|
242
|
+
return lines;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (start > 0) {
|
|
246
|
+
lines.push(truncateToWidth(theme.fg("dim", `... ${start} more above`), width));
|
|
247
|
+
}
|
|
248
|
+
for (const [offset, item] of visible.entries()) {
|
|
249
|
+
const index = start + offset;
|
|
250
|
+
lines.push(truncateToWidth(config.renderItem(item, index, index === config.selected), width));
|
|
251
|
+
}
|
|
252
|
+
if (end < config.items.length) {
|
|
253
|
+
lines.push(truncateToWidth(theme.fg("dim", `... ${config.items.length - end} more below`), width));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
lines.push("", truncateToWidth(theme.fg("dim", config.footer), width));
|
|
257
|
+
return lines;
|
|
258
|
+
}
|
|
259
|
+
|
|
192
260
|
// ── Duplication browser (used by index.ts) ─────────────────────────────────
|
|
193
261
|
|
|
194
262
|
export class DuplicationBrowser {
|
|
@@ -203,51 +271,24 @@ export class DuplicationBrowser {
|
|
|
203
271
|
invalidate(): void {} // NOSONAR - required by TUI interface
|
|
204
272
|
|
|
205
273
|
handleInput(data: string): void {
|
|
206
|
-
|
|
207
|
-
this.done(null);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
if (matchesKey(data, Key.up)) {
|
|
211
|
-
this.selected = Math.max(0, this.selected - 1);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (matchesKey(data, Key.down)) {
|
|
215
|
-
this.selected = Math.min(this.files.length - 1, this.selected + 1);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (matchesKey(data, Key.enter)) {
|
|
219
|
-
this.done(this.selected);
|
|
220
|
-
}
|
|
274
|
+
this.selected = handleListBrowserInput(data, this.selected, this.files.length, this.done);
|
|
221
275
|
}
|
|
222
276
|
|
|
223
277
|
render(width: number): string[] {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const index = start + offset;
|
|
239
|
-
const isSelected = index === this.selected;
|
|
240
|
-
const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
241
|
-
const detail = `blocks=${file.duplicatedBlocks} lines=${file.duplicatedLines}`;
|
|
242
|
-
const line = `${marker} ${String(index + 1).padStart(2, " ")}. ${this.theme.fg("accent", file.filePath)} ${this.theme.fg("dim", detail)}`;
|
|
243
|
-
lines.push(truncateToWidth(line, width));
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
lines.push(
|
|
247
|
-
"",
|
|
248
|
-
this.theme.fg("dim", "Up/Down to move, Enter for details, Esc to close"),
|
|
249
|
-
);
|
|
250
|
-
return lines;
|
|
278
|
+
return renderListBrowser(this.theme, width, {
|
|
279
|
+
title: "SonarQube Duplications",
|
|
280
|
+
subtitle: `${this.files.length} file(s) with duplicate code`,
|
|
281
|
+
items: this.files,
|
|
282
|
+
selected: this.selected,
|
|
283
|
+
pageSize: 15,
|
|
284
|
+
emptyMessage: "No duplicate code found.",
|
|
285
|
+
footer: "Up/Down to move, Enter for details, Esc to close",
|
|
286
|
+
renderItem: (file, index, isSelected) => {
|
|
287
|
+
const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
288
|
+
const detail = `blocks=${file.duplicatedBlocks} lines=${file.duplicatedLines}`;
|
|
289
|
+
return `${marker} ${String(index + 1).padStart(2, " ")}. ${this.theme.fg("accent", file.filePath)} ${this.theme.fg("dim", detail)}`;
|
|
290
|
+
},
|
|
291
|
+
});
|
|
251
292
|
}
|
|
252
293
|
}
|
|
253
294
|
|