@jonathan0823/pi-sonarqube 0.1.4 → 0.2.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 +25 -3
- package/dist/api.d.ts +6 -5
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +101 -25
- package/dist/api.js.map +1 -1
- package/dist/commands.d.ts +16 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +64 -8
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -3
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -6
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ui.d.ts +12 -3
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +61 -20
- package/dist/ui.js.map +1 -1
- package/package.json +1 -1
- package/src/api.ts +141 -30
- package/src/commands.ts +81 -15
- package/src/config.ts +0 -4
- package/src/index.ts +206 -3
- package/src/types.ts +33 -0
- package/src/ui.ts +81 -32
package/src/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type {
|
|
|
10
10
|
SonarIssueFetchOptions,
|
|
11
11
|
SonarInitConfig,
|
|
12
12
|
InitCommandOptions,
|
|
13
|
+
FileDuplication,
|
|
14
|
+
SonarProjectConfig,
|
|
13
15
|
} from "./types.js";
|
|
14
16
|
import {
|
|
15
17
|
slugify,
|
|
@@ -29,6 +31,10 @@ import {
|
|
|
29
31
|
waitForAnalysis,
|
|
30
32
|
normalizeIssueFilters,
|
|
31
33
|
fetchIssues,
|
|
34
|
+
fetchDuplicationMeasures,
|
|
35
|
+
fetchIssueSeverityCounts,
|
|
36
|
+
fetchFileDuplications,
|
|
37
|
+
fetchFileDuplicationBlocks,
|
|
32
38
|
createAnalysisState,
|
|
33
39
|
issueFilterLabel,
|
|
34
40
|
} from "./api.js";
|
|
@@ -40,6 +46,9 @@ import {
|
|
|
40
46
|
mergeAutocompleteSuggestions,
|
|
41
47
|
formatIssue,
|
|
42
48
|
formatSummary,
|
|
49
|
+
formatMetricsOutput,
|
|
50
|
+
formatDuplicationsList,
|
|
51
|
+
formatDuplicationBlockDetail,
|
|
43
52
|
formatReport,
|
|
44
53
|
helpText,
|
|
45
54
|
targetLabel,
|
|
@@ -52,10 +61,11 @@ import {
|
|
|
52
61
|
buildIssuePreview,
|
|
53
62
|
openIssuePreview,
|
|
54
63
|
resolveTargetState,
|
|
64
|
+
showDuplicationBrowser,
|
|
55
65
|
} from "./ui.js";
|
|
56
66
|
|
|
57
67
|
const SonarToolParams = Type.Object({
|
|
58
|
-
action: StringEnum(["analyze", "issues", "open"] as const),
|
|
68
|
+
action: StringEnum(["analyze", "issues", "open", "metrics", "duplications"] as const),
|
|
59
69
|
path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
|
|
60
70
|
issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
|
|
61
71
|
severities: Type.Optional(
|
|
@@ -126,11 +136,20 @@ async function analyzeProject(
|
|
|
126
136
|
normalizedFilters,
|
|
127
137
|
);
|
|
128
138
|
|
|
139
|
+
analysisUi.setPhase("Fetching metrics...");
|
|
140
|
+
const measures = await fetchDuplicationMeasures(
|
|
141
|
+
config.serverUrl,
|
|
142
|
+
config.token,
|
|
143
|
+
config.projectKey,
|
|
144
|
+
ctx.signal,
|
|
145
|
+
);
|
|
146
|
+
|
|
129
147
|
const state = createAnalysisState(config, issues, {
|
|
130
148
|
dashboardUrl,
|
|
131
149
|
ceTaskUrl,
|
|
132
150
|
analysisId,
|
|
133
151
|
filters: normalizedFilters,
|
|
152
|
+
measures,
|
|
134
153
|
});
|
|
135
154
|
|
|
136
155
|
pi.appendEntry(STATE_TYPE, state);
|
|
@@ -249,12 +268,25 @@ function renderIssueListResult(
|
|
|
249
268
|
theme: Theme,
|
|
250
269
|
): Text {
|
|
251
270
|
const summary = formatSummary(state);
|
|
252
|
-
if (state.issues.length === 0) {
|
|
271
|
+
if (state.issues.length === 0 && !state.measures) {
|
|
253
272
|
return new Text(theme.fg("success", summary), 0, 0);
|
|
254
273
|
}
|
|
255
274
|
|
|
275
|
+
const lines: string[] = [];
|
|
276
|
+
lines.push(theme.fg("accent", summary));
|
|
277
|
+
if (state.measures) {
|
|
278
|
+
const density = state.measures.duplicatedLinesDensity.toFixed(1);
|
|
279
|
+
const detail = state.measures.duplicatedBlocks > 0
|
|
280
|
+
? `Duplication: ${density}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
|
|
281
|
+
: `Duplication: ${density}% (no duplications detected)`;
|
|
282
|
+
lines.push(theme.fg("dim", detail));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (state.issues.length === 0) {
|
|
286
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
287
|
+
}
|
|
288
|
+
|
|
256
289
|
const visible = expanded ? state.issues : state.issues.slice(0, 5);
|
|
257
|
-
const lines: string[] = [theme.fg("accent", summary)];
|
|
258
290
|
for (const [i, issue] of visible.entries()) {
|
|
259
291
|
lines.push(theme.fg("muted", formatIssue(issue, i + 1)));
|
|
260
292
|
}
|
|
@@ -339,6 +371,9 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
339
371
|
return { content: [{ type: "text", text: formatReport(state) }], details: state };
|
|
340
372
|
}
|
|
341
373
|
|
|
374
|
+
if (params.action === "duplications") return toolDuplications(ctx, params.path);
|
|
375
|
+
if (params.action === "metrics") return toolMetrics(ctx, params.path);
|
|
376
|
+
|
|
342
377
|
const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
|
|
343
378
|
if (!targetState) {
|
|
344
379
|
return {
|
|
@@ -437,6 +472,12 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
437
472
|
await commandAnalyze(pi, ctx, parsed.targetInput, parsed.filters, rememberState);
|
|
438
473
|
return;
|
|
439
474
|
}
|
|
475
|
+
case "metrics":
|
|
476
|
+
await commandMetrics(ctx, parsed.targetInput);
|
|
477
|
+
return;
|
|
478
|
+
case "duplications":
|
|
479
|
+
await commandDuplications(pi, ctx, parsed.targetInput, parsed.issueIndex);
|
|
480
|
+
return;
|
|
440
481
|
default:
|
|
441
482
|
await commandIssuesOrOpen(pi, ctx, statesByBaseDir, parsed, rememberState);
|
|
442
483
|
}
|
|
@@ -469,6 +510,106 @@ async function commandAnalyze(
|
|
|
469
510
|
}
|
|
470
511
|
}
|
|
471
512
|
|
|
513
|
+
async function commandMetrics(
|
|
514
|
+
ctx: ExtensionCommandContext,
|
|
515
|
+
targetInput?: string,
|
|
516
|
+
): Promise<void> {
|
|
517
|
+
const config = await resolveConfig(ctx, targetInput);
|
|
518
|
+
const [measures, issueCounts] = await Promise.all([
|
|
519
|
+
fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
520
|
+
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
521
|
+
]);
|
|
522
|
+
if (!measures && !issueCounts) {
|
|
523
|
+
if (ctx.hasUI) {
|
|
524
|
+
ctx.ui.notify(`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`, "warning");
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
|
|
529
|
+
if (ctx.hasUI) {
|
|
530
|
+
ctx.ui.notify(text, "info");
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function commandDuplications(
|
|
535
|
+
pi: ExtensionAPI,
|
|
536
|
+
ctx: ExtensionCommandContext,
|
|
537
|
+
targetInput?: string,
|
|
538
|
+
fileIndex?: number,
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
const config = await resolveConfig(ctx, targetInput);
|
|
541
|
+
let files: FileDuplication[];
|
|
542
|
+
try {
|
|
543
|
+
files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
const msg = sonarErrorMessage(error);
|
|
546
|
+
if (msg.includes("403") || msg.includes("Insufficient privileges")) {
|
|
547
|
+
if (ctx.hasUI) {
|
|
548
|
+
ctx.ui.notify("SonarQube token needs 'See Source Code' permission to list duplicated files. Update the token permissions in SonarQube and run /sonarqube init to update the token.", "warning");
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
if (files.length === 0) {
|
|
555
|
+
// Fallback: check project-level metrics for confirmation
|
|
556
|
+
const projectMeasures = await fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal).catch(() => undefined);
|
|
557
|
+
if (projectMeasures && projectMeasures.duplicatedFiles > 0) {
|
|
558
|
+
if (ctx.hasUI) {
|
|
559
|
+
ctx.ui.notify(
|
|
560
|
+
`Cannot list individual files — your SonarQube token lacks "See Source Code" permission. Grant it in SonarQube project permissions, then run /sonarqube init to update the token.`,
|
|
561
|
+
"warning",
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
} else if (ctx.hasUI) {
|
|
565
|
+
ctx.ui.notify("No duplications found.", "info");
|
|
566
|
+
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (fileIndex !== undefined) {
|
|
571
|
+
await showDuplicationDrillDown(ctx, config, files, fileIndex);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
await showDuplicationListOrBrowser(ctx, config, files);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function showDuplicationDrillDown(
|
|
578
|
+
ctx: ExtensionCommandContext,
|
|
579
|
+
config: SonarProjectConfig,
|
|
580
|
+
files: FileDuplication[],
|
|
581
|
+
fileIndex: number,
|
|
582
|
+
): Promise<void> {
|
|
583
|
+
const file = files[fileIndex - 1];
|
|
584
|
+
if (!file) {
|
|
585
|
+
if (ctx.hasUI) ctx.ui.notify(`File #${fileIndex} not found.`, "error");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
|
|
589
|
+
const text = formatDuplicationBlockDetail(file.filePath, groups);
|
|
590
|
+
if (ctx.mode === "tui") {
|
|
591
|
+
await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
|
|
592
|
+
} else if (ctx.hasUI) {
|
|
593
|
+
ctx.ui.notify(text, "info");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function showDuplicationListOrBrowser(
|
|
598
|
+
ctx: ExtensionCommandContext,
|
|
599
|
+
config: SonarProjectConfig,
|
|
600
|
+
files: FileDuplication[],
|
|
601
|
+
): Promise<void> {
|
|
602
|
+
if (ctx.mode !== "tui") {
|
|
603
|
+
ctx.ui.notify(formatDuplicationsList(files), "info");
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const choice = await showDuplicationBrowser(ctx, files);
|
|
607
|
+
if (choice == null) return;
|
|
608
|
+
const file = files[choice];
|
|
609
|
+
const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
|
|
610
|
+
await ctx.ui.editor(`Duplications in ${file.filePath}`, formatDuplicationBlockDetail(file.filePath, groups));
|
|
611
|
+
}
|
|
612
|
+
|
|
472
613
|
async function commandIssuesOrOpen(
|
|
473
614
|
pi: ExtensionAPI,
|
|
474
615
|
ctx: ExtensionCommandContext,
|
|
@@ -518,6 +659,68 @@ async function commandShowIssues(
|
|
|
518
659
|
}
|
|
519
660
|
}
|
|
520
661
|
|
|
662
|
+
// ── Tool helper functions ────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
async function toolDuplications(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
665
|
+
let config;
|
|
666
|
+
try {
|
|
667
|
+
config = await resolveConfig(ctx, path);
|
|
668
|
+
} catch {
|
|
669
|
+
return {
|
|
670
|
+
content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
|
|
671
|
+
details: { error: "Project not configured." },
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
let files: FileDuplication[];
|
|
675
|
+
try {
|
|
676
|
+
files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
const msg = sonarErrorMessage(error);
|
|
679
|
+
if (msg.includes("403") || msg.includes("Insufficient privileges")) {
|
|
680
|
+
return {
|
|
681
|
+
content: [{ type: "text", text: "SonarQube token needs 'See Source Code' permission to list duplicated files. Grant it in SonarQube project permissions, then update the token via /sonarqube init." }],
|
|
682
|
+
details: { error: "Token missing 'See Source Code' permission." },
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
content: [{ type: "text", text: msg }],
|
|
687
|
+
details: { error: msg },
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
if (files.length === 0) {
|
|
691
|
+
return {
|
|
692
|
+
content: [{ type: "text", text: `No duplications found for ${config.projectKey}.` }],
|
|
693
|
+
details: { error: `No duplications found for ${config.projectKey}.` },
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const text = formatDuplicationsList(files);
|
|
697
|
+
return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, files } };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
701
|
+
let config;
|
|
702
|
+
try {
|
|
703
|
+
config = await resolveConfig(ctx, path);
|
|
704
|
+
} catch {
|
|
705
|
+
return {
|
|
706
|
+
content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
|
|
707
|
+
details: { error: "Project not configured." },
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
const [measures, issueCounts] = await Promise.all([
|
|
711
|
+
fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
712
|
+
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
713
|
+
]);
|
|
714
|
+
if (!measures && !issueCounts) {
|
|
715
|
+
return {
|
|
716
|
+
content: [{ type: "text", text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` }],
|
|
717
|
+
details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
|
|
721
|
+
return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts } };
|
|
722
|
+
}
|
|
723
|
+
|
|
521
724
|
// ── Re-exports for public API ───────────────────────────────────────────────
|
|
522
725
|
|
|
523
726
|
export type {
|
package/src/types.ts
CHANGED
|
@@ -13,6 +13,38 @@ export interface SonarIssue {
|
|
|
13
13
|
status?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface SonarDuplicationMeasures {
|
|
17
|
+
duplicatedLinesDensity: number;
|
|
18
|
+
duplicatedLines: number;
|
|
19
|
+
duplicatedBlocks: number;
|
|
20
|
+
duplicatedFiles: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IssueSeverityCounts {
|
|
24
|
+
blocker: number;
|
|
25
|
+
critical: number;
|
|
26
|
+
major: number;
|
|
27
|
+
minor: number;
|
|
28
|
+
info: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FileDuplication {
|
|
32
|
+
filePath: string;
|
|
33
|
+
fileKey: string;
|
|
34
|
+
duplicatedLines: number;
|
|
35
|
+
duplicatedBlocks: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DuplicationBlock {
|
|
39
|
+
from: number;
|
|
40
|
+
size: number;
|
|
41
|
+
filePath: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DuplicationBlockGroup {
|
|
45
|
+
blocks: DuplicationBlock[];
|
|
46
|
+
}
|
|
47
|
+
|
|
16
48
|
export interface SonarAnalysisState {
|
|
17
49
|
version: 1;
|
|
18
50
|
analyzedAt: string;
|
|
@@ -23,6 +55,7 @@ export interface SonarAnalysisState {
|
|
|
23
55
|
ceTaskUrl?: string;
|
|
24
56
|
analysisId?: string;
|
|
25
57
|
filters?: SonarIssueFetchOptions;
|
|
58
|
+
measures?: SonarDuplicationMeasures;
|
|
26
59
|
totalIssues: number;
|
|
27
60
|
issues: SonarIssue[];
|
|
28
61
|
}
|
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 } from "./types.js";
|
|
5
|
+
import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication } from "./types.js";
|
|
6
6
|
import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters } from "./api.js";
|
|
7
7
|
import { resolveConfig, resolveTarget } from "./config.js";
|
|
8
8
|
|
|
@@ -71,8 +71,6 @@ export function startAnalysisUi(ctx: ExtensionContext, projectKey: string): Anal
|
|
|
71
71
|
|
|
72
72
|
export class IssueBrowser {
|
|
73
73
|
private selected = 0;
|
|
74
|
-
private cachedWidth: number | undefined;
|
|
75
|
-
private cachedLines: string[] | undefined;
|
|
76
74
|
|
|
77
75
|
constructor(
|
|
78
76
|
private readonly state: SonarAnalysisState,
|
|
@@ -80,10 +78,7 @@ export class IssueBrowser {
|
|
|
80
78
|
private readonly done: (result: number | null) => void,
|
|
81
79
|
) {}
|
|
82
80
|
|
|
83
|
-
invalidate(): void {
|
|
84
|
-
this.cachedWidth = undefined;
|
|
85
|
-
this.cachedLines = undefined;
|
|
86
|
-
}
|
|
81
|
+
invalidate(): void {} // NOSONAR - required by TUI interface
|
|
87
82
|
|
|
88
83
|
handleInput(data: string): void {
|
|
89
84
|
if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
|
|
@@ -92,12 +87,10 @@ export class IssueBrowser {
|
|
|
92
87
|
}
|
|
93
88
|
if (matchesKey(data, Key.up)) {
|
|
94
89
|
this.selected = Math.max(0, this.selected - 1);
|
|
95
|
-
this.invalidate();
|
|
96
90
|
return;
|
|
97
91
|
}
|
|
98
92
|
if (matchesKey(data, Key.down)) {
|
|
99
93
|
this.selected = Math.min(this.state.issues.length - 1, this.selected + 1);
|
|
100
|
-
this.invalidate();
|
|
101
94
|
return;
|
|
102
95
|
}
|
|
103
96
|
if (matchesKey(data, Key.enter)) {
|
|
@@ -106,10 +99,6 @@ export class IssueBrowser {
|
|
|
106
99
|
}
|
|
107
100
|
|
|
108
101
|
render(width: number): string[] {
|
|
109
|
-
if (this.cachedLines && this.cachedWidth === width) {
|
|
110
|
-
return this.cachedLines;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
102
|
const lines: string[] = [];
|
|
114
103
|
const title = this.theme.fg("accent", this.theme.bold(" SonarQube Issues "));
|
|
115
104
|
const filterSuffix = this.state.filters ? ` • ${issueFilterLabel(this.state.filters)}` : "";
|
|
@@ -131,8 +120,15 @@ export class IssueBrowser {
|
|
|
131
120
|
const visibleIssues = this.state.issues.slice(start, end);
|
|
132
121
|
|
|
133
122
|
if (visibleIssues.length === 0) {
|
|
134
|
-
lines.push(
|
|
135
|
-
|
|
123
|
+
lines.push(
|
|
124
|
+
truncateToWidth(this.theme.fg("success", "No open issues found."), width),
|
|
125
|
+
"",
|
|
126
|
+
truncateToWidth(
|
|
127
|
+
this.theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"),
|
|
128
|
+
width,
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
return lines;
|
|
136
132
|
}
|
|
137
133
|
|
|
138
134
|
if (start > 0) {
|
|
@@ -173,27 +169,10 @@ export class IssueBrowser {
|
|
|
173
169
|
),
|
|
174
170
|
);
|
|
175
171
|
|
|
176
|
-
this.cachedWidth = width;
|
|
177
|
-
this.cachedLines = lines;
|
|
178
172
|
return lines;
|
|
179
173
|
}
|
|
180
174
|
}
|
|
181
175
|
|
|
182
|
-
function finalize(
|
|
183
|
-
lines: string[],
|
|
184
|
-
width: number,
|
|
185
|
-
theme: Theme,
|
|
186
|
-
cachedWidth: number | undefined,
|
|
187
|
-
cachedLines: string[] | undefined,
|
|
188
|
-
): string[] {
|
|
189
|
-
lines.push(
|
|
190
|
-
"",
|
|
191
|
-
truncateToWidth(theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"), width),
|
|
192
|
-
);
|
|
193
|
-
const result = cachedLines ?? lines;
|
|
194
|
-
return result;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
176
|
// ── Severity colors ─────────────────────────────────────────────────────────
|
|
198
177
|
|
|
199
178
|
function severityColor(theme: Theme, severity: string): string {
|
|
@@ -210,6 +189,76 @@ function severityColor(theme: Theme, severity: string): string {
|
|
|
210
189
|
}
|
|
211
190
|
}
|
|
212
191
|
|
|
192
|
+
// ── Duplication browser (used by index.ts) ─────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export class DuplicationBrowser {
|
|
195
|
+
private selected = 0;
|
|
196
|
+
|
|
197
|
+
constructor(
|
|
198
|
+
private readonly files: FileDuplication[],
|
|
199
|
+
private readonly theme: Theme,
|
|
200
|
+
private readonly done: (result: number | null) => void,
|
|
201
|
+
) {}
|
|
202
|
+
|
|
203
|
+
invalidate(): void {} // NOSONAR - required by TUI interface
|
|
204
|
+
|
|
205
|
+
handleInput(data: string): void {
|
|
206
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
|
|
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
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
render(width: number): string[] {
|
|
224
|
+
const lines: string[] = [
|
|
225
|
+
this.theme.fg("accent", this.theme.bold(" SonarQube Duplications ")),
|
|
226
|
+
this.theme.fg("dim", `${this.files.length} file(s) with duplicate code`),
|
|
227
|
+
"",
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
const pageSize = Math.min(15, this.files.length);
|
|
231
|
+
const halfWindow = Math.floor(pageSize / 2);
|
|
232
|
+
const maxStart = Math.max(0, this.files.length - pageSize);
|
|
233
|
+
const start = Math.max(0, Math.min(this.selected - halfWindow, maxStart));
|
|
234
|
+
const end = Math.min(this.files.length, start + pageSize);
|
|
235
|
+
const visible = this.files.slice(start, end);
|
|
236
|
+
|
|
237
|
+
for (const [offset, file] of visible.entries()) {
|
|
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;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function showDuplicationBrowser(
|
|
255
|
+
ctx: ExtensionCommandContext,
|
|
256
|
+
files: FileDuplication[],
|
|
257
|
+
): Promise<number | null> {
|
|
258
|
+
if (ctx.mode !== "tui" || files.length === 0) return null;
|
|
259
|
+
return await ctx.ui.custom<number | null>((_tui, theme, _kb, done) => new DuplicationBrowser(files, theme, done));
|
|
260
|
+
}
|
|
261
|
+
|
|
213
262
|
// ── Issue browser / preview helpers (used by index.ts) ──────────────────────
|
|
214
263
|
|
|
215
264
|
export async function showIssueBrowser(
|