@jonathan0823/pi-sonarqube 0.1.4 → 0.3.1

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/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,8 +31,15 @@ import {
29
31
  waitForAnalysis,
30
32
  normalizeIssueFilters,
31
33
  fetchIssues,
34
+ fetchDuplicationMeasures,
35
+ fetchIssueSeverityCounts,
36
+ fetchIssueQualityCounts,
37
+ fetchFileDuplications,
38
+ fetchFileDuplicationBlocks,
32
39
  createAnalysisState,
33
40
  issueFilterLabel,
41
+ fetchCleanCodeMode,
42
+ assertFiltersNotAmbiguous,
34
43
  } from "./api.js";
35
44
  import {
36
45
  STATE_TYPE,
@@ -40,6 +49,9 @@ import {
40
49
  mergeAutocompleteSuggestions,
41
50
  formatIssue,
42
51
  formatSummary,
52
+ formatMetricsOutput,
53
+ formatDuplicationsList,
54
+ formatDuplicationBlockDetail,
43
55
  formatReport,
44
56
  helpText,
45
57
  targetLabel,
@@ -52,10 +64,11 @@ import {
52
64
  buildIssuePreview,
53
65
  openIssuePreview,
54
66
  resolveTargetState,
67
+ showDuplicationBrowser,
55
68
  } from "./ui.js";
56
69
 
57
70
  const SonarToolParams = Type.Object({
58
- action: StringEnum(["analyze", "issues", "open"] as const),
71
+ action: StringEnum(["analyze", "issues", "open", "metrics", "duplications"] as const),
59
72
  path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
60
73
  issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
61
74
  severities: Type.Optional(
@@ -66,6 +79,12 @@ const SonarToolParams = Type.Object({
66
79
  ),
67
80
  types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
68
81
  rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
82
+ softwareQualities: Type.Optional(
83
+ Type.Array(Type.String({ description: "Software quality to fetch (e.g. MAINTAINABILITY)" })),
84
+ ),
85
+ impactSeverities: Type.Optional(
86
+ Type.Array(Type.String({ description: "Impact severity to fetch (e.g. HIGH)" })),
87
+ ),
69
88
  });
70
89
 
71
90
  // ── State restoration ───────────────────────────────────────────────────────
@@ -126,11 +145,24 @@ async function analyzeProject(
126
145
  normalizedFilters,
127
146
  );
128
147
 
148
+ analysisUi.setPhase("Fetching metrics...");
149
+ const measures = await fetchDuplicationMeasures(
150
+ config.serverUrl,
151
+ config.token,
152
+ config.projectKey,
153
+ ctx.signal,
154
+ );
155
+
156
+ analysisUi.setPhase("Detecting mode...");
157
+ const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
158
+
129
159
  const state = createAnalysisState(config, issues, {
130
160
  dashboardUrl,
131
161
  ceTaskUrl,
132
162
  analysisId,
133
163
  filters: normalizedFilters,
164
+ measures,
165
+ cleanCodeMode,
134
166
  });
135
167
 
136
168
  pi.appendEntry(STATE_TYPE, state);
@@ -249,12 +281,25 @@ function renderIssueListResult(
249
281
  theme: Theme,
250
282
  ): Text {
251
283
  const summary = formatSummary(state);
252
- if (state.issues.length === 0) {
284
+ if (state.issues.length === 0 && !state.measures) {
253
285
  return new Text(theme.fg("success", summary), 0, 0);
254
286
  }
255
287
 
288
+ const lines: string[] = [];
289
+ lines.push(theme.fg("accent", summary));
290
+ if (state.measures) {
291
+ const density = state.measures.duplicatedLinesDensity.toFixed(1);
292
+ const detail = state.measures.duplicatedBlocks > 0
293
+ ? `Duplication: ${density}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
294
+ : `Duplication: ${density}% (no duplications detected)`;
295
+ lines.push(theme.fg("dim", detail));
296
+ }
297
+
298
+ if (state.issues.length === 0) {
299
+ return new Text(lines.join("\n"), 0, 0);
300
+ }
301
+
256
302
  const visible = expanded ? state.issues : state.issues.slice(0, 5);
257
- const lines: string[] = [theme.fg("accent", summary)];
258
303
  for (const [i, issue] of visible.entries()) {
259
304
  lines.push(theme.fg("muted", formatIssue(issue, i + 1)));
260
305
  }
@@ -292,7 +337,7 @@ export default function sonarqube(pi: ExtensionAPI) {
292
337
 
293
338
  const argumentText = match[1] ?? "";
294
339
  const { current: currentToken } = splitSonarArgumentContext(argumentText);
295
- const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues) ?? [];
340
+ const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues, latestState?.cleanCodeMode) ?? [];
296
341
  const currentSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
297
342
  const merged = mergeAutocompleteSuggestions(currentToken, currentSuggestions, extraItems);
298
343
  return merged ?? currentSuggestions;
@@ -320,7 +365,7 @@ export default function sonarqube(pi: ExtensionAPI) {
320
365
  promptSnippet: "Run local SonarQube analysis or inspect issue results",
321
366
  promptGuidelines: [
322
367
  "Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
323
- "Use the optional severity, status, type, and rule filters to fetch only the most relevant issues.",
368
+ "Use the optional severity, status, type, rule, quality, and impact severity filters to fetch only the most relevant issues.",
324
369
  ],
325
370
  parameters: SonarToolParams,
326
371
 
@@ -331,7 +376,10 @@ export default function sonarqube(pi: ExtensionAPI) {
331
376
  statuses: params.statuses,
332
377
  types: params.types,
333
378
  rules: params.rules,
379
+ softwareQualities: params.softwareQualities,
380
+ impactSeverities: params.impactSeverities,
334
381
  });
382
+ assertFiltersNotAmbiguous(filters);
335
383
 
336
384
  if (params.action === "analyze") {
337
385
  const state = await analyzeProject(pi, ctx, params.path, filters);
@@ -339,6 +387,9 @@ export default function sonarqube(pi: ExtensionAPI) {
339
387
  return { content: [{ type: "text", text: formatReport(state) }], details: state };
340
388
  }
341
389
 
390
+ if (params.action === "duplications") return toolDuplications(ctx, params.path);
391
+ if (params.action === "metrics") return toolMetrics(ctx, params.path);
392
+
342
393
  const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
343
394
  if (!targetState) {
344
395
  return {
@@ -379,6 +430,8 @@ export default function sonarqube(pi: ExtensionAPI) {
379
430
  statuses: args.statuses,
380
431
  types: args.types,
381
432
  rules: args.rules,
433
+ softwareQualities: args.softwareQualities,
434
+ impactSeverities: args.impactSeverities,
382
435
  });
383
436
  const filterText = filters ? ` ${theme.fg("muted", issueFilterLabel(filters))}` : "";
384
437
  return new Text(
@@ -422,7 +475,7 @@ export default function sonarqube(pi: ExtensionAPI) {
422
475
 
423
476
  pi.registerCommand("sonarqube", {
424
477
  description: "Run a local SonarQube analysis, browse the latest issues, or init project config",
425
- getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues),
478
+ getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues, latestState?.cleanCodeMode),
426
479
  handler: async (args, ctx) => {
427
480
  try {
428
481
  const parsed = parseCommandArgs(args);
@@ -437,6 +490,12 @@ export default function sonarqube(pi: ExtensionAPI) {
437
490
  await commandAnalyze(pi, ctx, parsed.targetInput, parsed.filters, rememberState);
438
491
  return;
439
492
  }
493
+ case "metrics":
494
+ await commandMetrics(ctx, parsed.targetInput);
495
+ return;
496
+ case "duplications":
497
+ await commandDuplications(pi, ctx, parsed.targetInput, parsed.issueIndex);
498
+ return;
440
499
  default:
441
500
  await commandIssuesOrOpen(pi, ctx, statesByBaseDir, parsed, rememberState);
442
501
  }
@@ -469,6 +528,96 @@ async function commandAnalyze(
469
528
  }
470
529
  }
471
530
 
531
+ async function commandMetrics(
532
+ ctx: ExtensionCommandContext,
533
+ targetInput?: string,
534
+ ): Promise<void> {
535
+ const config = await resolveConfig(ctx, targetInput);
536
+ const [measures, issueCounts, qualityCounts] = await Promise.all([
537
+ fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
538
+ fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
539
+ fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
540
+ ]);
541
+ if (!measures && !issueCounts && !qualityCounts) {
542
+ if (ctx.hasUI) {
543
+ ctx.ui.notify(`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`, "warning");
544
+ }
545
+ return;
546
+ }
547
+ const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts });
548
+ if (ctx.hasUI) {
549
+ ctx.ui.notify(text, "info");
550
+ }
551
+ }
552
+
553
+ async function commandDuplications(
554
+ pi: ExtensionAPI,
555
+ ctx: ExtensionCommandContext,
556
+ targetInput?: string,
557
+ fileIndex?: number,
558
+ ): Promise<void> {
559
+ const config = await resolveConfig(ctx, targetInput);
560
+ let files: FileDuplication[];
561
+ try {
562
+ files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
563
+ } catch (error) {
564
+ const msg = sonarErrorMessage(error);
565
+ if (msg.includes("403") || msg.includes("Insufficient privileges")) {
566
+ if (ctx.hasUI) {
567
+ 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");
568
+ }
569
+ return;
570
+ }
571
+ throw error;
572
+ }
573
+ if (files.length === 0) {
574
+ if (ctx.hasUI) ctx.ui.notify("No duplications found.", "info");
575
+ return;
576
+ }
577
+
578
+ if (fileIndex !== undefined) {
579
+ await showDuplicationDrillDown(ctx, config, files, fileIndex);
580
+ return;
581
+ }
582
+ await showDuplicationListOrBrowser(ctx, config, files);
583
+ }
584
+
585
+ async function showDuplicationDrillDown(
586
+ ctx: ExtensionCommandContext,
587
+ config: SonarProjectConfig,
588
+ files: FileDuplication[],
589
+ fileIndex: number,
590
+ ): Promise<void> {
591
+ const file = files[fileIndex - 1];
592
+ if (!file) {
593
+ if (ctx.hasUI) ctx.ui.notify(`File #${fileIndex} not found.`, "error");
594
+ return;
595
+ }
596
+ const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
597
+ const text = formatDuplicationBlockDetail(file.filePath, groups);
598
+ if (ctx.mode === "tui") {
599
+ await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
600
+ } else if (ctx.hasUI) {
601
+ ctx.ui.notify(text, "info");
602
+ }
603
+ }
604
+
605
+ async function showDuplicationListOrBrowser(
606
+ ctx: ExtensionCommandContext,
607
+ config: SonarProjectConfig,
608
+ files: FileDuplication[],
609
+ ): Promise<void> {
610
+ if (ctx.mode !== "tui") {
611
+ ctx.ui.notify(formatDuplicationsList(files), "info");
612
+ return;
613
+ }
614
+ const choice = await showDuplicationBrowser(ctx, files);
615
+ if (choice == null) return;
616
+ const file = files[choice];
617
+ const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
618
+ await ctx.ui.editor(`Duplications in ${file.filePath}`, formatDuplicationBlockDetail(file.filePath, groups));
619
+ }
620
+
472
621
  async function commandIssuesOrOpen(
473
622
  pi: ExtensionAPI,
474
623
  ctx: ExtensionCommandContext,
@@ -518,6 +667,69 @@ async function commandShowIssues(
518
667
  }
519
668
  }
520
669
 
670
+ // ── Tool helper functions ────────────────────────────────────────────────────
671
+
672
+ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
673
+ let config;
674
+ try {
675
+ config = await resolveConfig(ctx, path);
676
+ } catch {
677
+ return {
678
+ content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
679
+ details: { error: "Project not configured." },
680
+ };
681
+ }
682
+ let files: FileDuplication[];
683
+ try {
684
+ files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
685
+ } catch (error) {
686
+ const msg = sonarErrorMessage(error);
687
+ if (msg.includes("403") || msg.includes("Insufficient privileges")) {
688
+ return {
689
+ 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." }],
690
+ details: { error: "Token missing 'See Source Code' permission." },
691
+ };
692
+ }
693
+ return {
694
+ content: [{ type: "text", text: msg }],
695
+ details: { error: msg },
696
+ };
697
+ }
698
+ if (files.length === 0) {
699
+ return {
700
+ content: [{ type: "text", text: `No duplications found for ${config.projectKey}.` }],
701
+ details: { error: `No duplications found for ${config.projectKey}.` },
702
+ };
703
+ }
704
+ const text = formatDuplicationsList(files);
705
+ return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, files } };
706
+ }
707
+
708
+ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
709
+ let config;
710
+ try {
711
+ config = await resolveConfig(ctx, path);
712
+ } catch {
713
+ return {
714
+ content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
715
+ details: { error: "Project not configured." },
716
+ };
717
+ }
718
+ const [measures, issueCounts, qualityCounts] = await Promise.all([
719
+ fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
720
+ fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
721
+ fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
722
+ ]);
723
+ if (!measures && !issueCounts && !qualityCounts) {
724
+ return {
725
+ content: [{ type: "text", text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` }],
726
+ details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
727
+ };
728
+ }
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 } };
731
+ }
732
+
521
733
  // ── Re-exports for public API ───────────────────────────────────────────────
522
734
 
523
735
  export type {
@@ -527,6 +739,7 @@ export type {
527
739
  SonarProjectConfig,
528
740
  SonarIssueFetchOptions,
529
741
  SonarInitConfig,
742
+ IssueQualityCounts,
530
743
  } from "./types.js";
531
744
 
532
745
  export { projectConfigPath, loadProjectConfig, saveProjectConfig } from "./config.js";
package/src/types.ts CHANGED
@@ -13,6 +13,44 @@ 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 IssueQualityCounts {
32
+ maintainability: number;
33
+ reliability: number;
34
+ security: number;
35
+ }
36
+
37
+ export interface FileDuplication {
38
+ filePath: string;
39
+ fileKey: string;
40
+ duplicatedLines: number;
41
+ duplicatedBlocks: number;
42
+ }
43
+
44
+ export interface DuplicationBlock {
45
+ from: number;
46
+ size: number;
47
+ filePath: string;
48
+ }
49
+
50
+ export interface DuplicationBlockGroup {
51
+ blocks: DuplicationBlock[];
52
+ }
53
+
16
54
  export interface SonarAnalysisState {
17
55
  version: 1;
18
56
  analyzedAt: string;
@@ -23,8 +61,10 @@ export interface SonarAnalysisState {
23
61
  ceTaskUrl?: string;
24
62
  analysisId?: string;
25
63
  filters?: SonarIssueFetchOptions;
64
+ measures?: SonarDuplicationMeasures;
26
65
  totalIssues: number;
27
66
  issues: SonarIssue[];
67
+ cleanCodeMode?: "STANDARD" | "MQR";
28
68
  }
29
69
 
30
70
  export interface SonarProjectConfig {
@@ -40,6 +80,8 @@ export interface SonarIssueFetchOptions {
40
80
  statuses?: string[];
41
81
  types?: string[];
42
82
  rules?: string[];
83
+ softwareQualities?: string[];
84
+ impactSeverities?: string[];
43
85
  }
44
86
 
45
87
  export interface SonarInitConfig {
@@ -77,3 +119,5 @@ export interface InitCommandOptions {
77
119
  export const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"] as const;
78
120
  export const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"] as const;
79
121
  export const SONAR_TYPES = ["BUG", "VULNERABILITY", "CODE_SMELL"] as const;
122
+ export const SONAR_SOFTWARE_QUALITIES = ["MAINTAINABILITY", "RELIABILITY", "SECURITY"] as const;
123
+ export const SONAR_IMPACT_SEVERITIES = ["BLOCKER", "HIGH", "MEDIUM", "LOW", "INFO"] as const;
package/src/ui.ts CHANGED
@@ -2,8 +2,8 @@ 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";
6
- import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters } from "./api.js";
5
+ import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication } from "./types.js";
6
+ import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters, fetchCleanCodeMode } from "./api.js";
7
7
  import { resolveConfig, resolveTarget } from "./config.js";
8
8
 
9
9
  // ── Issue preview ───────────────────────────────────────────────────────────
@@ -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(truncateToWidth(this.theme.fg("success", "No open issues found."), width));
135
- return finalize(lines, width, this.theme, this.cachedWidth, this.cachedLines);
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(
@@ -251,7 +300,8 @@ export async function loadProjectIssuesFromApi(
251
300
  ctx.signal,
252
301
  normalizedFilters,
253
302
  );
254
- return createAnalysisState(config, issues, { filters: normalizedFilters });
303
+ const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
304
+ return createAnalysisState(config, issues, { filters: normalizedFilters, cleanCodeMode });
255
305
  }
256
306
 
257
307
  export async function resolveTargetState(