@jonathan0823/pi-sonarqube 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- `BLOCKER ${metrics.issueCounts.blocker}`,
249
- `CRITICAL ${metrics.issueCounts.critical}`,
250
- `MAJOR ${metrics.issueCounts.major}`,
251
- `MINOR ${metrics.issueCounts.minor}`,
252
- `INFO ${metrics.issueCounts.info}`,
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 = formatDuplicationBlockDetail(file.filePath, groups);
598
+ const text = await buildDuplicationPreview(config.baseDir, file.filePath, groups);
598
599
  if (ctx.mode === "tui") {
599
600
  await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
600
601
  } else if (ctx.hasUI) {
@@ -615,7 +616,7 @@ async function showDuplicationListOrBrowser(
615
616
  if (choice == null) return;
616
617
  const file = files[choice];
617
618
  const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
618
- await ctx.ui.editor(`Duplications in ${file.filePath}`, formatDuplicationBlockDetail(file.filePath, groups));
619
+ await ctx.ui.editor(`Duplications in ${file.filePath}`, await buildDuplicationPreview(config.baseDir, file.filePath, groups));
619
620
  }
620
621
 
621
622
  async function commandIssuesOrOpen(
@@ -701,8 +702,47 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
701
702
  details: { error: `No duplications found for ${config.projectKey}.` },
702
703
  };
703
704
  }
704
- const text = formatDuplicationsList(files);
705
- 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
+ };
706
746
  }
707
747
 
708
748
  async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
@@ -715,9 +755,10 @@ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ conten
715
755
  details: { error: "Project not configured." },
716
756
  };
717
757
  }
758
+ const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
718
759
  const [measures, issueCounts, qualityCounts] = await Promise.all([
719
760
  fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
720
- fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
761
+ fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
721
762
  fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
722
763
  ]);
723
764
  if (!measures && !issueCounts && !qualityCounts) {
@@ -726,8 +767,8 @@ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ conten
726
767
  details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
727
768
  };
728
769
  }
729
- const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts });
730
- return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts } };
770
+ const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
771
+ return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode } };
731
772
  }
732
773
 
733
774
  // ── Re-exports for public API ───────────────────────────────────────────────
package/src/types.ts CHANGED
@@ -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 {
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
- 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