@jonathan0823/pi-sonarqube 0.6.0 → 0.8.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 { Input, Key, matchesKey, truncateToWidth, type Focusable } 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 {
@@ -20,7 +26,7 @@ import {
20
26
  normalizeIssueFilters,
21
27
  fetchCleanCodeMode,
22
28
  } from "./api.js";
23
- import { resolveConfig, resolveTarget } from "./config.js";
29
+ import { resolveConfig, resolveTarget, gatherTargets } from "./config.js";
24
30
 
25
31
  // ── Issue preview ───────────────────────────────────────────────────────────
26
32
 
@@ -162,11 +168,7 @@ export function startAnalysisUi(
162
168
  // ── Searchable browser helpers ─────────────────────────────────────────────
163
169
 
164
170
  function normalizeSearchQuery(query: string): string[] {
165
- return query
166
- .trim()
167
- .toLowerCase()
168
- .split(/\s+/)
169
- .filter(Boolean);
171
+ return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
170
172
  }
171
173
 
172
174
  function matchesSearchQuery(haystack: string, query: string): boolean {
@@ -187,7 +189,11 @@ function filterSearchItems<T>(
187
189
 
188
190
  interface SearchableBrowserConfig<T> {
189
191
  title: string;
190
- subtitle: (query: string, totalCount: number, filteredCount: number) => string;
192
+ subtitle: (
193
+ query: string,
194
+ totalCount: number,
195
+ filteredCount: number,
196
+ ) => string;
191
197
  searchHint: string;
192
198
  emptyMessage: string;
193
199
  footer: string;
@@ -295,7 +301,9 @@ export class IssueBrowser extends SearchableListBrowser<SonarIssue> {
295
301
  theme: Theme,
296
302
  done: (result: number | null) => void,
297
303
  ) {
298
- const filterSuffix = state.filters ? ` • ${issueFilterLabel(state.filters)}` : "";
304
+ const filterSuffix = state.filters
305
+ ? ` • ${issueFilterLabel(state.filters)}`
306
+ : "";
299
307
  super(state.issues, theme, done, {
300
308
  title: "SonarQube Issues",
301
309
  subtitle: (_query, totalCount, filteredCount) =>
@@ -317,7 +325,9 @@ export class IssueBrowser extends SearchableListBrowser<SonarIssue> {
317
325
  const marker = isSelected
318
326
  ? theme.fg("accent", ">")
319
327
  : theme.fg("dim", " ");
320
- const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
328
+ const location = issue.line
329
+ ? `${issue.filePath}:${issue.line}`
330
+ : issue.filePath;
321
331
  const rule = issue.ruleName
322
332
  ? `${issue.rule} (${issue.ruleName})`
323
333
  : issue.rule;
@@ -442,7 +452,8 @@ export class DuplicationBrowser extends SearchableListBrowser<FileDuplication> {
442
452
  title: "SonarQube Duplications",
443
453
  subtitle: (_query, totalCount, filteredCount) =>
444
454
  `${totalCount} file(s) with duplicate code${scopeSuffix} • ${filteredCount} match(es)`,
445
- searchHint: "Search duplications by file path, duplicated lines, blocks, or density",
455
+ searchHint:
456
+ "Search duplications by file path, duplicated lines, blocks, or density",
446
457
  emptyMessage: "No matching duplicated files found.",
447
458
  footer: "Up/Down to move, Enter for details, Esc to close",
448
459
  pageSize: 15,
@@ -476,6 +487,98 @@ export async function showDuplicationBrowser(
476
487
  );
477
488
  }
478
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),
527
+ );
528
+ if (choice == null) return null;
529
+ return workspaces[choice].path;
530
+ }
531
+
532
+ // ── Unified target picker (used by index.ts) ────────────────────────────────
533
+
534
+ /**
535
+ * Helpers to make a relative path resolvable by `resolveTarget`.
536
+ * Paths that don't look like paths (no `/`, `.`, or leading `/`) get `./`
537
+ * prefixed so `looksLikePath` can detect them.
538
+ */
539
+ function makeTargetResolvable(path: string): string {
540
+ if (path.startsWith("/") || path.startsWith(".") || path.includes("/"))
541
+ return path;
542
+ return "./" + path;
543
+ }
544
+
545
+ /**
546
+ * Resolve an optional target to a string usable by downstream commands.
547
+ *
548
+ * Returns:
549
+ * - the explicit target string (if given)
550
+ * - `undefined` when no targets exist → caller falls back to cwd
551
+ * - `null` when user cancels the picker → caller stops
552
+ * - the selected alias or path otherwise
553
+ *
554
+ * Headless ambiguity throws an error listing all candidates.
555
+ */
556
+ export async function pickOrResolveTarget(
557
+ ctx: ExtensionContext | ExtensionCommandContext,
558
+ targetInput?: string,
559
+ ): Promise<string | undefined | null> {
560
+ if (targetInput) return targetInput;
561
+
562
+ const { targets } = await gatherTargets(ctx.cwd);
563
+
564
+ if (targets.length === 0) return undefined;
565
+
566
+ if (targets.length === 1) {
567
+ return makeTargetResolvable(targets[0].path);
568
+ }
569
+
570
+ if (ctx.mode !== "tui") {
571
+ const list = targets.map((t) => `"${t.alias}" (${t.path})`).join(", ");
572
+ throw new Error(
573
+ `Multiple SonarQube targets found: ${list}. Pass a target explicitly, e.g. /sonarqube <command> ${targets[0].path}.`,
574
+ );
575
+ }
576
+
577
+ const choice = await showWorkspaceBrowser(ctx, targets);
578
+ if (choice == null) return null;
579
+ return makeTargetResolvable(choice);
580
+ }
581
+
479
582
  // ── Issue browser / preview helpers (used by index.ts) ──────────────────────
480
583
 
481
584
  export async function showIssueBrowser(