Skip to content

Commit a41381e

Browse files
authored
Merge pull request #22 from greylag-ci/ux-polish-3
ux: filter findings, non-preview open, quieter copy toasts
2 parents 60590c5 + a02a90f commit a41381e

6 files changed

Lines changed: 260 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ versions follow [SemVer](https://semver.org/).
1515

1616
### Added
1717

18+
- **`Pipeline-Check: Filter Findings` command.** Opens an InputBox;
19+
matches against rule ID, message body, and file path
20+
(case-insensitive substring). Re-invoking the command pre-fills
21+
the current filter so users can edit or clear (empty input
22+
clears). New `$(filter)` button on the Findings view title bar.
23+
The badge updates to reflect the filtered count; the
24+
`lastFindingUris` set still tracks the unfiltered universe so a
25+
publish for a currently-hidden finding still wakes the tree up.
26+
- **`Pipeline-Check: Open Finding` context-menu entry** on Findings
27+
tree leaves. Opens the finding as a **permanent (non-preview)**
28+
tab — useful when triaging multiple findings side-by-side. The
29+
default click-to-reveal still uses preview-style so the common
30+
"click through to scan" flow doesn't create tab clutter.
1831
- **Status bar background colour reflects severity.** A workspace with
1932
any CRITICAL finding tints the bar to `statusBarItem.errorBackground`
2033
(red in the default themes); a workspace with HIGH but no CRITICAL
@@ -41,6 +54,11 @@ versions follow [SemVer](https://semver.org/).
4154

4255
### Changed
4356

57+
- **Quieter clipboard confirmations.** Copy Rule ID and Copy LSP
58+
Install Command now write a 2-second status-bar message instead of
59+
firing a modal information toast. The copy still succeeded
60+
silently 95% of the time anyway; this confirms the action without
61+
stealing focus.
4462
- **"Refresh Findings" now triggers a real scan** instead of just
4563
re-painting the tree from already-published diagnostics. Matches
4664
the user's mental model: clicking a refresh icon should fetch new

package.json

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@
131131
"title": "Open Rule Documentation",
132132
"category": "Pipeline-Check"
133133
},
134+
{
135+
"command": "pipelineCheck.findings.openNonPreview",
136+
"title": "Open Finding",
137+
"category": "Pipeline-Check"
138+
},
139+
{
140+
"command": "pipelineCheck.findings.filter",
141+
"title": "Filter Findings",
142+
"category": "Pipeline-Check",
143+
"icon": "$(filter)"
144+
},
134145
{
135146
"command": "pipelineCheck.goToNextFinding",
136147
"title": "Go to Next Finding",
@@ -162,17 +173,27 @@
162173
"group": "navigation@0"
163174
},
164175
{
165-
"command": "pipelineCheck.findings.changeGrouping",
176+
"command": "pipelineCheck.findings.filter",
166177
"when": "view == pipelineCheck.findings",
167178
"group": "navigation@1"
168179
},
180+
{
181+
"command": "pipelineCheck.findings.changeGrouping",
182+
"when": "view == pipelineCheck.findings",
183+
"group": "navigation@2"
184+
},
169185
{
170186
"command": "pipelineCheck.findings.refresh",
171187
"when": "view == pipelineCheck.findings",
172188
"group": "navigation@9"
173189
}
174190
],
175191
"view/item/context": [
192+
{
193+
"command": "pipelineCheck.findings.openNonPreview",
194+
"when": "view == pipelineCheck.findings && viewItem == pipelineCheck.finding",
195+
"group": "navigation@0"
196+
},
176197
{
177198
"command": "pipelineCheck.findings.openRuleDocs",
178199
"when": "view == pipelineCheck.findings && viewItem == pipelineCheck.finding",
@@ -200,6 +221,10 @@
200221
{
201222
"command": "pipelineCheck.findings.openRuleDocs",
202223
"when": "false"
224+
},
225+
{
226+
"command": "pipelineCheck.findings.openNonPreview",
227+
"when": "false"
203228
}
204229
]
205230
},

src/extension.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ const LANGUAGE_ID = "pipelineCheck";
8585
const LANGUAGE_NAME = "Pipeline-Check";
8686
const OUTPUT_CHANNEL = "Pipeline-Check";
8787

88+
// `setStatusBarMessage` TTL for transient confirmations (clipboard
89+
// writes, etc.). Two seconds is long enough to be readable and short
90+
// enough that a stream of copies doesn't pile up.
91+
const CONFIRM_TTL_MS = 2000;
92+
8893
// Structural shape of a Findings-tree leaf node, used by the
8994
// context-menu commands. The real LeafNode lives in findingsView.ts;
9095
// duplicating just the fields the commands read keeps extension.ts
@@ -93,6 +98,8 @@ type LeafLike = {
9398
readonly finding?: {
9499
readonly ruleId?: string;
95100
readonly docsUrl?: string;
101+
readonly uri?: vscode.Uri;
102+
readonly diagnostic?: { readonly range?: vscode.Range };
96103
};
97104
};
98105

@@ -210,8 +217,9 @@ async function startClient(): Promise<void> {
210217
await vscode.env.clipboard.writeText(
211218
'pip install "pipeline-check[lsp]"',
212219
);
213-
void vscode.window.showInformationMessage(
220+
vscode.window.setStatusBarMessage(
214221
'Copied: pip install "pipeline-check[lsp]"',
222+
CONFIRM_TTL_MS,
215223
);
216224
} else if (choice === "Open server log") {
217225
outputChannel.show();
@@ -318,7 +326,10 @@ export async function activate(
318326
return;
319327
}
320328
await vscode.env.clipboard.writeText(id);
321-
void vscode.window.showInformationMessage(`Copied ${id} to clipboard.`);
329+
// Status-bar message instead of a modal toast — the copy
330+
// succeeded silently 95% of the time anyway; this is a
331+
// ~2-second confirmation that doesn't steal focus.
332+
vscode.window.setStatusBarMessage(`Copied ${id}`, CONFIRM_TTL_MS);
322333
},
323334
),
324335
vscode.commands.registerCommand(
@@ -334,6 +345,45 @@ export async function activate(
334345
await vscode.env.openExternal(vscode.Uri.parse(url));
335346
},
336347
),
348+
// Open a finding without using the editor's preview-tab slot.
349+
// Same target as the default click-to-reveal, but `preview: false`
350+
// pins each opened file as a permanent tab — useful when the user
351+
// is opening several findings side-by-side. Lives only in the
352+
// leaf context menu; the single-click path stays preview-style so
353+
// the common "click through findings to triage" flow doesn't
354+
// create tab clutter.
355+
vscode.commands.registerCommand(
356+
"pipelineCheck.findings.openNonPreview",
357+
async (node: LeafLike | undefined) => {
358+
const uri = node?.finding?.uri;
359+
const range = node?.finding?.diagnostic?.range;
360+
if (!uri) return;
361+
await vscode.commands.executeCommand("vscode.open", uri, {
362+
selection: range,
363+
preserveFocus: false,
364+
preview: false,
365+
});
366+
},
367+
),
368+
// Filter the Findings tree by a substring. Matches against rule
369+
// ID, message body, and fsPath case-insensitively. Re-invoking
370+
// the command pre-fills the current filter so users can edit or
371+
// clear it (empty string clears).
372+
vscode.commands.registerCommand(
373+
"pipelineCheck.findings.filter",
374+
async () => {
375+
const current = findingsProvider.getFilter();
376+
const next = await vscode.window.showInputBox({
377+
title: "Filter Pipeline-Check findings",
378+
prompt:
379+
"Match rule ID, message text, or file path. Empty to clear.",
380+
value: current,
381+
placeHolder: "e.g. GHA-001 or release.yml",
382+
});
383+
if (next === undefined) return; // user cancelled
384+
findingsProvider.setFilter(next);
385+
},
386+
),
337387
// Copy-install-command also lives in the welcome-state and is
338388
// promoted to a top-level command so users can re-find it after
339389
// dismissing the first-run notification.
@@ -343,8 +393,9 @@ export async function activate(
343393
await vscode.env.clipboard.writeText(
344394
'pip install "pipeline-check[lsp]"',
345395
);
346-
void vscode.window.showInformationMessage(
396+
vscode.window.setStatusBarMessage(
347397
'Copied: pip install "pipeline-check[lsp]"',
398+
CONFIRM_TTL_MS,
348399
);
349400
},
350401
),

src/findingsView.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,3 +488,119 @@ describe("FindingsTreeProvider — findings cache invalidation", () => {
488488
expect(roots[0].kind === "group" && roots[0].label).toBe("CRITICAL");
489489
});
490490
});
491+
492+
describe("FindingsTreeProvider — filter", () => {
493+
// The filter narrows the visible tree to findings whose rule ID,
494+
// message, or fsPath contains the filter string (case-insensitive).
495+
// The badge tracks the filtered count; `lastFindingUris` keeps the
496+
// full set so the batch-touches-us check still wakes us up for
497+
// publishes that would currently be filtered out (otherwise a
498+
// CLEAR of a filtered-out URI would never refresh).
499+
500+
function fakeTreeView(): { badge: unknown } & object {
501+
return { badge: undefined };
502+
}
503+
504+
it("defaults to no filter (getFilter returns empty string)", () => {
505+
const p = new FindingsTreeProvider(ctx);
506+
expect(p.getFilter()).toBe("");
507+
});
508+
509+
it("setFilter narrows the tree to matching rule IDs", () => {
510+
setStubDiagnostics([
511+
{ file: "a.yml", rule: "GHA-001", severity: "HIGH" },
512+
{ file: "b.yml", rule: "GHA-015", severity: "HIGH" },
513+
{ file: "c.yml", rule: "GLI-002", severity: "HIGH" },
514+
]);
515+
const p = new FindingsTreeProvider(ctx);
516+
p.setGroupMode("severity");
517+
expect(p.getChildren()[0]).toMatchObject({ kind: "group" });
518+
expect((p.getChildren()[0] as unknown as { children: unknown[] }).children).toHaveLength(
519+
3,
520+
);
521+
522+
p.setFilter("GHA");
523+
const after = p.getChildren()[0] as unknown as { children: unknown[] };
524+
expect(after.children).toHaveLength(2);
525+
});
526+
527+
it("filter is case-insensitive", () => {
528+
setStubDiagnostics([
529+
{ file: "a.yml", rule: "GHA-001", severity: "HIGH" },
530+
]);
531+
const p = new FindingsTreeProvider(ctx);
532+
p.setGroupMode("severity");
533+
p.setFilter("gha");
534+
const roots = p.getChildren();
535+
expect(roots).toHaveLength(1);
536+
});
537+
538+
it("filter matches the message body, not just the rule ID", () => {
539+
setStubDiagnostics([
540+
{ file: "a.yml", rule: "GHA-001", severity: "HIGH" },
541+
{ file: "b.yml", rule: "GHA-002", severity: "HIGH" },
542+
]);
543+
// Both findings have message "GHA-001 title" / "GHA-002 title"
544+
// because setStubDiagnostics builds the message from the rule.
545+
// Filtering on "title" should keep both.
546+
const p = new FindingsTreeProvider(ctx);
547+
p.setGroupMode("severity");
548+
p.setFilter("title");
549+
const roots = p.getChildren();
550+
expect((roots[0] as unknown as { children: unknown[] }).children).toHaveLength(2);
551+
});
552+
553+
it("filter matches the fsPath", () => {
554+
setStubDiagnostics([
555+
{ file: "workflows/ci.yml", rule: "GHA-001", severity: "HIGH" },
556+
{ file: "config/dockerfile", rule: "DOCK-001", severity: "HIGH" },
557+
]);
558+
const p = new FindingsTreeProvider(ctx);
559+
p.setGroupMode("severity");
560+
p.setFilter("workflows");
561+
expect((p.getChildren()[0] as unknown as { children: unknown[] }).children).toHaveLength(
562+
1,
563+
);
564+
});
565+
566+
it("empty filter clears the narrowing", () => {
567+
setStubDiagnostics([
568+
{ file: "a.yml", rule: "GHA-001", severity: "HIGH" },
569+
{ file: "b.yml", rule: "GLI-002", severity: "HIGH" },
570+
]);
571+
const p = new FindingsTreeProvider(ctx);
572+
p.setGroupMode("severity");
573+
p.setFilter("GHA");
574+
expect((p.getChildren()[0] as unknown as { children: unknown[] }).children).toHaveLength(
575+
1,
576+
);
577+
p.setFilter("");
578+
expect((p.getChildren()[0] as unknown as { children: unknown[] }).children).toHaveLength(
579+
2,
580+
);
581+
});
582+
583+
it("setFilter trims whitespace before comparing for change", () => {
584+
setStubDiagnostics([
585+
{ file: "a.yml", rule: "GHA-001", severity: "HIGH" },
586+
]);
587+
const p = new FindingsTreeProvider(ctx);
588+
p.setFilter(" GHA ");
589+
expect(p.getFilter()).toBe("GHA");
590+
});
591+
592+
it("badge reflects the filtered count, not the workspace total", () => {
593+
setStubDiagnostics([
594+
{ file: "a.yml", rule: "GHA-001", severity: "HIGH" },
595+
{ file: "b.yml", rule: "GLI-002", severity: "HIGH" },
596+
{ file: "c.yml", rule: "GHA-003", severity: "HIGH" },
597+
]);
598+
const p = new FindingsTreeProvider(ctx);
599+
const view = fakeTreeView();
600+
p.setTreeView(view as unknown as Parameters<typeof p.setTreeView>[0]);
601+
expect((view.badge as { value: number }).value).toBe(3);
602+
603+
p.setFilter("GHA");
604+
expect((view.badge as { value: number }).value).toBe(2);
605+
});
606+
});

src/findingsView.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ export class FindingsTreeProvider implements vscode.TreeDataProvider<TreeNode> {
117117
// onDidChangeDiagnostics batch with no remaining pipeline-check
118118
// diagnostic, we still need to refresh so the stale leaf vanishes.
119119
private lastFindingUris = new Set<string>();
120+
// Case-insensitive substring; matches against ruleId, message, and
121+
// fsPath. Empty string disables filtering. Stored verbatim (with
122+
// original case) for echo in the InputBox; matched lowercased.
123+
private filter = "";
120124

121125
constructor(context: vscode.ExtensionContext) {
122126
// VS Code does not expose a per-source filter on the diagnostic-
@@ -152,6 +156,25 @@ export class FindingsTreeProvider implements vscode.TreeDataProvider<TreeNode> {
152156
return this.groupMode;
153157
}
154158

159+
getFilter(): string {
160+
return this.filter;
161+
}
162+
163+
setFilter(value: string): void {
164+
const trimmed = value.trim();
165+
if (trimmed === this.filter) return;
166+
this.filter = trimmed;
167+
// Context key lets the manifest's `when` clauses paint a
168+
// "filter active" affordance — e.g. swap the title-bar filter
169+
// icon for a filled variant when the filter has content.
170+
void vscode.commands.executeCommand(
171+
"setContext",
172+
"pipelineCheck.filterActive",
173+
trimmed.length > 0,
174+
);
175+
this.refresh();
176+
}
177+
155178
setGroupMode(mode: GroupMode): void {
156179
if (this.groupMode === mode) {
157180
return;
@@ -177,17 +200,34 @@ export class FindingsTreeProvider implements vscode.TreeDataProvider<TreeNode> {
177200
* findings. Walks the workspace diagnostic store once per refresh
178201
* instead of once per consumer, and rebuilds the "URIs we had
179202
* findings for" set so the next batch-skip check has fresh data.
203+
*
204+
* When a filter string is active, the returned list is restricted
205+
* to findings whose rule ID, message, or fsPath contains the
206+
* filter (case-insensitive). `lastFindingUris` still tracks the
207+
* *full* set so the batch-touches-us check stays correct — a
208+
* publish for a URI whose finding is currently filtered out
209+
* should still wake us up.
180210
*/
181211
private findings(): Finding[] {
182212
if (this.cachedFindings === null) {
183-
this.cachedFindings = collectFindings();
184-
this.lastFindingUris = new Set(
185-
this.cachedFindings.map((f) => f.uri.toString()),
186-
);
213+
const all = collectFindings();
214+
this.lastFindingUris = new Set(all.map((f) => f.uri.toString()));
215+
this.cachedFindings = this.applyFilter(all);
187216
}
188217
return this.cachedFindings;
189218
}
190219

220+
private applyFilter(findings: readonly Finding[]): Finding[] {
221+
if (!this.filter) return [...findings];
222+
const needle = this.filter.toLowerCase();
223+
return findings.filter((f) => {
224+
if (f.ruleId.toLowerCase().includes(needle)) return true;
225+
if (f.diagnostic.message.toLowerCase().includes(needle)) return true;
226+
if (f.uri.fsPath.toLowerCase().includes(needle)) return true;
227+
return false;
228+
});
229+
}
230+
191231
/**
192232
* Returns true if any of the changed URIs in this batch either
193233
* carries a pipeline-check diagnostic right now (publish or update)

src/test/integration/activation.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ suite("Pipeline-Check — activation", () => {
3737
"pipelineCheck.scanWorkspace",
3838
"pipelineCheck.findings.refresh",
3939
"pipelineCheck.findings.changeGrouping",
40+
"pipelineCheck.findings.filter",
4041
"pipelineCheck.findings.copyRuleId",
4142
"pipelineCheck.findings.openRuleDocs",
43+
"pipelineCheck.findings.openNonPreview",
4244
"pipelineCheck.goToNextFinding",
4345
"pipelineCheck.goToPreviousFinding",
4446
];

0 commit comments

Comments
 (0)