@jonathan0823/pi-sonarqube 0.2.0 → 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/src/index.ts CHANGED
@@ -33,10 +33,13 @@ import {
33
33
  fetchIssues,
34
34
  fetchDuplicationMeasures,
35
35
  fetchIssueSeverityCounts,
36
+ fetchIssueQualityCounts,
36
37
  fetchFileDuplications,
37
38
  fetchFileDuplicationBlocks,
38
39
  createAnalysisState,
39
40
  issueFilterLabel,
41
+ fetchCleanCodeMode,
42
+ assertFiltersNotAmbiguous,
40
43
  } from "./api.js";
41
44
  import {
42
45
  STATE_TYPE,
@@ -48,7 +51,6 @@ import {
48
51
  formatSummary,
49
52
  formatMetricsOutput,
50
53
  formatDuplicationsList,
51
- formatDuplicationBlockDetail,
52
54
  formatReport,
53
55
  helpText,
54
56
  targetLabel,
@@ -59,6 +61,7 @@ import {
59
61
  startAnalysisUi,
60
62
  showIssueBrowser,
61
63
  buildIssuePreview,
64
+ buildDuplicationPreview,
62
65
  openIssuePreview,
63
66
  resolveTargetState,
64
67
  showDuplicationBrowser,
@@ -76,6 +79,12 @@ const SonarToolParams = Type.Object({
76
79
  ),
77
80
  types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
78
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
+ ),
79
88
  });
80
89
 
81
90
  // ── State restoration ───────────────────────────────────────────────────────
@@ -144,12 +153,16 @@ async function analyzeProject(
144
153
  ctx.signal,
145
154
  );
146
155
 
156
+ analysisUi.setPhase("Detecting mode...");
157
+ const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
158
+
147
159
  const state = createAnalysisState(config, issues, {
148
160
  dashboardUrl,
149
161
  ceTaskUrl,
150
162
  analysisId,
151
163
  filters: normalizedFilters,
152
164
  measures,
165
+ cleanCodeMode,
153
166
  });
154
167
 
155
168
  pi.appendEntry(STATE_TYPE, state);
@@ -324,7 +337,7 @@ export default function sonarqube(pi: ExtensionAPI) {
324
337
 
325
338
  const argumentText = match[1] ?? "";
326
339
  const { current: currentToken } = splitSonarArgumentContext(argumentText);
327
- const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues) ?? [];
340
+ const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues, latestState?.cleanCodeMode) ?? [];
328
341
  const currentSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
329
342
  const merged = mergeAutocompleteSuggestions(currentToken, currentSuggestions, extraItems);
330
343
  return merged ?? currentSuggestions;
@@ -352,7 +365,7 @@ export default function sonarqube(pi: ExtensionAPI) {
352
365
  promptSnippet: "Run local SonarQube analysis or inspect issue results",
353
366
  promptGuidelines: [
354
367
  "Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
355
- "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.",
356
369
  ],
357
370
  parameters: SonarToolParams,
358
371
 
@@ -363,7 +376,10 @@ export default function sonarqube(pi: ExtensionAPI) {
363
376
  statuses: params.statuses,
364
377
  types: params.types,
365
378
  rules: params.rules,
379
+ softwareQualities: params.softwareQualities,
380
+ impactSeverities: params.impactSeverities,
366
381
  });
382
+ assertFiltersNotAmbiguous(filters);
367
383
 
368
384
  if (params.action === "analyze") {
369
385
  const state = await analyzeProject(pi, ctx, params.path, filters);
@@ -414,6 +430,8 @@ export default function sonarqube(pi: ExtensionAPI) {
414
430
  statuses: args.statuses,
415
431
  types: args.types,
416
432
  rules: args.rules,
433
+ softwareQualities: args.softwareQualities,
434
+ impactSeverities: args.impactSeverities,
417
435
  });
418
436
  const filterText = filters ? ` ${theme.fg("muted", issueFilterLabel(filters))}` : "";
419
437
  return new Text(
@@ -457,7 +475,7 @@ export default function sonarqube(pi: ExtensionAPI) {
457
475
 
458
476
  pi.registerCommand("sonarqube", {
459
477
  description: "Run a local SonarQube analysis, browse the latest issues, or init project config",
460
- getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues),
478
+ getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues, latestState?.cleanCodeMode),
461
479
  handler: async (args, ctx) => {
462
480
  try {
463
481
  const parsed = parseCommandArgs(args);
@@ -515,17 +533,19 @@ async function commandMetrics(
515
533
  targetInput?: string,
516
534
  ): Promise<void> {
517
535
  const config = await resolveConfig(ctx, targetInput);
518
- const [measures, issueCounts] = await Promise.all([
536
+ const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
537
+ const [measures, issueCounts, qualityCounts] = await Promise.all([
519
538
  fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
520
- fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
539
+ fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
540
+ fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
521
541
  ]);
522
- if (!measures && !issueCounts) {
542
+ if (!measures && !issueCounts && !qualityCounts) {
523
543
  if (ctx.hasUI) {
524
544
  ctx.ui.notify(`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`, "warning");
525
545
  }
526
546
  return;
527
547
  }
528
- const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
548
+ const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
529
549
  if (ctx.hasUI) {
530
550
  ctx.ui.notify(text, "info");
531
551
  }
@@ -552,18 +572,7 @@ async function commandDuplications(
552
572
  throw error;
553
573
  }
554
574
  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
- }
575
+ if (ctx.hasUI) ctx.ui.notify("No duplications found.", "info");
567
576
  return;
568
577
  }
569
578
 
@@ -586,7 +595,7 @@ async function showDuplicationDrillDown(
586
595
  return;
587
596
  }
588
597
  const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
589
- const text = formatDuplicationBlockDetail(file.filePath, groups);
598
+ const text = await buildDuplicationPreview(config.baseDir, file.filePath, groups);
590
599
  if (ctx.mode === "tui") {
591
600
  await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
592
601
  } else if (ctx.hasUI) {
@@ -607,7 +616,7 @@ async function showDuplicationListOrBrowser(
607
616
  if (choice == null) return;
608
617
  const file = files[choice];
609
618
  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));
619
+ await ctx.ui.editor(`Duplications in ${file.filePath}`, await buildDuplicationPreview(config.baseDir, file.filePath, groups));
611
620
  }
612
621
 
613
622
  async function commandIssuesOrOpen(
@@ -693,8 +702,47 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
693
702
  details: { error: `No duplications found for ${config.projectKey}.` },
694
703
  };
695
704
  }
696
- const text = formatDuplicationsList(files);
697
- return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, files } };
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
+ };
698
746
  }
699
747
 
700
748
  async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
@@ -707,18 +755,20 @@ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ conten
707
755
  details: { error: "Project not configured." },
708
756
  };
709
757
  }
710
- const [measures, issueCounts] = await Promise.all([
758
+ const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
759
+ const [measures, issueCounts, qualityCounts] = await Promise.all([
711
760
  fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
712
- fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
761
+ fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
762
+ fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
713
763
  ]);
714
- if (!measures && !issueCounts) {
764
+ if (!measures && !issueCounts && !qualityCounts) {
715
765
  return {
716
766
  content: [{ type: "text", text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` }],
717
767
  details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
718
768
  };
719
769
  }
720
- const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
721
- return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts } };
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 } };
722
772
  }
723
773
 
724
774
  // ── Re-exports for public API ───────────────────────────────────────────────
@@ -730,6 +780,7 @@ export type {
730
780
  SonarProjectConfig,
731
781
  SonarIssueFetchOptions,
732
782
  SonarInitConfig,
783
+ IssueQualityCounts,
733
784
  } from "./types.js";
734
785
 
735
786
  export { projectConfigPath, loadProjectConfig, saveProjectConfig } from "./config.js";
package/src/types.ts CHANGED
@@ -18,6 +18,9 @@ export interface SonarDuplicationMeasures {
18
18
  duplicatedLines: number;
19
19
  duplicatedBlocks: number;
20
20
  duplicatedFiles: number;
21
+ coverage?: number;
22
+ linesToCover?: number;
23
+ uncoveredLines?: number;
21
24
  }
22
25
 
23
26
  export interface IssueSeverityCounts {
@@ -28,6 +31,12 @@ export interface IssueSeverityCounts {
28
31
  info: number;
29
32
  }
30
33
 
34
+ export interface IssueQualityCounts {
35
+ maintainability: number;
36
+ reliability: number;
37
+ security: number;
38
+ }
39
+
31
40
  export interface FileDuplication {
32
41
  filePath: string;
33
42
  fileKey: string;
@@ -58,6 +67,7 @@ export interface SonarAnalysisState {
58
67
  measures?: SonarDuplicationMeasures;
59
68
  totalIssues: number;
60
69
  issues: SonarIssue[];
70
+ cleanCodeMode?: "STANDARD" | "MQR";
61
71
  }
62
72
 
63
73
  export interface SonarProjectConfig {
@@ -73,6 +83,8 @@ export interface SonarIssueFetchOptions {
73
83
  statuses?: string[];
74
84
  types?: string[];
75
85
  rules?: string[];
86
+ softwareQualities?: string[];
87
+ impactSeverities?: string[];
76
88
  }
77
89
 
78
90
  export interface SonarInitConfig {
@@ -110,3 +122,5 @@ export interface InitCommandOptions {
110
122
  export const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"] as const;
111
123
  export const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"] as const;
112
124
  export const SONAR_TYPES = ["BUG", "VULNERABILITY", "CODE_SMELL"] as const;
125
+ export const SONAR_SOFTWARE_QUALITIES = ["MAINTAINABILITY", "RELIABILITY", "SECURITY"] as const;
126
+ 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, FileDuplication } from "./types.js";
6
- import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters } from "./api.js";
5
+ import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication, DuplicationBlockGroup } 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 ───────────────────────────────────────────────────────────
@@ -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
- if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
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
- const subtitle = this.theme.fg(
106
- "dim",
107
- `${this.state.projectKey} • ${this.state.totalIssues} issue(s)${filterSuffix}`,
108
- );
109
- lines.push(
110
- truncateToWidth(title, width),
111
- truncateToWidth(subtitle, width),
112
- "",
113
- );
114
-
115
- const pageSize = Math.min(20, this.state.issues.length);
116
- const halfWindow = Math.floor(pageSize / 2);
117
- const maxStart = Math.max(0, this.state.issues.length - pageSize);
118
- const start = Math.max(0, Math.min(this.selected - halfWindow, maxStart));
119
- const end = Math.min(this.state.issues.length, start + pageSize);
120
- const visibleIssues = this.state.issues.slice(start, end);
121
-
122
- if (visibleIssues.length === 0) {
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;
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
- 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
- }
274
+ this.selected = handleListBrowserInput(data, this.selected, this.files.length, this.done);
221
275
  }
222
276
 
223
277
  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;
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
 
@@ -300,7 +341,8 @@ export async function loadProjectIssuesFromApi(
300
341
  ctx.signal,
301
342
  normalizedFilters,
302
343
  );
303
- return createAnalysisState(config, issues, { filters: normalizedFilters });
344
+ const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
345
+ return createAnalysisState(config, issues, { filters: normalizedFilters, cleanCodeMode });
304
346
  }
305
347
 
306
348
  export async function resolveTargetState(