@jonathan0823/pi-sonarqube 0.3.1 → 0.4.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/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 +65 -13
- 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 +66 -11
- 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,16 @@ 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
|
-
|
|
619
|
+
const lines = [`Duplications in ${file.filePath}`];
|
|
620
|
+
for (const [i, group] of groups.entries()) {
|
|
621
|
+
lines.push("", `Block ${i + 1}:`);
|
|
622
|
+
for (const block of group.blocks) {
|
|
623
|
+
const end = block.from + block.size - 1;
|
|
624
|
+
lines.push(` ${block.filePath}:${block.from}-${end}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
ctx.ui.setEditorText(lines.join("\n"));
|
|
628
|
+
ctx.ui.notify("Duplication loaded into editor — press Enter to send", "info");
|
|
619
629
|
}
|
|
620
630
|
|
|
621
631
|
async function commandIssuesOrOpen(
|
|
@@ -663,7 +673,12 @@ async function commandShowIssues(
|
|
|
663
673
|
const choice = await showIssueBrowser(ctx, targetState);
|
|
664
674
|
if (choice != null) {
|
|
665
675
|
const issue = targetState.issues[choice];
|
|
666
|
-
if (issue)
|
|
676
|
+
if (issue) {
|
|
677
|
+
const line = issue.line ? `:${issue.line}` : "";
|
|
678
|
+
const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
|
|
679
|
+
ctx.ui.setEditorText(`${issue.severity} ${issue.filePath}${line} — ${rule} — ${issue.message}`);
|
|
680
|
+
ctx.ui.notify("Issue loaded into editor — press Enter to send", "info");
|
|
681
|
+
}
|
|
667
682
|
}
|
|
668
683
|
}
|
|
669
684
|
|
|
@@ -701,8 +716,47 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
|
|
|
701
716
|
details: { error: `No duplications found for ${config.projectKey}.` },
|
|
702
717
|
};
|
|
703
718
|
}
|
|
704
|
-
|
|
705
|
-
|
|
719
|
+
|
|
720
|
+
const topFiles = files.slice(0, 5);
|
|
721
|
+
const topContexts = await Promise.all(
|
|
722
|
+
topFiles.map(async (file) => {
|
|
723
|
+
try {
|
|
724
|
+
const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
|
|
725
|
+
const preview = await buildDuplicationPreview(config.baseDir, file.filePath, groups);
|
|
726
|
+
return {
|
|
727
|
+
filePath: file.filePath,
|
|
728
|
+
duplicatedBlocks: file.duplicatedBlocks,
|
|
729
|
+
duplicatedLines: file.duplicatedLines,
|
|
730
|
+
preview,
|
|
731
|
+
};
|
|
732
|
+
} catch (error) {
|
|
733
|
+
return {
|
|
734
|
+
filePath: file.filePath,
|
|
735
|
+
duplicatedBlocks: file.duplicatedBlocks,
|
|
736
|
+
duplicatedLines: file.duplicatedLines,
|
|
737
|
+
preview: `Duplications in ${file.filePath}\n\n<failed to load duplicated lines: ${sonarErrorMessage(error)}>`,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
}),
|
|
741
|
+
);
|
|
742
|
+
const remaining = files.length - topContexts.length;
|
|
743
|
+
const content: Array<{ type: "text"; text: string }> = [
|
|
744
|
+
{ type: "text", text: `Duplicated files for ${config.projectKey}: ${files.length}. Showing top ${topContexts.length}.` },
|
|
745
|
+
...topContexts.map((item, index) => ({
|
|
746
|
+
type: "text" as const,
|
|
747
|
+
text: `#${index + 1} ${item.filePath} blocks=${item.duplicatedBlocks} lines=${item.duplicatedLines}\n${item.preview}`,
|
|
748
|
+
})),
|
|
749
|
+
...(remaining > 0 ? [{ type: "text" as const, text: `+ ${remaining} more file(s) not shown` }] : []),
|
|
750
|
+
];
|
|
751
|
+
return {
|
|
752
|
+
content,
|
|
753
|
+
details: {
|
|
754
|
+
projectKey: config.projectKey,
|
|
755
|
+
totalFiles: files.length,
|
|
756
|
+
topFiles: topContexts,
|
|
757
|
+
moreFiles: remaining,
|
|
758
|
+
},
|
|
759
|
+
};
|
|
706
760
|
}
|
|
707
761
|
|
|
708
762
|
async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
@@ -715,9 +769,10 @@ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ conten
|
|
|
715
769
|
details: { error: "Project not configured." },
|
|
716
770
|
};
|
|
717
771
|
}
|
|
772
|
+
const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
|
|
718
773
|
const [measures, issueCounts, qualityCounts] = await Promise.all([
|
|
719
774
|
fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
720
|
-
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
775
|
+
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
|
|
721
776
|
fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
722
777
|
]);
|
|
723
778
|
if (!measures && !issueCounts && !qualityCounts) {
|
|
@@ -726,8 +781,8 @@ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ conten
|
|
|
726
781
|
details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
|
|
727
782
|
};
|
|
728
783
|
}
|
|
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 } };
|
|
784
|
+
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
|
|
785
|
+
return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode } };
|
|
731
786
|
}
|
|
732
787
|
|
|
733
788
|
// ── 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
|
|