@jonathan0823/pi-sonarqube 0.4.0 → 0.6.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/src/types.ts CHANGED
@@ -41,6 +41,7 @@ export interface FileDuplication {
41
41
  filePath: string;
42
42
  fileKey: string;
43
43
  duplicatedLines: number;
44
+ duplicatedLinesDensity: number;
44
45
  duplicatedBlocks: number;
45
46
  }
46
47
 
@@ -85,6 +86,8 @@ export interface SonarIssueFetchOptions {
85
86
  rules?: string[];
86
87
  softwareQualities?: string[];
87
88
  impactSeverities?: string[];
89
+ pathScope?: string;
90
+ componentKeys?: string[];
88
91
  }
89
92
 
90
93
  export interface SonarInitConfig {
@@ -119,8 +122,30 @@ export interface InitCommandOptions {
119
122
 
120
123
  // ── Constants shared by adapter and extension ───────────────────────────────
121
124
 
122
- export const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"] as const;
123
- export const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"] as const;
125
+ export const SONAR_SEVERITIES = [
126
+ "BLOCKER",
127
+ "CRITICAL",
128
+ "MAJOR",
129
+ "MINOR",
130
+ "INFO",
131
+ ] as const;
132
+ export const SONAR_STATUSES = [
133
+ "OPEN",
134
+ "CONFIRMED",
135
+ "REOPENED",
136
+ "RESOLVED",
137
+ "CLOSED",
138
+ ] as const;
124
139
  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;
140
+ export const SONAR_SOFTWARE_QUALITIES = [
141
+ "MAINTAINABILITY",
142
+ "RELIABILITY",
143
+ "SECURITY",
144
+ ] as const;
145
+ export const SONAR_IMPACT_SEVERITIES = [
146
+ "BLOCKER",
147
+ "HIGH",
148
+ "MEDIUM",
149
+ "LOW",
150
+ "INFO",
151
+ ] as const;
package/src/ui.ts CHANGED
@@ -1,14 +1,34 @@
1
- import type { Theme, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
1
+ import type {
2
+ Theme,
3
+ ExtensionContext,
4
+ ExtensionCommandContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Input, Key, matchesKey, truncateToWidth, type Focusable } from "@earendil-works/pi-tui";
3
7
  import { readFile } from "node:fs/promises";
4
8
  import { resolve } from "node:path";
5
- import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication, DuplicationBlockGroup } from "./types.js";
6
- import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters, fetchCleanCodeMode } from "./api.js";
9
+ import type {
10
+ SonarIssue,
11
+ SonarAnalysisState,
12
+ SonarIssueFetchOptions,
13
+ FileDuplication,
14
+ DuplicationBlockGroup,
15
+ } from "./types.js";
16
+ import {
17
+ issueFilterLabel,
18
+ fetchIssues,
19
+ createAnalysisState,
20
+ normalizeIssueFilters,
21
+ fetchCleanCodeMode,
22
+ } from "./api.js";
7
23
  import { resolveConfig, resolveTarget } from "./config.js";
8
24
 
9
25
  // ── Issue preview ───────────────────────────────────────────────────────────
10
26
 
11
- export async function buildIssuePreview(baseDir: string, issue: SonarIssue, radius = 3): Promise<string> {
27
+ export async function buildIssuePreview(
28
+ baseDir: string,
29
+ issue: SonarIssue,
30
+ radius = 3,
31
+ ): Promise<string> {
12
32
  const filePath = resolve(baseDir, issue.filePath);
13
33
  const content = await readFile(filePath, "utf8");
14
34
  const lines = content.split(/\r?\n/);
@@ -40,7 +60,11 @@ export async function buildDuplicationPreview(
40
60
  cache.set(absolutePath, content);
41
61
  return content.split(/\r?\n/);
42
62
  };
43
- const renderRange = (lines: string[], from: number, size: number): string[] => {
63
+ const renderRange = (
64
+ lines: string[],
65
+ from: number,
66
+ size: number,
67
+ ): string[] => {
44
68
  const start = Math.max(1, from);
45
69
  const end = Math.min(lines.length, from + size - 1);
46
70
  const width = String(end).length;
@@ -76,7 +100,18 @@ export async function buildDuplicationPreview(
76
100
 
77
101
  // ── Analysis UI ─────────────────────────────────────────────────────────────
78
102
 
79
- const ANALYSIS_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
103
+ const ANALYSIS_SPINNER = [
104
+ "⠋",
105
+ "⠙",
106
+ "⠹",
107
+ "⠸",
108
+ "⠼",
109
+ "⠴",
110
+ "⠦",
111
+ "⠧",
112
+ "⠇",
113
+ "⠏",
114
+ ] as const;
80
115
  const ANALYSIS_WIDGET_KEY = "sonarqube-analysis";
81
116
 
82
117
  export interface AnalysisUiHandle {
@@ -84,16 +119,25 @@ export interface AnalysisUiHandle {
84
119
  stop(): void;
85
120
  }
86
121
 
87
- export function startAnalysisUi(ctx: ExtensionContext, projectKey: string): AnalysisUiHandle {
122
+ export function startAnalysisUi(
123
+ ctx: ExtensionContext,
124
+ projectKey: string,
125
+ ): AnalysisUiHandle {
88
126
  let frameIndex = 0;
89
127
  let phase = "Starting...";
90
128
  const render = () => {
91
129
  const frame = ANALYSIS_SPINNER[frameIndex];
92
- ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, [`${frame} SonarQube ${projectKey}`, phase]);
130
+ ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, [
131
+ `${frame} SonarQube ${projectKey}`,
132
+ phase,
133
+ ]);
93
134
  };
94
135
 
95
136
  ctx.ui.setWorkingMessage(`Analyzing ${projectKey}...`);
96
- ctx.ui.setWorkingIndicator({ frames: ANALYSIS_SPINNER as unknown as string[], intervalMs: 80 });
137
+ ctx.ui.setWorkingIndicator({
138
+ frames: ANALYSIS_SPINNER as unknown as string[],
139
+ intervalMs: 80,
140
+ });
97
141
  render();
98
142
 
99
143
  const timer = setInterval(() => {
@@ -115,46 +159,177 @@ export function startAnalysisUi(ctx: ExtensionContext, projectKey: string): Anal
115
159
  };
116
160
  }
117
161
 
118
- // ── Issue Browser UI component ──────────────────────────────────────────────
162
+ // ── Searchable browser helpers ─────────────────────────────────────────────
163
+
164
+ function normalizeSearchQuery(query: string): string[] {
165
+ return query
166
+ .trim()
167
+ .toLowerCase()
168
+ .split(/\s+/)
169
+ .filter(Boolean);
170
+ }
171
+
172
+ function matchesSearchQuery(haystack: string, query: string): boolean {
173
+ const tokens = normalizeSearchQuery(query);
174
+ if (tokens.length === 0) return true;
175
+ const text = haystack.toLowerCase();
176
+ return tokens.every((token) => text.includes(token));
177
+ }
119
178
 
120
- export class IssueBrowser {
179
+ function filterSearchItems<T>(
180
+ items: readonly T[],
181
+ query: string,
182
+ searchText: (item: T) => string,
183
+ ): T[] {
184
+ if (!query.trim()) return [...items];
185
+ return items.filter((item) => matchesSearchQuery(searchText(item), query));
186
+ }
187
+
188
+ interface SearchableBrowserConfig<T> {
189
+ title: string;
190
+ subtitle: (query: string, totalCount: number, filteredCount: number) => string;
191
+ searchHint: string;
192
+ emptyMessage: string;
193
+ footer: string;
194
+ pageSize: number;
195
+ searchText: (item: T) => string;
196
+ renderItem: (item: T, index: number, isSelected: boolean) => string;
197
+ }
198
+
199
+ class SearchableListBrowser<T> implements Focusable {
121
200
  private selected = 0;
201
+ private query = "";
202
+ private filteredItems: T[];
203
+ private _focused = false;
204
+ private readonly searchInput = new Input();
122
205
 
123
206
  constructor(
124
- private readonly state: SonarAnalysisState,
207
+ private readonly items: readonly T[],
125
208
  private readonly theme: Theme,
126
209
  private readonly done: (result: number | null) => void,
127
- ) {}
210
+ private readonly config: SearchableBrowserConfig<T>,
211
+ ) {
212
+ this.filteredItems = [...items];
213
+ }
128
214
 
129
- invalidate(): void {} // NOSONAR - required by TUI interface
215
+ get focused(): boolean {
216
+ return this._focused;
217
+ }
218
+
219
+ set focused(value: boolean) {
220
+ this._focused = value;
221
+ this.searchInput.focused = value;
222
+ }
223
+
224
+ invalidate(): void {
225
+ this.searchInput.invalidate();
226
+ }
130
227
 
131
228
  handleInput(data: string): void {
132
- this.selected = handleListBrowserInput(data, this.selected, this.state.issues.length, this.done);
229
+ if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
230
+ this.done(null);
231
+ return;
232
+ }
233
+
234
+ if (matchesKey(data, Key.up)) {
235
+ this.selected = Math.max(0, this.selected - 1);
236
+ return;
237
+ }
238
+
239
+ if (matchesKey(data, Key.down)) {
240
+ this.selected = Math.min(
241
+ Math.max(0, this.filteredItems.length - 1),
242
+ this.selected + 1,
243
+ );
244
+ return;
245
+ }
246
+
247
+ if (matchesKey(data, Key.enter)) {
248
+ if (this.filteredItems.length > 0) this.done(this.selected);
249
+ return;
250
+ }
251
+
252
+ this.searchInput.handleInput(data);
253
+ this.syncFilter();
133
254
  }
134
255
 
135
256
  render(width: number): string[] {
136
- const filterSuffix = this.state.filters ? ` • ${issueFilterLabel(this.state.filters)}` : "";
137
- return renderListBrowser(this.theme, width, {
257
+ const subtitle = this.config.subtitle(
258
+ this.query,
259
+ this.items.length,
260
+ this.filteredItems.length,
261
+ );
262
+ return [
263
+ truncateToWidth(this.theme.fg("dim", this.config.searchHint), width),
264
+ ...this.searchInput.render(width),
265
+ "",
266
+ ...renderListBrowser(this.theme, width, {
267
+ title: this.config.title,
268
+ subtitle,
269
+ items: this.filteredItems,
270
+ selected: this.selected,
271
+ pageSize: this.config.pageSize,
272
+ emptyMessage: this.config.emptyMessage,
273
+ footer: this.config.footer,
274
+ renderItem: this.config.renderItem,
275
+ }),
276
+ ];
277
+ }
278
+
279
+ private syncFilter(): void {
280
+ const nextQuery = this.searchInput.getValue();
281
+ if (nextQuery === this.query) return;
282
+ this.query = nextQuery;
283
+ this.filteredItems = filterSearchItems(
284
+ this.items,
285
+ nextQuery,
286
+ this.config.searchText,
287
+ );
288
+ this.selected = 0;
289
+ }
290
+ }
291
+
292
+ export class IssueBrowser extends SearchableListBrowser<SonarIssue> {
293
+ constructor(
294
+ state: SonarAnalysisState,
295
+ theme: Theme,
296
+ done: (result: number | null) => void,
297
+ ) {
298
+ const filterSuffix = state.filters ? ` • ${issueFilterLabel(state.filters)}` : "";
299
+ super(state.issues, theme, done, {
138
300
  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.",
301
+ subtitle: (_query, totalCount, filteredCount) =>
302
+ `${state.projectKey} • ${totalCount} issue(s) • ${filteredCount} match(es)${filterSuffix}`,
303
+ searchHint: "Search issues by file, rule, severity, status, or message",
304
+ emptyMessage: "No matching issues found.",
144
305
  footer: "Up/Down to move, Enter to preview, Esc to close",
306
+ pageSize: 20,
307
+ searchText: (issue) =>
308
+ [
309
+ issue.filePath,
310
+ issue.rule,
311
+ issue.ruleName ?? "",
312
+ issue.severity,
313
+ issue.status ?? "",
314
+ issue.message,
315
+ ].join(" "),
145
316
  renderItem: (issue, issueIndex, isSelected) => {
146
- const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
317
+ const marker = isSelected
318
+ ? theme.fg("accent", ">")
319
+ : theme.fg("dim", " ");
147
320
  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);
321
+ const rule = issue.ruleName
322
+ ? `${issue.rule} (${issue.ruleName})`
323
+ : issue.rule;
324
+ const severity = severityColor(theme, issue.severity);
150
325
  return [
151
326
  marker,
152
327
  String(issueIndex + 1).padStart(2, " "),
153
328
  ".",
154
329
  severity,
155
- this.theme.fg("accent", location),
156
- this.theme.fg("muted", rule),
157
- this.theme.fg("text", `— ${issue.message}`),
330
+ theme.fg("accent", location),
331
+ theme.fg("muted", rule),
332
+ theme.fg("text", `— ${issue.message}`),
158
333
  ].join(" ");
159
334
  },
160
335
  });
@@ -177,29 +352,11 @@ function severityColor(theme: Theme, severity: string): string {
177
352
  }
178
353
  }
179
354
 
180
- function handleListBrowserInput(
181
- data: string,
355
+ function getBrowserWindow(
182
356
  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 } {
357
+ total: number,
358
+ pageSize: number,
359
+ ): { start: number; end: number } {
203
360
  const halfWindow = Math.floor(pageSize / 2);
204
361
  const maxStart = Math.max(0, total - pageSize);
205
362
  const start = Math.max(0, Math.min(selected - halfWindow, maxStart));
@@ -230,7 +387,11 @@ function renderListBrowser<T>(
230
387
  ];
231
388
 
232
389
  const pageSize = Math.min(config.pageSize, config.items.length);
233
- const { start, end } = getBrowserWindow(config.selected, config.items.length, pageSize);
390
+ const { start, end } = getBrowserWindow(
391
+ config.selected,
392
+ config.items.length,
393
+ pageSize,
394
+ );
234
395
  const visible = config.items.slice(start, end);
235
396
 
236
397
  if (visible.length === 0) {
@@ -243,50 +404,61 @@ function renderListBrowser<T>(
243
404
  }
244
405
 
245
406
  if (start > 0) {
246
- lines.push(truncateToWidth(theme.fg("dim", `... ${start} more above`), width));
407
+ lines.push(
408
+ truncateToWidth(theme.fg("dim", `... ${start} more above`), width),
409
+ );
247
410
  }
248
411
  for (const [offset, item] of visible.entries()) {
249
412
  const index = start + offset;
250
- lines.push(truncateToWidth(config.renderItem(item, index, index === config.selected), width));
413
+ lines.push(
414
+ truncateToWidth(
415
+ config.renderItem(item, index, index === config.selected),
416
+ width,
417
+ ),
418
+ );
251
419
  }
252
420
  if (end < config.items.length) {
253
- lines.push(truncateToWidth(theme.fg("dim", `... ${config.items.length - end} more below`), width));
421
+ lines.push(
422
+ truncateToWidth(
423
+ theme.fg("dim", `... ${config.items.length - end} more below`),
424
+ width,
425
+ ),
426
+ );
254
427
  }
255
428
 
256
429
  lines.push("", truncateToWidth(theme.fg("dim", config.footer), width));
257
430
  return lines;
258
431
  }
259
432
 
260
- // ── Duplication browser (used by index.ts) ─────────────────────────────────
261
-
262
- export class DuplicationBrowser {
263
- private selected = 0;
264
-
433
+ export class DuplicationBrowser extends SearchableListBrowser<FileDuplication> {
265
434
  constructor(
266
- private readonly files: FileDuplication[],
267
- private readonly theme: Theme,
268
- private readonly done: (result: number | null) => void,
269
- ) {}
270
-
271
- invalidate(): void {} // NOSONAR - required by TUI interface
272
-
273
- handleInput(data: string): void {
274
- this.selected = handleListBrowserInput(data, this.selected, this.files.length, this.done);
275
- }
276
-
277
- render(width: number): string[] {
278
- return renderListBrowser(this.theme, width, {
435
+ files: FileDuplication[],
436
+ theme: Theme,
437
+ done: (result: number | null) => void,
438
+ scopeLabel?: string,
439
+ ) {
440
+ const scopeSuffix = scopeLabel ? ` • ${scopeLabel}` : "";
441
+ super(files, theme, done, {
279
442
  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.",
443
+ subtitle: (_query, totalCount, filteredCount) =>
444
+ `${totalCount} file(s) with duplicate code${scopeSuffix} • ${filteredCount} match(es)`,
445
+ searchHint: "Search duplications by file path, duplicated lines, blocks, or density",
446
+ emptyMessage: "No matching duplicated files found.",
285
447
  footer: "Up/Down to move, Enter for details, Esc to close",
448
+ pageSize: 15,
449
+ searchText: (file) =>
450
+ [
451
+ file.filePath,
452
+ `dup%=${file.duplicatedLinesDensity.toFixed(1)}`,
453
+ `blocks=${file.duplicatedBlocks}`,
454
+ `lines=${file.duplicatedLines}`,
455
+ ].join(" "),
286
456
  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)}`;
457
+ const marker = isSelected
458
+ ? theme.fg("accent", ">")
459
+ : theme.fg("dim", " ");
460
+ const detail = `dup%=${file.duplicatedLinesDensity.toFixed(1)} blocks=${file.duplicatedBlocks} lines=${file.duplicatedLines}`;
461
+ return `${marker} ${String(index + 1).padStart(2, " ")}. ${theme.fg("accent", file.filePath)} ${theme.fg("dim", detail)}`;
290
462
  },
291
463
  });
292
464
  }
@@ -295,9 +467,13 @@ export class DuplicationBrowser {
295
467
  export async function showDuplicationBrowser(
296
468
  ctx: ExtensionCommandContext,
297
469
  files: FileDuplication[],
470
+ scopeLabel?: string,
298
471
  ): Promise<number | null> {
299
472
  if (ctx.mode !== "tui" || files.length === 0) return null;
300
- return await ctx.ui.custom<number | null>((_tui, theme, _kb, done) => new DuplicationBrowser(files, theme, done));
473
+ return await ctx.ui.custom<number | null>(
474
+ (_tui, theme, _kb, done) =>
475
+ new DuplicationBrowser(files, theme, done, scopeLabel),
476
+ );
301
477
  }
302
478
 
303
479
  // ── Issue browser / preview helpers (used by index.ts) ──────────────────────
@@ -308,7 +484,9 @@ export async function showIssueBrowser(
308
484
  ): Promise<number | null> {
309
485
  if (ctx.mode !== "tui") return null;
310
486
  if (state.issues.length === 0) return null;
311
- return await ctx.ui.custom<number | null>((_tui, theme, _kb, done) => new IssueBrowser(state, theme, done));
487
+ return await ctx.ui.custom<number | null>(
488
+ (_tui, theme, _kb, done) => new IssueBrowser(state, theme, done),
489
+ );
312
490
  }
313
491
 
314
492
  export async function openIssuePreview(
@@ -340,9 +518,17 @@ export async function loadProjectIssuesFromApi(
340
518
  config.projectKey,
341
519
  ctx.signal,
342
520
  normalizedFilters,
521
+ config.baseDir,
522
+ );
523
+ const cleanCodeMode = await fetchCleanCodeMode(
524
+ config.serverUrl,
525
+ config.token,
526
+ ctx.signal,
343
527
  );
344
- const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
345
- return createAnalysisState(config, issues, { filters: normalizedFilters, cleanCodeMode });
528
+ return createAnalysisState(config, issues, {
529
+ filters: normalizedFilters,
530
+ cleanCodeMode,
531
+ });
346
532
  }
347
533
 
348
534
  export async function resolveTargetState(