@jonathan0823/pi-sonarqube 0.5.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/index.ts CHANGED
@@ -116,6 +116,12 @@ const SonarToolParams = Type.Object({
116
116
  Type.String({ description: "Impact severity to fetch (e.g. HIGH)" }),
117
117
  ),
118
118
  ),
119
+ pathScope: Type.Optional(
120
+ Type.String({
121
+ description:
122
+ "Scope issues to a specific file or directory path (e.g. src/api.ts or src/). Use this instead of the `path` parameter for file-level filtering.",
123
+ }),
124
+ ),
119
125
  });
120
126
 
121
127
  // ── State restoration ───────────────────────────────────────────────────────
@@ -150,6 +156,7 @@ async function analyzeProject(
150
156
  filters?: SonarIssueFetchOptions,
151
157
  ): Promise<SonarAnalysisState> {
152
158
  const config = await resolveConfig(ctx, inputPath);
159
+ _cachedConfig = { serverUrl: config.serverUrl, projectKey: config.projectKey, token: config.token };
153
160
  const baseDirStat = await stat(config.baseDir).catch(() => undefined);
154
161
  if (!baseDirStat?.isDirectory()) {
155
162
  throw new Error(`Project directory not found: ${config.baseDir}`);
@@ -183,6 +190,7 @@ async function analyzeProject(
183
190
  config.projectKey,
184
191
  ctx.signal,
185
192
  normalizedFilters,
193
+ config.baseDir,
186
194
  );
187
195
 
188
196
  analysisUi.setPhase("Fetching metrics...");
@@ -385,6 +393,21 @@ function renderIssueListResult(
385
393
 
386
394
  // ── Extension entrypoint ────────────────────────────────────────────────────
387
395
 
396
+ // ponytail: lazy config cache so rule autocomplete can fetch from the server
397
+ let _cachedConfig: { serverUrl: string; projectKey: string; token?: string } | undefined;
398
+
399
+ async function cachedServerConfig(cwd: string): Promise<typeof _cachedConfig> {
400
+ if (!_cachedConfig) {
401
+ try {
402
+ const cfg = await resolveConfig({ cwd });
403
+ _cachedConfig = { serverUrl: cfg.serverUrl, projectKey: cfg.projectKey, token: cfg.token };
404
+ } catch {
405
+ // fall through
406
+ }
407
+ }
408
+ return _cachedConfig;
409
+ }
410
+
388
411
  export default function sonarqube(pi: ExtensionAPI) {
389
412
  const statesByBaseDir = new Map<string, SonarAnalysisState>();
390
413
  let latestState: SonarAnalysisState | undefined;
@@ -418,12 +441,17 @@ export default function sonarqube(pi: ExtensionAPI) {
418
441
  const argumentText = match[1] ?? "";
419
442
  const { current: currentToken } =
420
443
  splitSonarArgumentContext(argumentText);
444
+ const cfg = await cachedServerConfig(ctx.cwd);
421
445
  const extraItems =
422
- sonarArgumentCompletions(
446
+ (await sonarArgumentCompletions(
423
447
  argumentText,
424
448
  latestState?.issues,
425
449
  latestState?.cleanCodeMode,
426
- ) ?? [];
450
+ undefined,
451
+ cfg?.serverUrl,
452
+ cfg?.token,
453
+ cfg?.projectKey,
454
+ )) ?? [];
427
455
  const currentSuggestions = await current.getSuggestions(
428
456
  lines,
429
457
  cursorLine,
@@ -473,6 +501,7 @@ export default function sonarqube(pi: ExtensionAPI) {
473
501
  promptGuidelines: [
474
502
  "Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
475
503
  "Use the optional severity, status, type, rule, quality, and impact severity filters to fetch only the most relevant issues.",
504
+ "To scope issues to a specific file or directory, use the `pathScope` parameter (e.g. pathScope: 'src/api.ts') — do not use the `path` parameter for file filtering.",
476
505
  ],
477
506
  parameters: SonarToolParams,
478
507
 
@@ -485,6 +514,7 @@ export default function sonarqube(pi: ExtensionAPI) {
485
514
  rules: params.rules,
486
515
  softwareQualities: params.softwareQualities,
487
516
  impactSeverities: params.impactSeverities,
517
+ pathScope: params.pathScope,
488
518
  });
489
519
  assertFiltersNotAmbiguous(filters);
490
520
 
@@ -615,12 +645,18 @@ export default function sonarqube(pi: ExtensionAPI) {
615
645
  pi.registerCommand("sonarqube", {
616
646
  description:
617
647
  "Run a local SonarQube analysis, browse the latest issues, or init project config",
618
- getArgumentCompletions: (argumentPrefix) =>
619
- sonarArgumentCompletions(
648
+ getArgumentCompletions: async (argumentPrefix) => {
649
+ const cfg = await cachedServerConfig(process.cwd());
650
+ return sonarArgumentCompletions(
620
651
  argumentPrefix,
621
652
  latestState?.issues,
622
653
  latestState?.cleanCodeMode,
623
- ),
654
+ process.cwd(),
655
+ cfg?.serverUrl,
656
+ cfg?.token,
657
+ cfg?.projectKey,
658
+ );
659
+ },
624
660
  handler: async (args, ctx) => {
625
661
  try {
626
662
  const parsed = parseCommandArgs(args);
@@ -653,6 +689,7 @@ export default function sonarqube(pi: ExtensionAPI) {
653
689
  ctx,
654
690
  parsed.targetInput,
655
691
  parsed.issueIndex,
692
+ parsed.filters,
656
693
  );
657
694
  return;
658
695
  default:
@@ -693,38 +730,52 @@ async function commandAnalyze(
693
730
  }
694
731
  }
695
732
 
696
- async function commandMetrics(
697
- ctx: ExtensionCommandContext,
698
- targetInput?: string,
699
- ): Promise<void> {
700
- const config = await resolveConfig(ctx, targetInput);
733
+ async function loadMetricsData(
734
+ config: SonarProjectConfig,
735
+ signal?: AbortSignal,
736
+ ): Promise<{
737
+ cleanCodeMode: Awaited<ReturnType<typeof fetchCleanCodeMode>>;
738
+ measures: Awaited<ReturnType<typeof fetchDuplicationMeasures>>;
739
+ issueCounts: Awaited<ReturnType<typeof fetchIssueSeverityCounts>>;
740
+ qualityCounts: Awaited<ReturnType<typeof fetchIssueQualityCounts>>;
741
+ } | null> {
701
742
  const cleanCodeMode = await fetchCleanCodeMode(
702
743
  config.serverUrl,
703
744
  config.token,
704
- ctx.signal,
745
+ signal,
705
746
  );
706
747
  const [measures, issueCounts, qualityCounts] = await Promise.all([
707
748
  fetchDuplicationMeasures(
708
749
  config.serverUrl,
709
750
  config.token,
710
751
  config.projectKey,
711
- ctx.signal,
752
+ signal,
712
753
  ),
713
754
  fetchIssueSeverityCounts(
714
755
  config.serverUrl,
715
756
  config.token,
716
757
  config.projectKey,
717
- ctx.signal,
758
+ signal,
718
759
  cleanCodeMode,
719
760
  ),
720
761
  fetchIssueQualityCounts(
721
762
  config.serverUrl,
722
763
  config.token,
723
764
  config.projectKey,
724
- ctx.signal,
765
+ signal,
725
766
  ),
726
767
  ]);
727
- if (!measures && !issueCounts && !qualityCounts) {
768
+ if (!measures && !issueCounts && !qualityCounts) return null;
769
+ return { cleanCodeMode, measures, issueCounts, qualityCounts };
770
+ }
771
+
772
+ async function commandMetrics(
773
+ ctx: ExtensionCommandContext,
774
+ targetInput?: string,
775
+ ): Promise<void> {
776
+ const config = await resolveConfig(ctx, targetInput);
777
+ const metrics = await loadMetricsData(config, ctx.signal);
778
+ if (!metrics) {
728
779
  if (ctx.hasUI) {
729
780
  ctx.ui.notify(
730
781
  `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
@@ -735,10 +786,10 @@ async function commandMetrics(
735
786
  }
736
787
  const text = formatMetricsOutput({
737
788
  projectKey: config.projectKey,
738
- measures,
739
- issueCounts,
740
- issueQualityCounts: qualityCounts,
741
- cleanCodeMode,
789
+ measures: metrics.measures,
790
+ issueCounts: metrics.issueCounts,
791
+ issueQualityCounts: metrics.qualityCounts,
792
+ cleanCodeMode: metrics.cleanCodeMode,
742
793
  });
743
794
  if (ctx.hasUI) {
744
795
  ctx.ui.notify(text, "info");
@@ -750,6 +801,7 @@ async function commandDuplications(
750
801
  ctx: ExtensionCommandContext,
751
802
  targetInput?: string,
752
803
  fileIndex?: number,
804
+ filters?: SonarIssueFetchOptions,
753
805
  ): Promise<void> {
754
806
  const config = await resolveConfig(ctx, targetInput);
755
807
  let files: FileDuplication[];
@@ -759,6 +811,8 @@ async function commandDuplications(
759
811
  config.token,
760
812
  config.projectKey,
761
813
  ctx.signal,
814
+ filters?.pathScope,
815
+ config.baseDir,
762
816
  );
763
817
  } catch (error) {
764
818
  const msg = sonarErrorMessage(error);
@@ -782,7 +836,7 @@ async function commandDuplications(
782
836
  await showDuplicationDrillDown(ctx, config, files, fileIndex);
783
837
  return;
784
838
  }
785
- await showDuplicationListOrBrowser(ctx, config, files);
839
+ await showDuplicationListOrBrowser(ctx, config, files, filters);
786
840
  }
787
841
 
788
842
  async function showDuplicationDrillDown(
@@ -819,12 +873,14 @@ async function showDuplicationListOrBrowser(
819
873
  ctx: ExtensionCommandContext,
820
874
  config: SonarProjectConfig,
821
875
  files: FileDuplication[],
876
+ filters?: SonarIssueFetchOptions,
822
877
  ): Promise<void> {
823
878
  if (ctx.mode !== "tui") {
824
879
  ctx.ui.notify(formatDuplicationsList(files), "info");
825
880
  return;
826
881
  }
827
- const choice = await showDuplicationBrowser(ctx, files);
882
+ const scopeLabel = filters?.pathScope ? issueFilterLabel(filters) : undefined;
883
+ const choice = await showDuplicationBrowser(ctx, files, scopeLabel);
828
884
  if (choice == null) return;
829
885
  const file = files[choice];
830
886
  const groups = await fetchFileDuplicationBlocks(
@@ -1060,33 +1116,8 @@ async function toolMetrics(
1060
1116
  details: { error: "Project not configured." },
1061
1117
  };
1062
1118
  }
1063
- const cleanCodeMode = await fetchCleanCodeMode(
1064
- config.serverUrl,
1065
- config.token,
1066
- ctx.signal,
1067
- );
1068
- const [measures, issueCounts, qualityCounts] = await Promise.all([
1069
- fetchDuplicationMeasures(
1070
- config.serverUrl,
1071
- config.token,
1072
- config.projectKey,
1073
- ctx.signal,
1074
- ),
1075
- fetchIssueSeverityCounts(
1076
- config.serverUrl,
1077
- config.token,
1078
- config.projectKey,
1079
- ctx.signal,
1080
- cleanCodeMode,
1081
- ),
1082
- fetchIssueQualityCounts(
1083
- config.serverUrl,
1084
- config.token,
1085
- config.projectKey,
1086
- ctx.signal,
1087
- ),
1088
- ]);
1089
- if (!measures && !issueCounts && !qualityCounts) {
1119
+ const metrics = await loadMetricsData(config, ctx.signal);
1120
+ if (!metrics) {
1090
1121
  return {
1091
1122
  content: [
1092
1123
  {
@@ -1101,19 +1132,19 @@ async function toolMetrics(
1101
1132
  }
1102
1133
  const text = formatMetricsOutput({
1103
1134
  projectKey: config.projectKey,
1104
- measures,
1105
- issueCounts,
1106
- issueQualityCounts: qualityCounts,
1107
- cleanCodeMode,
1135
+ measures: metrics.measures,
1136
+ issueCounts: metrics.issueCounts,
1137
+ issueQualityCounts: metrics.qualityCounts,
1138
+ cleanCodeMode: metrics.cleanCodeMode,
1108
1139
  });
1109
1140
  return {
1110
1141
  content: [{ type: "text", text }],
1111
1142
  details: {
1112
1143
  projectKey: config.projectKey,
1113
- measures,
1114
- issueCounts,
1115
- issueQualityCounts: qualityCounts,
1116
- cleanCodeMode,
1144
+ measures: metrics.measures,
1145
+ issueCounts: metrics.issueCounts,
1146
+ issueQualityCounts: metrics.qualityCounts,
1147
+ cleanCodeMode: metrics.cleanCodeMode,
1117
1148
  },
1118
1149
  };
1119
1150
  }
package/src/types.ts CHANGED
@@ -86,6 +86,8 @@ export interface SonarIssueFetchOptions {
86
86
  rules?: string[];
87
87
  softwareQualities?: string[];
88
88
  impactSeverities?: string[];
89
+ pathScope?: string;
90
+ componentKeys?: string[];
89
91
  }
90
92
 
91
93
  export interface SonarInitConfig {
package/src/ui.ts CHANGED
@@ -3,7 +3,7 @@ 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 { Input, Key, matchesKey, truncateToWidth, type Focusable } from "@earendil-works/pi-tui";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { resolve } from "node:path";
9
9
  import type {
@@ -159,59 +159,177 @@ export function startAnalysisUi(
159
159
  };
160
160
  }
161
161
 
162
- // ── Issue Browser UI component ──────────────────────────────────────────────
162
+ // ── Searchable browser helpers ─────────────────────────────────────────────
163
163
 
164
- export class IssueBrowser {
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
+ }
178
+
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 {
165
200
  private selected = 0;
201
+ private query = "";
202
+ private filteredItems: T[];
203
+ private _focused = false;
204
+ private readonly searchInput = new Input();
166
205
 
167
206
  constructor(
168
- private readonly state: SonarAnalysisState,
207
+ private readonly items: readonly T[],
169
208
  private readonly theme: Theme,
170
209
  private readonly done: (result: number | null) => void,
171
- ) {}
210
+ private readonly config: SearchableBrowserConfig<T>,
211
+ ) {
212
+ this.filteredItems = [...items];
213
+ }
214
+
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
+ }
172
223
 
173
- invalidate(): void {} // NOSONAR - required by TUI interface
224
+ invalidate(): void {
225
+ this.searchInput.invalidate();
226
+ }
174
227
 
175
228
  handleInput(data: string): void {
176
- this.selected = handleListBrowserInput(
177
- data,
178
- this.selected,
179
- this.state.issues.length,
180
- this.done,
181
- );
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();
182
254
  }
183
255
 
184
256
  render(width: number): string[] {
185
- const filterSuffix = this.state.filters
186
- ? ` • ${issueFilterLabel(this.state.filters)}`
187
- : "";
188
- 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, {
189
300
  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.",
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.",
195
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(" "),
196
316
  renderItem: (issue, issueIndex, isSelected) => {
197
317
  const marker = isSelected
198
- ? this.theme.fg("accent", ">")
199
- : this.theme.fg("dim", " ");
200
- const location = issue.line
201
- ? `${issue.filePath}:${issue.line}`
202
- : issue.filePath;
318
+ ? theme.fg("accent", ">")
319
+ : theme.fg("dim", " ");
320
+ const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
203
321
  const rule = issue.ruleName
204
322
  ? `${issue.rule} (${issue.ruleName})`
205
323
  : issue.rule;
206
- const severity = severityColor(this.theme, issue.severity);
324
+ const severity = severityColor(theme, issue.severity);
207
325
  return [
208
326
  marker,
209
327
  String(issueIndex + 1).padStart(2, " "),
210
328
  ".",
211
329
  severity,
212
- this.theme.fg("accent", location),
213
- this.theme.fg("muted", rule),
214
- this.theme.fg("text", `— ${issue.message}`),
330
+ theme.fg("accent", location),
331
+ theme.fg("muted", rule),
332
+ theme.fg("text", `— ${issue.message}`),
215
333
  ].join(" ");
216
334
  },
217
335
  });
@@ -234,28 +352,6 @@ function severityColor(theme: Theme, severity: string): string {
234
352
  }
235
353
  }
236
354
 
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
355
  function getBrowserWindow(
260
356
  selected: number,
261
357
  total: number,
@@ -334,43 +430,35 @@ function renderListBrowser<T>(
334
430
  return lines;
335
431
  }
336
432
 
337
- // ── Duplication browser (used by index.ts) ─────────────────────────────────
338
-
339
- export class DuplicationBrowser {
340
- private selected = 0;
341
-
433
+ export class DuplicationBrowser extends SearchableListBrowser<FileDuplication> {
342
434
  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, {
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, {
361
442
  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.",
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.",
367
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(" "),
368
456
  renderItem: (file, index, isSelected) => {
369
457
  const marker = isSelected
370
- ? this.theme.fg("accent", ">")
371
- : this.theme.fg("dim", " ");
458
+ ? theme.fg("accent", ">")
459
+ : theme.fg("dim", " ");
372
460
  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)}`;
461
+ return `${marker} ${String(index + 1).padStart(2, " ")}. ${theme.fg("accent", file.filePath)} ${theme.fg("dim", detail)}`;
374
462
  },
375
463
  });
376
464
  }
@@ -379,10 +467,12 @@ export class DuplicationBrowser {
379
467
  export async function showDuplicationBrowser(
380
468
  ctx: ExtensionCommandContext,
381
469
  files: FileDuplication[],
470
+ scopeLabel?: string,
382
471
  ): Promise<number | null> {
383
472
  if (ctx.mode !== "tui" || files.length === 0) return null;
384
473
  return await ctx.ui.custom<number | null>(
385
- (_tui, theme, _kb, done) => new DuplicationBrowser(files, theme, done),
474
+ (_tui, theme, _kb, done) =>
475
+ new DuplicationBrowser(files, theme, done, scopeLabel),
386
476
  );
387
477
  }
388
478
 
@@ -428,6 +518,7 @@ export async function loadProjectIssuesFromApi(
428
518
  config.projectKey,
429
519
  ctx.signal,
430
520
  normalizedFilters,
521
+ config.baseDir,
431
522
  );
432
523
  const cleanCodeMode = await fetchCleanCodeMode(
433
524
  config.serverUrl,