@jonathan0823/pi-sonarqube 0.5.0 → 0.7.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/ui.ts CHANGED
@@ -3,7 +3,13 @@ import type {
3
3
  ExtensionContext,
4
4
  ExtensionCommandContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
6
+ import {
7
+ Input,
8
+ Key,
9
+ matchesKey,
10
+ truncateToWidth,
11
+ type Focusable,
12
+ } from "@earendil-works/pi-tui";
7
13
  import { readFile } from "node:fs/promises";
8
14
  import { resolve } from "node:path";
9
15
  import type {
@@ -159,59 +165,181 @@ export function startAnalysisUi(
159
165
  };
160
166
  }
161
167
 
162
- // ── Issue Browser UI component ──────────────────────────────────────────────
168
+ // ── Searchable browser helpers ─────────────────────────────────────────────
169
+
170
+ function normalizeSearchQuery(query: string): string[] {
171
+ return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
172
+ }
173
+
174
+ function matchesSearchQuery(haystack: string, query: string): boolean {
175
+ const tokens = normalizeSearchQuery(query);
176
+ if (tokens.length === 0) return true;
177
+ const text = haystack.toLowerCase();
178
+ return tokens.every((token) => text.includes(token));
179
+ }
180
+
181
+ function filterSearchItems<T>(
182
+ items: readonly T[],
183
+ query: string,
184
+ searchText: (item: T) => string,
185
+ ): T[] {
186
+ if (!query.trim()) return [...items];
187
+ return items.filter((item) => matchesSearchQuery(searchText(item), query));
188
+ }
163
189
 
164
- export class IssueBrowser {
190
+ interface SearchableBrowserConfig<T> {
191
+ title: string;
192
+ subtitle: (
193
+ query: string,
194
+ totalCount: number,
195
+ filteredCount: number,
196
+ ) => string;
197
+ searchHint: string;
198
+ emptyMessage: string;
199
+ footer: string;
200
+ pageSize: number;
201
+ searchText: (item: T) => string;
202
+ renderItem: (item: T, index: number, isSelected: boolean) => string;
203
+ }
204
+
205
+ class SearchableListBrowser<T> implements Focusable {
165
206
  private selected = 0;
207
+ private query = "";
208
+ private filteredItems: T[];
209
+ private _focused = false;
210
+ private readonly searchInput = new Input();
166
211
 
167
212
  constructor(
168
- private readonly state: SonarAnalysisState,
213
+ private readonly items: readonly T[],
169
214
  private readonly theme: Theme,
170
215
  private readonly done: (result: number | null) => void,
171
- ) {}
216
+ private readonly config: SearchableBrowserConfig<T>,
217
+ ) {
218
+ this.filteredItems = [...items];
219
+ }
172
220
 
173
- invalidate(): void {} // NOSONAR - required by TUI interface
221
+ get focused(): boolean {
222
+ return this._focused;
223
+ }
224
+
225
+ set focused(value: boolean) {
226
+ this._focused = value;
227
+ this.searchInput.focused = value;
228
+ }
229
+
230
+ invalidate(): void {
231
+ this.searchInput.invalidate();
232
+ }
174
233
 
175
234
  handleInput(data: string): void {
176
- this.selected = handleListBrowserInput(
177
- data,
178
- this.selected,
179
- this.state.issues.length,
180
- this.done,
181
- );
235
+ if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
236
+ this.done(null);
237
+ return;
238
+ }
239
+
240
+ if (matchesKey(data, Key.up)) {
241
+ this.selected = Math.max(0, this.selected - 1);
242
+ return;
243
+ }
244
+
245
+ if (matchesKey(data, Key.down)) {
246
+ this.selected = Math.min(
247
+ Math.max(0, this.filteredItems.length - 1),
248
+ this.selected + 1,
249
+ );
250
+ return;
251
+ }
252
+
253
+ if (matchesKey(data, Key.enter)) {
254
+ if (this.filteredItems.length > 0) this.done(this.selected);
255
+ return;
256
+ }
257
+
258
+ this.searchInput.handleInput(data);
259
+ this.syncFilter();
182
260
  }
183
261
 
184
262
  render(width: number): string[] {
185
- const filterSuffix = this.state.filters
186
- ? ` • ${issueFilterLabel(this.state.filters)}`
263
+ const subtitle = this.config.subtitle(
264
+ this.query,
265
+ this.items.length,
266
+ this.filteredItems.length,
267
+ );
268
+ return [
269
+ truncateToWidth(this.theme.fg("dim", this.config.searchHint), width),
270
+ ...this.searchInput.render(width),
271
+ "",
272
+ ...renderListBrowser(this.theme, width, {
273
+ title: this.config.title,
274
+ subtitle,
275
+ items: this.filteredItems,
276
+ selected: this.selected,
277
+ pageSize: this.config.pageSize,
278
+ emptyMessage: this.config.emptyMessage,
279
+ footer: this.config.footer,
280
+ renderItem: this.config.renderItem,
281
+ }),
282
+ ];
283
+ }
284
+
285
+ private syncFilter(): void {
286
+ const nextQuery = this.searchInput.getValue();
287
+ if (nextQuery === this.query) return;
288
+ this.query = nextQuery;
289
+ this.filteredItems = filterSearchItems(
290
+ this.items,
291
+ nextQuery,
292
+ this.config.searchText,
293
+ );
294
+ this.selected = 0;
295
+ }
296
+ }
297
+
298
+ export class IssueBrowser extends SearchableListBrowser<SonarIssue> {
299
+ constructor(
300
+ state: SonarAnalysisState,
301
+ theme: Theme,
302
+ done: (result: number | null) => void,
303
+ ) {
304
+ const filterSuffix = state.filters
305
+ ? ` • ${issueFilterLabel(state.filters)}`
187
306
  : "";
188
- return renderListBrowser(this.theme, width, {
307
+ super(state.issues, theme, done, {
189
308
  title: "SonarQube Issues",
190
- subtitle: `${this.state.projectKey} • ${this.state.totalIssues} issue(s)${filterSuffix}`,
191
- items: this.state.issues,
192
- selected: this.selected,
193
- pageSize: 20,
194
- emptyMessage: "No open issues found.",
309
+ subtitle: (_query, totalCount, filteredCount) =>
310
+ `${state.projectKey} • ${totalCount} issue(s) • ${filteredCount} match(es)${filterSuffix}`,
311
+ searchHint: "Search issues by file, rule, severity, status, or message",
312
+ emptyMessage: "No matching issues found.",
195
313
  footer: "Up/Down to move, Enter to preview, Esc to close",
314
+ pageSize: 20,
315
+ searchText: (issue) =>
316
+ [
317
+ issue.filePath,
318
+ issue.rule,
319
+ issue.ruleName ?? "",
320
+ issue.severity,
321
+ issue.status ?? "",
322
+ issue.message,
323
+ ].join(" "),
196
324
  renderItem: (issue, issueIndex, isSelected) => {
197
325
  const marker = isSelected
198
- ? this.theme.fg("accent", ">")
199
- : this.theme.fg("dim", " ");
326
+ ? theme.fg("accent", ">")
327
+ : theme.fg("dim", " ");
200
328
  const location = issue.line
201
329
  ? `${issue.filePath}:${issue.line}`
202
330
  : issue.filePath;
203
331
  const rule = issue.ruleName
204
332
  ? `${issue.rule} (${issue.ruleName})`
205
333
  : issue.rule;
206
- const severity = severityColor(this.theme, issue.severity);
334
+ const severity = severityColor(theme, issue.severity);
207
335
  return [
208
336
  marker,
209
337
  String(issueIndex + 1).padStart(2, " "),
210
338
  ".",
211
339
  severity,
212
- this.theme.fg("accent", location),
213
- this.theme.fg("muted", rule),
214
- this.theme.fg("text", `— ${issue.message}`),
340
+ theme.fg("accent", location),
341
+ theme.fg("muted", rule),
342
+ theme.fg("text", `— ${issue.message}`),
215
343
  ].join(" ");
216
344
  },
217
345
  });
@@ -234,28 +362,6 @@ function severityColor(theme: Theme, severity: string): string {
234
362
  }
235
363
  }
236
364
 
237
- function handleListBrowserInput(
238
- data: string,
239
- selected: number,
240
- count: number,
241
- done: (result: number | null) => void,
242
- ): number {
243
- if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
244
- done(null);
245
- return selected;
246
- }
247
- if (matchesKey(data, Key.up)) {
248
- return Math.max(0, selected - 1);
249
- }
250
- if (matchesKey(data, Key.down)) {
251
- return Math.min(Math.max(0, count - 1), selected + 1);
252
- }
253
- if (matchesKey(data, Key.enter)) {
254
- done(selected);
255
- }
256
- return selected;
257
- }
258
-
259
365
  function getBrowserWindow(
260
366
  selected: number,
261
367
  total: number,
@@ -334,43 +440,36 @@ function renderListBrowser<T>(
334
440
  return lines;
335
441
  }
336
442
 
337
- // ── Duplication browser (used by index.ts) ─────────────────────────────────
338
-
339
- export class DuplicationBrowser {
340
- private selected = 0;
341
-
443
+ export class DuplicationBrowser extends SearchableListBrowser<FileDuplication> {
342
444
  constructor(
343
- private readonly files: FileDuplication[],
344
- private readonly theme: Theme,
345
- private readonly done: (result: number | null) => void,
346
- ) {}
347
-
348
- invalidate(): void {} // NOSONAR - required by TUI interface
349
-
350
- handleInput(data: string): void {
351
- this.selected = handleListBrowserInput(
352
- data,
353
- this.selected,
354
- this.files.length,
355
- this.done,
356
- );
357
- }
358
-
359
- render(width: number): string[] {
360
- return renderListBrowser(this.theme, width, {
445
+ files: FileDuplication[],
446
+ theme: Theme,
447
+ done: (result: number | null) => void,
448
+ scopeLabel?: string,
449
+ ) {
450
+ const scopeSuffix = scopeLabel ? ` • ${scopeLabel}` : "";
451
+ super(files, theme, done, {
361
452
  title: "SonarQube Duplications",
362
- subtitle: `${this.files.length} file(s) with duplicate code`,
363
- items: this.files,
364
- selected: this.selected,
365
- pageSize: 15,
366
- emptyMessage: "No duplicate code found.",
453
+ subtitle: (_query, totalCount, filteredCount) =>
454
+ `${totalCount} file(s) with duplicate code${scopeSuffix} • ${filteredCount} match(es)`,
455
+ searchHint:
456
+ "Search duplications by file path, duplicated lines, blocks, or density",
457
+ emptyMessage: "No matching duplicated files found.",
367
458
  footer: "Up/Down to move, Enter for details, Esc to close",
459
+ pageSize: 15,
460
+ searchText: (file) =>
461
+ [
462
+ file.filePath,
463
+ `dup%=${file.duplicatedLinesDensity.toFixed(1)}`,
464
+ `blocks=${file.duplicatedBlocks}`,
465
+ `lines=${file.duplicatedLines}`,
466
+ ].join(" "),
368
467
  renderItem: (file, index, isSelected) => {
369
468
  const marker = isSelected
370
- ? this.theme.fg("accent", ">")
371
- : this.theme.fg("dim", " ");
469
+ ? theme.fg("accent", ">")
470
+ : theme.fg("dim", " ");
372
471
  const detail = `dup%=${file.duplicatedLinesDensity.toFixed(1)} blocks=${file.duplicatedBlocks} lines=${file.duplicatedLines}`;
373
- return `${marker} ${String(index + 1).padStart(2, " ")}. ${this.theme.fg("accent", file.filePath)} ${this.theme.fg("dim", detail)}`;
472
+ return `${marker} ${String(index + 1).padStart(2, " ")}. ${theme.fg("accent", file.filePath)} ${theme.fg("dim", detail)}`;
374
473
  },
375
474
  });
376
475
  }
@@ -379,11 +478,55 @@ export class DuplicationBrowser {
379
478
  export async function showDuplicationBrowser(
380
479
  ctx: ExtensionCommandContext,
381
480
  files: FileDuplication[],
481
+ scopeLabel?: string,
382
482
  ): Promise<number | null> {
383
483
  if (ctx.mode !== "tui" || files.length === 0) return null;
384
484
  return await ctx.ui.custom<number | null>(
385
- (_tui, theme, _kb, done) => new DuplicationBrowser(files, theme, done),
485
+ (_tui, theme, _kb, done) =>
486
+ new DuplicationBrowser(files, theme, done, scopeLabel),
487
+ );
488
+ }
489
+
490
+ // ── Workspace selector browser ────────────────────────────────────────────
491
+
492
+ export class WorkspaceBrowser extends SearchableListBrowser<{
493
+ alias: string;
494
+ path: string;
495
+ }> {
496
+ constructor(
497
+ workspaces: { alias: string; path: string }[],
498
+ theme: Theme,
499
+ done: (result: number | null) => void,
500
+ ) {
501
+ super(workspaces, theme, done, {
502
+ title: "SonarQube Workspaces",
503
+ subtitle: (_query, totalCount, filteredCount) =>
504
+ `${totalCount} workspace(s) • ${filteredCount} match(es)`,
505
+ searchHint: "Search workspaces by alias or path",
506
+ emptyMessage: "No matching workspaces found.",
507
+ footer: "Up/Down to move, Enter to select, Esc to cancel",
508
+ pageSize: 15,
509
+ searchText: (ws) => `${ws.alias} ${ws.path}`,
510
+ renderItem: (ws, index, isSelected) => {
511
+ const marker = isSelected
512
+ ? theme.fg("accent", ">")
513
+ : theme.fg("dim", " ");
514
+ return `${marker} ${String(index + 1).padStart(2, " ")}. ${theme.fg("accent", ws.alias.padEnd(20))} ${theme.fg("dim", ws.path)}`;
515
+ },
516
+ });
517
+ }
518
+ }
519
+
520
+ export async function showWorkspaceBrowser(
521
+ ctx: ExtensionContext,
522
+ workspaces: { alias: string; path: string }[],
523
+ ): Promise<string | null> {
524
+ if (ctx.mode !== "tui" || workspaces.length === 0) return null;
525
+ const choice = await ctx.ui.custom<number | null>(
526
+ (_tui, theme, _kb, done) => new WorkspaceBrowser(workspaces, theme, done),
386
527
  );
528
+ if (choice == null) return null;
529
+ return workspaces[choice].alias;
387
530
  }
388
531
 
389
532
  // ── Issue browser / preview helpers (used by index.ts) ──────────────────────
@@ -428,6 +571,7 @@ export async function loadProjectIssuesFromApi(
428
571
  config.projectKey,
429
572
  ctx.signal,
430
573
  normalizedFilters,
574
+ config.baseDir,
431
575
  );
432
576
  const cleanCodeMode = await fetchCleanCodeMode(
433
577
  config.serverUrl,