From ff6f56e6db8ab30407516d212d1dbfa97d0ad919 Mon Sep 17 00:00:00 2001 From: Asger F Date: Wed, 8 Apr 2026 17:36:26 +0200 Subject: [PATCH 01/14] Add change note --- extensions/ql-vscode/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/ql-vscode/CHANGELOG.md b/extensions/ql-vscode/CHANGELOG.md index ee40e8cf406..8801a7e2cf6 100644 --- a/extensions/ql-vscode/CHANGELOG.md +++ b/extensions/ql-vscode/CHANGELOG.md @@ -3,6 +3,7 @@ ## [UNRELEASED] - Remove support for CodeQL CLI versions older than 2.22.4. [#4344](https://github.com/github/vscode-codeql/pull/4344) +- Added support for selection-based result filtering via a checkbox in the result viewer. When enabled, only results from the currently-viewed file are shown. Additionally, if the editor selection is non-empty, only results within the selection range are shown. [#4362](https://github.com/github/vscode-codeql/pull/4362) ## 1.17.7 - 5 December 2025 From efc260663c7adcfd57dc81e232b9a54000085c4f Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 09:14:49 +0200 Subject: [PATCH 02/14] Move problems-view selected state into ResultsApp --- .../src/view/results/ResultTables.tsx | 52 +++++-------------- .../ql-vscode/src/view/results/ResultsApp.tsx | 6 ++- 2 files changed, 18 insertions(+), 40 deletions(-) diff --git a/extensions/ql-vscode/src/view/results/ResultTables.tsx b/extensions/ql-vscode/src/view/results/ResultTables.tsx index 3afcb170314..ec1451acf8b 100644 --- a/extensions/ql-vscode/src/view/results/ResultTables.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTables.tsx @@ -7,7 +7,6 @@ import type { InterpretedResultsSortState, ResultSet, ParsedResultSets, - IntoResultsViewMsg, UserSettings, } from "../../common/interface-types"; import { @@ -43,6 +42,8 @@ interface ResultTablesProps { isLoadingNewResults: boolean; queryName: string; queryPath: string; + problemsViewSelected: boolean; + onProblemsViewSelectedChange: (selected: boolean) => void; } const UPDATING_RESULTS_TEXT_CLASS_NAME = @@ -101,48 +102,14 @@ export function ResultTables(props: ResultTablesProps) { origResultsPaths, isLoadingNewResults, sortStates, + problemsViewSelected, + onProblemsViewSelectedChange, } = props; const [selectedTable, setSelectedTable] = useState( parsedResultSets.selectedTable || getDefaultResultSet(getResultSets(rawResultSets, interpretation)), ); - const [problemsViewSelected, setProblemsViewSelected] = useState(false); - - const handleMessage = useCallback((msg: IntoResultsViewMsg): void => { - switch (msg.t) { - case "untoggleShowProblems": - setProblemsViewSelected(false); - break; - - default: - // noop - } - }, []); - - const vscodeMessageHandler = useCallback( - (evt: MessageEvent): void => { - // sanitize origin - const origin = evt.origin.replace(/\n|\r/g, ""); - if (evt.origin === window.origin) { - handleMessage(evt.data as IntoResultsViewMsg); - } else { - console.error(`Invalid event origin ${origin}`); - } - }, - [handleMessage], - ); - - // TODO: Duplicated from ResultsApp.tsx consider a way to - // avoid this duplication - useEffect(() => { - window.addEventListener("message", vscodeMessageHandler); - - return () => { - window.removeEventListener("message", vscodeMessageHandler); - }; - }, [vscodeMessageHandler]); - useEffect(() => { const resultSetExists = parsedResultSets.resultSetNames.some((v) => selectedTable === v) || @@ -178,7 +145,7 @@ export function ResultTables(props: ResultTablesProps) { // no change return; } - setProblemsViewSelected(e.target.checked); + onProblemsViewSelectedChange(e.target.checked); if (e.target.checked) { sendTelemetry("local-results-show-results-in-problems-view"); } @@ -192,7 +159,14 @@ export function ResultTables(props: ResultTablesProps) { }); } }, - [database, metadata, origResultsPaths, problemsViewSelected, resultsPath], + [ + database, + metadata, + onProblemsViewSelectedChange, + origResultsPaths, + problemsViewSelected, + resultsPath, + ], ); const offset = parsedResultSets.pageNumber * parsedResultSets.pageSize; diff --git a/extensions/ql-vscode/src/view/results/ResultsApp.tsx b/extensions/ql-vscode/src/view/results/ResultsApp.tsx index 014080a9bdd..c467e10b2a7 100644 --- a/extensions/ql-vscode/src/view/results/ResultsApp.tsx +++ b/extensions/ql-vscode/src/view/results/ResultsApp.tsx @@ -84,6 +84,8 @@ export function ResultsApp() { DEFAULT_USER_SETTINGS, ); + const [problemsViewSelected, setProblemsViewSelected] = useState(false); + const updateStateWithNewResultsInfo = useCallback( (resultsInfo: ResultsInfo): void => { let results: Results | null = null; @@ -180,7 +182,7 @@ export function ResultsApp() { break; case "untoggleShowProblems": - // noop + setProblemsViewSelected(false); break; default: @@ -230,6 +232,8 @@ export function ResultsApp() { } queryName={displayedResults.resultsInfo.queryName} queryPath={displayedResults.resultsInfo.queryPath} + problemsViewSelected={problemsViewSelected} + onProblemsViewSelectedChange={setProblemsViewSelected} /> ); } else { From 3a1349e2f9583d720a9581cd062c6e7d35f5cbbf Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 09:22:15 +0200 Subject: [PATCH 03/14] Move selectedTable state up into ResultsApp --- .../src/view/results/ResultTables.tsx | 36 +++------------- .../ql-vscode/src/view/results/ResultsApp.tsx | 43 ++++++++++++++++++- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/extensions/ql-vscode/src/view/results/ResultTables.tsx b/extensions/ql-vscode/src/view/results/ResultTables.tsx index ec1451acf8b..f8057604ef1 100644 --- a/extensions/ql-vscode/src/view/results/ResultTables.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTables.tsx @@ -13,7 +13,6 @@ import { ALERTS_TABLE_NAME, GRAPH_TABLE_NAME, SELECT_TABLE_NAME, - getDefaultResultSetName, } from "../../common/interface-types"; import { tableHeaderClassName } from "./result-table-utils"; import { vscode } from "../vscode-api"; @@ -42,6 +41,8 @@ interface ResultTablesProps { isLoadingNewResults: boolean; queryName: string; queryPath: string; + selectedTable: string; + onSelectedTableChange: (tableName: string) => void; problemsViewSelected: boolean; onProblemsViewSelectedChange: (selected: boolean) => void; } @@ -102,30 +103,12 @@ export function ResultTables(props: ResultTablesProps) { origResultsPaths, isLoadingNewResults, sortStates, + selectedTable, + onSelectedTableChange, problemsViewSelected, onProblemsViewSelectedChange, } = props; - const [selectedTable, setSelectedTable] = useState( - parsedResultSets.selectedTable || - getDefaultResultSet(getResultSets(rawResultSets, interpretation)), - ); - useEffect(() => { - const resultSetExists = - parsedResultSets.resultSetNames.some((v) => selectedTable === v) || - getResultSets(rawResultSets, interpretation).some( - (v) => selectedTable === getResultSetName(v), - ); - - // If the selected result set does not exist, select the default result set. - if (!resultSetExists) { - setSelectedTable( - parsedResultSets.selectedTable || - getDefaultResultSet(getResultSets(rawResultSets, interpretation)), - ); - } - }, [parsedResultSets, interpretation, rawResultSets, selectedTable]); - const onTableSelectionChange = useCallback( (event: React.ChangeEvent): void => { const selectedTable = event.target.value; @@ -134,9 +117,10 @@ export function ResultTables(props: ResultTablesProps) { pageNumber: 0, selectedTable, }); + onSelectedTableChange(selectedTable); sendTelemetry("local-results-table-selection"); }, - [], + [onSelectedTableChange], ); const handleCheckboxChanged = useCallback( @@ -227,7 +211,7 @@ export function ResultTables(props: ResultTablesProps) { sortState={sortStates.get(resultSetName)} nonemptyRawResults={nonemptyRawResults} showRawResults={() => { - setSelectedTable(SELECT_TABLE_NAME); + onSelectedTableChange(SELECT_TABLE_NAME); sendTelemetry("local-results-show-raw-results"); }} offset={offset} @@ -237,12 +221,6 @@ export function ResultTables(props: ResultTablesProps) { ); } -function getDefaultResultSet(resultSets: readonly ResultSet[]): string { - return getDefaultResultSetName( - resultSets.map((resultSet) => getResultSetName(resultSet)), - ); -} - function getResultSetName(resultSet: ResultSet): string { switch (resultSet.t) { case "RawResultSet": diff --git a/extensions/ql-vscode/src/view/results/ResultsApp.tsx b/extensions/ql-vscode/src/view/results/ResultsApp.tsx index c467e10b2a7..2126008ff4a 100644 --- a/extensions/ql-vscode/src/view/results/ResultsApp.tsx +++ b/extensions/ql-vscode/src/view/results/ResultsApp.tsx @@ -15,6 +15,7 @@ import { ALERTS_TABLE_NAME, DEFAULT_USER_SETTINGS, GRAPH_TABLE_NAME, + getDefaultResultSetName, } from "../../common/interface-types"; import { useMessageFromExtension } from "../common/useMessageFromExtension"; import { ResultTables } from "./ResultTables"; @@ -64,6 +65,7 @@ interface ResultsViewState { displayedResults: ResultsState; nextResultsInfo: ResultsInfo | null; isExpectingResultsUpdate: boolean; + selectedTable: string | undefined; } /** @@ -78,6 +80,7 @@ export function ResultsApp() { }, nextResultsInfo: null, isExpectingResultsUpdate: true, + selectedTable: undefined, }); const [userSettings, setUserSettings] = useState( @@ -86,6 +89,39 @@ export function ResultsApp() { const [problemsViewSelected, setProblemsViewSelected] = useState(false); + const onSelectedTableChange = useCallback((tableName: string) => { + setState((prev) => { + if (tableName === prev.selectedTable) return prev; + return { + ...prev, + selectedTable: tableName, + }; + }); + }, []); + + // Ensure selectedTable is valid for the current result sets. + // This runs in ResultsApp (not ResultTables) so it survives remounts. + const displayedResultsInfo = state.displayedResults.resultsInfo; + useEffect(() => { + if (!displayedResultsInfo) return; + const { parsedResultSets, interpretation } = displayedResultsInfo; + const allNames = interpretation + ? parsedResultSets.resultSetNames.concat([ + interpretation.data.t === "GraphInterpretationData" + ? GRAPH_TABLE_NAME + : ALERTS_TABLE_NAME, + ]) + : parsedResultSets.resultSetNames; + if ( + state.selectedTable === undefined || + !allNames.includes(state.selectedTable) + ) { + const tableName = + parsedResultSets.selectedTable ?? getDefaultResultSetName(allNames); + onSelectedTableChange(tableName); + } + }, [displayedResultsInfo, state.selectedTable, onSelectedTableChange]); + const updateStateWithNewResultsInfo = useCallback( (resultsInfo: ResultsInfo): void => { let results: Results | null = null; @@ -103,7 +139,8 @@ export function ResultsApp() { statusText = `Error loading results: ${errorMessage}`; } - setState({ + setState((prev) => ({ + ...prev, displayedResults: { resultsInfo, results, @@ -111,7 +148,7 @@ export function ResultsApp() { }, nextResultsInfo: null, isExpectingResultsUpdate: false, - }); + })); }, [], ); @@ -232,6 +269,8 @@ export function ResultsApp() { } queryName={displayedResults.resultsInfo.queryName} queryPath={displayedResults.resultsInfo.queryPath} + selectedTable={state.selectedTable ?? ""} + onSelectedTableChange={onSelectedTableChange} problemsViewSelected={problemsViewSelected} onProblemsViewSelectedChange={setProblemsViewSelected} /> From 690f63ae972e32dcf6e3a9a1ec2a5ba250b6d55d Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 09:29:03 +0200 Subject: [PATCH 04/14] Add a checkbox for enabling selection filtering For now the checkbox doesn't do anything, we just wire up the state --- .../src/view/results/ResultTables.tsx | 14 +++++++++ .../ql-vscode/src/view/results/ResultsApp.tsx | 6 ++++ .../view/results/SelectionFilterCheckbox.tsx | 31 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 extensions/ql-vscode/src/view/results/SelectionFilterCheckbox.tsx diff --git a/extensions/ql-vscode/src/view/results/ResultTables.tsx b/extensions/ql-vscode/src/view/results/ResultTables.tsx index f8057604ef1..8a12fe92ed2 100644 --- a/extensions/ql-vscode/src/view/results/ResultTables.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTables.tsx @@ -22,6 +22,7 @@ import { ResultTablesHeader } from "./ResultTablesHeader"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ResultCount } from "./ResultCount"; import { ProblemsViewCheckbox } from "./ProblemsViewCheckbox"; +import { SelectionFilterCheckbox } from "./SelectionFilterCheckbox"; import { assertNever } from "../../common/helpers-pure"; /** @@ -43,6 +44,8 @@ interface ResultTablesProps { queryPath: string; selectedTable: string; onSelectedTableChange: (tableName: string) => void; + selectionFilterEnabled: boolean; + onSelectionFilterEnabledChange: (value: boolean) => void; problemsViewSelected: boolean; onProblemsViewSelectedChange: (selected: boolean) => void; } @@ -105,6 +108,8 @@ export function ResultTables(props: ResultTablesProps) { sortStates, selectedTable, onSelectedTableChange, + selectionFilterEnabled, + onSelectionFilterEnabledChange, problemsViewSelected, onProblemsViewSelectedChange, } = props; @@ -185,6 +190,15 @@ export function ResultTables(props: ResultTablesProps) {
+
+ onSelectionFilterEnabledChange(e.target.checked)} + /> +
+ +
+
+ ); +} From 60598c65c395b77cf8f49095cd2ec7cc69e720ab Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 09:35:50 +0200 Subject: [PATCH 05/14] Send editor selection from extension to webview --- .../ql-vscode/src/common/interface-types.ts | 24 ++++- .../src/local-queries/results-view.ts | 96 ++++++++++++++++++- .../ql-vscode/src/view/results/ResultsApp.tsx | 15 +++ 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/extensions/ql-vscode/src/common/interface-types.ts b/extensions/ql-vscode/src/common/interface-types.ts index 7d18d4db93c..e358b6206f8 100644 --- a/extensions/ql-vscode/src/common/interface-types.ts +++ b/extensions/ql-vscode/src/common/interface-types.ts @@ -220,6 +220,27 @@ interface UntoggleShowProblemsMsg { t: "untoggleShowProblems"; } +/** + * Information about the current editor selection, sent to the results view + * so it can filter results to only those overlapping the selection. + */ +export interface EditorSelection { + /** The file URI in result-compatible format. */ + fileUri: string; + startLine: number; + endLine: number; + startColumn: number; + endColumn: number; + /** True if the selection is empty (just a cursor), in which case we match the whole file. */ + isEmpty: boolean; +} + +interface SetEditorSelectionMsg { + t: "setEditorSelection"; + selection: EditorSelection | undefined; + wasFromUserInteraction?: boolean; +} + /** * A message sent into the results view. */ @@ -229,7 +250,8 @@ export type IntoResultsViewMsg = | SetUserSettingsMsg | ShowInterpretedPageMsg | NavigateMsg - | UntoggleShowProblemsMsg; + | UntoggleShowProblemsMsg + | SetEditorSelectionMsg; /** * A message sent from the results view. diff --git a/extensions/ql-vscode/src/local-queries/results-view.ts b/extensions/ql-vscode/src/local-queries/results-view.ts index 55480c344f8..113872c3210 100644 --- a/extensions/ql-vscode/src/local-queries/results-view.ts +++ b/extensions/ql-vscode/src/local-queries/results-view.ts @@ -1,5 +1,9 @@ import type { Location, Result, Run } from "sarif"; -import type { WebviewPanel, TextEditorSelectionChangeEvent } from "vscode"; +import type { + WebviewPanel, + TextEditorSelectionChangeEvent, + Range, +} from "vscode"; import { Diagnostic, DiagnosticRelatedInformation, @@ -18,6 +22,10 @@ import type { DatabaseManager, } from "../databases/local-databases"; import { DatabaseEventKind } from "../databases/local-databases"; +import { + decodeSourceArchiveUri, + zipArchiveScheme, +} from "../common/vscode/archive-filesystem-provider"; import { asError, assertNever, @@ -35,6 +43,7 @@ import type { InterpretedResultsSortState, RawResultsSortState, ParsedResultSets, + EditorSelection, } from "../common/interface-types"; import { SortDirection, @@ -197,6 +206,12 @@ export class ResultsView extends AbstractWebview< ), ); + this.disposableEventListeners.push( + window.onDidChangeActiveTextEditor(() => { + this.sendEditorSelectionToWebview(); + }), + ); + this.disposableEventListeners.push( this.databaseManager.onDidChangeDatabaseItem(({ kind }) => { if (kind === DatabaseEventKind.Remove) { @@ -573,6 +588,9 @@ export class ResultsView extends AbstractWebview< queryName: this.labelProvider.getLabel(fullQuery), queryPath: fullQuery.initialInfo.queryPath, }); + + // Send the current editor selection so the webview can apply filtering immediately + this.sendEditorSelectionToWebview(); } /** @@ -1021,7 +1039,10 @@ export class ResultsView extends AbstractWebview< } private handleSelectionChange(event: TextEditorSelectionChangeEvent): void { - if (event.kind === TextEditorSelectionChangeKind.Command) { + const wasFromUserInteraction = + event.kind !== TextEditorSelectionChangeKind.Command; + this.sendEditorSelectionToWebview(wasFromUserInteraction); + if (!wasFromUserInteraction) { return; // Ignore selection events we caused ourselves. } const editor = window.activeTextEditor; @@ -1031,6 +1052,77 @@ export class ResultsView extends AbstractWebview< } } + /** + * Sends the current editor selection to the webview so it can filter results. + * Does not send when there is no active text editor (e.g. when the webview + * gains focus), so the webview retains the last known selection. + */ + private sendEditorSelectionToWebview(wasFromUserInteraction = false): void { + if (!this.isShowingPanel) { + return; + } + const selection = this.computeEditorSelection(); + if (selection === undefined) { + return; + } + void this.postMessage({ + t: "setEditorSelection", + selection, + wasFromUserInteraction, + }); + } + + /** + * Computes the current editor selection in a format compatible with result locations. + */ + private computeEditorSelection(): EditorSelection | undefined { + const editor = window.activeTextEditor; + if (!editor) { + return undefined; + } + + return this.rangeToEditorSelection(editor.document.uri, editor.selection); + } + + private rangeToEditorSelection(uri: Uri, range: Range) { + const fileUri = this.getEditorFileUri(uri); + if (fileUri == null) { + return undefined; + } + return { + fileUri, + // VS Code selections are 0-based; result locations are 1-based + startLine: range.start.line + 1, + endLine: range.end.line + 1, + startColumn: range.start.character + 1, + endColumn: range.end.character + 1, + isEmpty: range.isEmpty, + }; + } + + /** + * Gets a file URI from the editor that can be compared with result location URIs. + * + * Result URIs (in BQRS and SARIF) use the original source file paths. + * For `file:` scheme editors, the URI already matches. + * For source archive editors, we extract the path within the archive, + * which corresponds to the original source file path. + */ + private getEditorFileUri(editorUri: Uri): string | undefined { + if (editorUri.scheme === "file") { + return editorUri.toString(); + } + if (editorUri.scheme === zipArchiveScheme) { + try { + const { pathWithinSourceArchive } = decodeSourceArchiveUri(editorUri); + return `file://${pathWithinSourceArchive}`; + } catch { + return undefined; + } + } + return undefined; + } + dispose() { super.dispose(); diff --git a/extensions/ql-vscode/src/view/results/ResultsApp.tsx b/extensions/ql-vscode/src/view/results/ResultsApp.tsx index 3669cb8966b..0ba3f3bf2c0 100644 --- a/extensions/ql-vscode/src/view/results/ResultsApp.tsx +++ b/extensions/ql-vscode/src/view/results/ResultsApp.tsx @@ -1,6 +1,7 @@ import { assertNever, getErrorMessage } from "../../common/helpers-pure"; import type { DatabaseInfo, + EditorSelection, Interpretation, IntoResultsViewMsg, SortedResultSetInfo, @@ -66,6 +67,7 @@ interface ResultsViewState { nextResultsInfo: ResultsInfo | null; isExpectingResultsUpdate: boolean; selectionFilterEnabled: boolean; + editorSelection: EditorSelection | undefined; selectedTable: string | undefined; } @@ -82,6 +84,7 @@ export function ResultsApp() { nextResultsInfo: null, isExpectingResultsUpdate: true, selectionFilterEnabled: false, + editorSelection: undefined, selectedTable: undefined, }); @@ -224,6 +227,18 @@ export function ResultsApp() { setProblemsViewSelected(false); break; + case "setEditorSelection": + if (msg.selection) { + const selection = msg.selection; + setState((prev) => { + return { + ...prev, + editorSelection: selection, + }; + }); + } + break; + default: assertNever(msg); } From 3730d2a50c2207332b6b8e1f7ee55da25af149f5 Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 09:38:25 +0200 Subject: [PATCH 06/14] Do not update selection filter when clicking a link --- extensions/ql-vscode/src/view/results/ResultsApp.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extensions/ql-vscode/src/view/results/ResultsApp.tsx b/extensions/ql-vscode/src/view/results/ResultsApp.tsx index 0ba3f3bf2c0..eda9e58aec2 100644 --- a/extensions/ql-vscode/src/view/results/ResultsApp.tsx +++ b/extensions/ql-vscode/src/view/results/ResultsApp.tsx @@ -230,7 +230,11 @@ export function ResultsApp() { case "setEditorSelection": if (msg.selection) { const selection = msg.selection; + const wasFromUserInteraction = msg.wasFromUserInteraction ?? false; setState((prev) => { + if (prev.selectionFilterEnabled && !wasFromUserInteraction) { + return prev; // Ignore selection changes we caused ourselves while filter was active + } return { ...prev, editorSelection: selection, From 679b2c6053798276a39f00c0b9b648e1147cae2e Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 09:44:42 +0200 Subject: [PATCH 07/14] Add state and messages for file-filtered results Just adds the state and messages, and invalidation logic. No actual filtering happens yet --- .../ql-vscode/src/common/interface-types.ts | 38 +++++++++++++++- .../src/local-queries/results-view.ts | 2 + .../ql-vscode/src/view/results/ResultsApp.tsx | 43 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/extensions/ql-vscode/src/common/interface-types.ts b/extensions/ql-vscode/src/common/interface-types.ts index e358b6206f8..e92d6844408 100644 --- a/extensions/ql-vscode/src/common/interface-types.ts +++ b/extensions/ql-vscode/src/common/interface-types.ts @@ -241,6 +241,28 @@ interface SetEditorSelectionMsg { wasFromUserInteraction?: boolean; } +/** + * Results pre-filtered by file URI, sent from the extension when the + * selection filter is active and the editor's file changes. + * This bypasses pagination so the webview can apply line-range filtering + * on the complete set of results for the file. + */ +export interface FileFilteredResults { + /** The file URI these results were filtered for. */ + fileUri: string; + /** The result set table these results were filtered for. */ + selectedTable: string; + /** Raw result rows from the current result set that reference this file. */ + rawRows?: Row[]; + /** SARIF results that reference this file. */ + sarifResults?: Result[]; +} + +interface SetFileFilteredResultsMsg { + t: "setFileFilteredResults"; + results: FileFilteredResults; +} + /** * A message sent into the results view. */ @@ -251,6 +273,7 @@ export type IntoResultsViewMsg = | ShowInterpretedPageMsg | NavigateMsg | UntoggleShowProblemsMsg + | SetFileFilteredResultsMsg | SetEditorSelectionMsg; /** @@ -263,7 +286,20 @@ export type FromResultsViewMsg = | ChangeRawResultsSortMsg | ChangeInterpretedResultsSortMsg | ChangePage - | OpenFileMsg; + | OpenFileMsg + | RequestFileFilteredResultsMsg; + +/** + * Message from the results view to request pre-filtered results for + * a specific (file, table) pair. The extension loads all results from + * the given table that reference the given file and sends them back + * via setFileFilteredResults. + */ +interface RequestFileFilteredResultsMsg { + t: "requestFileFilteredResults"; + fileUri: string; + selectedTable: string; +} /** * Message from the results view to open a source diff --git a/extensions/ql-vscode/src/local-queries/results-view.ts b/extensions/ql-vscode/src/local-queries/results-view.ts index 113872c3210..432f6686f3a 100644 --- a/extensions/ql-vscode/src/local-queries/results-view.ts +++ b/extensions/ql-vscode/src/local-queries/results-view.ts @@ -348,6 +348,8 @@ export class ResultsView extends AbstractWebview< case "openFile": await this.openFile(msg.filePath); break; + case "requestFileFilteredResults": + break; case "telemetry": telemetryListener?.sendUIInteraction(msg.action); break; diff --git a/extensions/ql-vscode/src/view/results/ResultsApp.tsx b/extensions/ql-vscode/src/view/results/ResultsApp.tsx index eda9e58aec2..60475ac59e1 100644 --- a/extensions/ql-vscode/src/view/results/ResultsApp.tsx +++ b/extensions/ql-vscode/src/view/results/ResultsApp.tsx @@ -2,6 +2,7 @@ import { assertNever, getErrorMessage } from "../../common/helpers-pure"; import type { DatabaseInfo, EditorSelection, + FileFilteredResults, Interpretation, IntoResultsViewMsg, SortedResultSetInfo, @@ -69,6 +70,7 @@ interface ResultsViewState { selectionFilterEnabled: boolean; editorSelection: EditorSelection | undefined; selectedTable: string | undefined; + fileFilteredResults: FileFilteredResults | undefined; } /** @@ -86,12 +88,33 @@ export function ResultsApp() { selectionFilterEnabled: false, editorSelection: undefined, selectedTable: undefined, + fileFilteredResults: undefined, }); const [userSettings, setUserSettings] = useState( DEFAULT_USER_SETTINGS, ); + useEffect(() => { + if ( + state.selectionFilterEnabled && + state.editorSelection?.fileUri != null && + state.selectedTable != null && + state.fileFilteredResults == null + ) { + vscode.postMessage({ + t: "requestFileFilteredResults", + fileUri: state.editorSelection.fileUri, + selectedTable: state.selectedTable, + }); + } + }, [ + state.selectionFilterEnabled, + state.editorSelection?.fileUri, + state.selectedTable, + state.fileFilteredResults, + ]); + const [problemsViewSelected, setProblemsViewSelected] = useState(false); const onSelectedTableChange = useCallback((tableName: string) => { @@ -100,6 +123,7 @@ export function ResultsApp() { return { ...prev, selectedTable: tableName, + fileFilteredResults: undefined, // Discard stale results (they are from another table) }; }); }, []); @@ -238,11 +262,30 @@ export function ResultsApp() { return { ...prev, editorSelection: selection, + fileFilteredResults: + selection.fileUri === prev.editorSelection?.fileUri + ? prev.fileFilteredResults + : undefined, // Discard stale results (they are from another file) }; }); } break; + case "setFileFilteredResults": { + const results = msg.results; + setState((prev) => { + if ( + results.fileUri === prev.editorSelection?.fileUri && + results.selectedTable === prev.selectedTable && + prev.fileFilteredResults === undefined + ) { + return { ...prev, fileFilteredResults: results }; + } + return prev; + }); + break; + } + default: assertNever(msg); } From 9eec6d03da8db081bbb0d35fcc2212cdf6e65145 Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 09:48:11 +0200 Subject: [PATCH 08/14] Compute file-filtered results --- .../ql-vscode/src/common/sarif-utils.ts | 51 +++++++- .../src/local-queries/results-view.ts | 112 +++++++++++++++++- .../ql-vscode/src/view/results/ResultsApp.tsx | 3 +- 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/extensions/ql-vscode/src/common/sarif-utils.ts b/extensions/ql-vscode/src/common/sarif-utils.ts index 61e3d3a3807..95b60cbb0cb 100644 --- a/extensions/ql-vscode/src/common/sarif-utils.ts +++ b/extensions/ql-vscode/src/common/sarif-utils.ts @@ -1,4 +1,4 @@ -import type { Location, Region } from "sarif"; +import type { Location, Region, Result } from "sarif"; import type { HighlightedRegion } from "../variant-analysis/shared/analysis-result"; import type { UrlValueResolvable } from "./raw-result-types"; import { isEmptyPath } from "./bqrs-utils"; @@ -252,3 +252,52 @@ export function parseHighlightedLine( return { plainSection1, highlightedSection, plainSection2 }; } + +/** + * Normalizes a file URI to a plain path for comparison purposes. + * Strips the `file:` scheme prefix and decodes URI components. + */ +export function normalizeFileUri(uri: string): string { + try { + const path = uri.replace(/^file:\/*/, "/"); + return decodeURIComponent(path); + } catch { + return uri.replace(/^file:\/*/, "/"); + } +} + +interface ParsedResultLocation { + uri: string; + startLine?: number; + endLine?: number; +} + +/** + * Extracts all locations from a SARIF result, including relatedLocations. + */ +export function getLocationsFromSarifResult( + result: Result, + sourceLocationPrefix: string, +): ParsedResultLocation[] { + const sarifLocations: Location[] = [ + ...(result.locations ?? []), + ...(result.relatedLocations ?? []), + ]; + const parsed: ParsedResultLocation[] = []; + for (const loc of sarifLocations) { + const p = parseSarifLocation(loc, sourceLocationPrefix); + if ("hint" in p) { + continue; + } + if (p.type === "wholeFileLocation") { + parsed.push({ uri: p.uri }); + } else if (p.type === "lineColumnLocation") { + parsed.push({ + uri: p.uri, + startLine: p.startLine, + endLine: p.endLine, + }); + } + } + return parsed; +} diff --git a/extensions/ql-vscode/src/local-queries/results-view.ts b/extensions/ql-vscode/src/local-queries/results-view.ts index 432f6686f3a..5a7d6978af1 100644 --- a/extensions/ql-vscode/src/local-queries/results-view.ts +++ b/extensions/ql-vscode/src/local-queries/results-view.ts @@ -51,6 +51,7 @@ import { GRAPH_TABLE_NAME, NavigationDirection, getDefaultResultSetName, + RAW_RESULTS_LIMIT, } from "../common/interface-types"; import { extLogger } from "../common/logging/vscode"; import type { Logger } from "../common/logging"; @@ -62,6 +63,8 @@ import type { import { interpretResultsSarif, interpretGraphResults } from "../query-results"; import type { QueryEvaluationInfo } from "../run-queries-shared"; import { + getLocationsFromSarifResult, + normalizeFileUri, parseSarifLocation, parseSarifPlainTextMessage, } from "../common/sarif-utils"; @@ -82,7 +85,7 @@ import { redactableError } from "../common/errors"; import type { ResultsViewCommands } from "../common/commands"; import type { App } from "../common/app"; import type { Disposable } from "../common/disposable-object"; -import type { RawResultSet } from "../common/raw-result-types"; +import type { RawResultSet, Row } from "../common/raw-result-types"; import type { BqrsResultSetSchema } from "../common/bqrs-cli-types"; import { CachedOperation } from "../language-support/contextual/cached-operation"; @@ -349,6 +352,7 @@ export class ResultsView extends AbstractWebview< await this.openFile(msg.filePath); break; case "requestFileFilteredResults": + void this.loadFileFilteredResults(msg.fileUri, msg.selectedTable); break; case "telemetry": telemetryListener?.sendUIInteraction(msg.action); @@ -1125,6 +1129,83 @@ export class ResultsView extends AbstractWebview< return undefined; } + /** + * Loads all results from the given table that reference the given file URI, + * and sends them to the webview. Called on demand when the webview requests + * pre-filtered results for a specific (file, table) pair. + */ + private async loadFileFilteredResults( + fileUri: string, + selectedTable: string, + ): Promise { + const query = this._displayedQuery; + if (!query) { + void this.postMessage({ + t: "setFileFilteredResults", + results: { fileUri, selectedTable }, + }); + return; + } + + const normalizedFilterUri = normalizeFileUri(fileUri); + + let rawRows: Row[] | undefined; + let sarifResults: Result[] | undefined; + + // Load and filter raw BQRS results + try { + const resultSetSchemas = await this.getResultSetSchemas( + query.completedQuery, + ); + const schema = resultSetSchemas.find((s) => s.name === selectedTable); + + if (schema && schema.rows > 0) { + const resultsPath = query.completedQuery.getResultsPath(selectedTable); + const chunk = await this.cliServer.bqrsDecode( + resultsPath, + schema.name, + { + offset: schema.pagination?.offsets[0], + pageSize: schema.rows, + }, + ); + const resultSet = bqrsToResultSet(schema, chunk); + rawRows = filterRowsByFileUri(resultSet.rows, normalizedFilterUri); + if (rawRows.length > RAW_RESULTS_LIMIT) { + rawRows = rawRows.slice(0, RAW_RESULTS_LIMIT); + } + } + } catch (e) { + void this.logger.log( + `Error loading file-filtered raw results: ${getErrorMessage(e)}`, + ); + } + + // Filter SARIF results (already in memory) + if (this._interpretation?.data.t === "SarifInterpretationData") { + const allResults = this._interpretation.data.runs[0]?.results ?? []; + sarifResults = allResults.filter((result) => { + const locations = getLocationsFromSarifResult( + result, + this._interpretation!.sourceLocationPrefix, + ); + return locations.some( + (loc) => normalizeFileUri(loc.uri) === normalizedFilterUri, + ); + }); + } + + void this.postMessage({ + t: "setFileFilteredResults", + results: { + fileUri, + selectedTable, + rawRows, + sarifResults, + }, + }); + } + dispose() { super.dispose(); @@ -1133,3 +1214,32 @@ export class ResultsView extends AbstractWebview< this.disposableEventListeners = []; } } + +/** + * Filters raw result rows to those that have at least one location + * referencing the given file (compared by normalized URI). + */ +function filterRowsByFileUri(rows: Row[], normalizedFileUri: string): Row[] { + return rows.filter((row) => { + for (const cell of row) { + if (cell.type !== "entity") { + continue; + } + const url = cell.value.url; + if (!url) { + continue; + } + let uri: string | undefined; + if ( + url.type === "wholeFileLocation" || + url.type === "lineColumnLocation" + ) { + uri = url.uri; + } + if (uri !== undefined && normalizeFileUri(uri) === normalizedFileUri) { + return true; + } + } + return false; + }); +} diff --git a/extensions/ql-vscode/src/view/results/ResultsApp.tsx b/extensions/ql-vscode/src/view/results/ResultsApp.tsx index 60475ac59e1..3f495714fd6 100644 --- a/extensions/ql-vscode/src/view/results/ResultsApp.tsx +++ b/extensions/ql-vscode/src/view/results/ResultsApp.tsx @@ -24,7 +24,8 @@ import { ResultTables } from "./ResultTables"; import { onNavigation } from "./navigation"; import "./resultsView.css"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { vscode } from "../vscode-api"; /** * ResultsApp.tsx From a9fd7bedbc3cba193863633ef5f65390a061a4c9 Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 10:40:18 +0200 Subject: [PATCH 09/14] Pass down selection filter data --- .../ql-vscode/src/view/results/ResultTables.tsx | 14 +++++++++++++- .../ql-vscode/src/view/results/ResultsApp.tsx | 6 ++++++ .../src/view/results/result-table-utils.ts | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/extensions/ql-vscode/src/view/results/ResultTables.tsx b/extensions/ql-vscode/src/view/results/ResultTables.tsx index 8a12fe92ed2..9a48af5c1a6 100644 --- a/extensions/ql-vscode/src/view/results/ResultTables.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTables.tsx @@ -1,5 +1,7 @@ import type { DatabaseInfo, + EditorSelection, + FileFilteredResults, Interpretation, RawResultsSortState, QueryMetadata, @@ -44,6 +46,8 @@ interface ResultTablesProps { queryPath: string; selectedTable: string; onSelectedTableChange: (tableName: string) => void; + selectionFilter: EditorSelection | undefined; + fileFilteredResults: FileFilteredResults | undefined; selectionFilterEnabled: boolean; onSelectionFilterEnabledChange: (value: boolean) => void; problemsViewSelected: boolean; @@ -108,6 +112,8 @@ export function ResultTables(props: ResultTablesProps) { sortStates, selectedTable, onSelectedTableChange, + selectionFilter, + fileFilteredResults, selectionFilterEnabled, onSelectionFilterEnabledChange, problemsViewSelected, @@ -186,6 +192,10 @@ export function ResultTables(props: ResultTablesProps) { const resultSetName = resultSet ? getResultSetName(resultSet) : undefined; + // True if file-filtered results are still loading from the extension + const isLoadingFilteredResults = + selectionFilter != null && fileFilteredResults == null; + return (
@@ -215,7 +225,8 @@ export function ResultTables(props: ResultTablesProps) { ) : null}
- {resultSet && resultSetName && ( + {isLoadingFilteredResults && Loading filtered results…} + {!isLoadingFilteredResults && resultSet && resultSetName && ( )} diff --git a/extensions/ql-vscode/src/view/results/ResultsApp.tsx b/extensions/ql-vscode/src/view/results/ResultsApp.tsx index 3f495714fd6..baca3c3cbcc 100644 --- a/extensions/ql-vscode/src/view/results/ResultsApp.tsx +++ b/extensions/ql-vscode/src/view/results/ResultsApp.tsx @@ -152,6 +152,10 @@ export function ResultsApp() { } }, [displayedResultsInfo, state.selectedTable, onSelectedTableChange]); + const selectionFilter = state.selectionFilterEnabled + ? state.editorSelection + : undefined; + const updateStateWithNewResultsInfo = useCallback( (resultsInfo: ResultsInfo): void => { let results: Results | null = null; @@ -336,6 +340,8 @@ export function ResultsApp() { queryPath={displayedResults.resultsInfo.queryPath} selectedTable={state.selectedTable ?? ""} onSelectedTableChange={onSelectedTableChange} + selectionFilter={selectionFilter} + fileFilteredResults={state.fileFilteredResults} selectionFilterEnabled={state.selectionFilterEnabled} onSelectionFilterEnabledChange={(selectionFilterEnabled) => { setState((prev) => ({ ...prev, selectionFilterEnabled })); diff --git a/extensions/ql-vscode/src/view/results/result-table-utils.ts b/extensions/ql-vscode/src/view/results/result-table-utils.ts index 8e340324296..d6107c704ce 100644 --- a/extensions/ql-vscode/src/view/results/result-table-utils.ts +++ b/extensions/ql-vscode/src/view/results/result-table-utils.ts @@ -29,6 +29,8 @@ export interface ResultTableProps { * Callback to show raw results. */ showRawResults: () => void; + + selectionFilter?: EditorSelection; } export const className = "vscode-codeql__result-table"; From 08f5361dc28430d2b58e13c0f7f4a820c12fe137 Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 10:43:24 +0200 Subject: [PATCH 10/14] Actual show filtered results --- .../src/view/results/ResultTable.tsx | 15 ++- .../src/view/results/ResultTables.tsx | 54 +++++++- .../src/view/results/result-table-utils.ts | 121 +++++++++++++++++- 3 files changed, 182 insertions(+), 8 deletions(-) diff --git a/extensions/ql-vscode/src/view/results/ResultTable.tsx b/extensions/ql-vscode/src/view/results/ResultTable.tsx index b6718dd36b8..de8da83f94a 100644 --- a/extensions/ql-vscode/src/view/results/ResultTable.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTable.tsx @@ -6,17 +6,24 @@ import { AlertTableNoResults } from "./AlertTableNoResults"; import { AlertTableHeader } from "./AlertTableHeader"; export function ResultTable(props: ResultTableProps) { - const { resultSet, userSettings } = props; + const { resultSet, userSettings, filteredRawRows, filteredSarifResults } = + props; switch (resultSet.t) { - case "RawResultSet": - return ; + case "RawResultSet": { + const filteredResultSet = { + ...resultSet.resultSet, + rows: filteredRawRows ?? resultSet.resultSet.rows, + }; + return ; + } case "InterpretedResultSet": { const data = resultSet.interpretation.data; switch (data.t) { case "SarifInterpretationData": { + const results = filteredSarifResults ?? data.runs[0].results ?? []; return ( { + if (!selectionFilter || !resultSet || resultSet.t !== "RawResultSet") { + return undefined; + } + const sourceRows = fileFilteredResults?.rawRows; + if (sourceRows == null) { + return undefined; + } + return filterRawRows(sourceRows, selectionFilter); + }, [selectionFilter, fileFilteredResults, resultSet]); + + // Filter SARIF results at line granularity (if filtering is enabled) + const filteredSarifResults = useMemo(() => { + if ( + !selectionFilter || + !resultSet || + resultSet.t !== "InterpretedResultSet" + ) { + return undefined; + } + const data = resultSet.interpretation.data; + if (data.t !== "SarifInterpretationData") { + return undefined; + } + const sourceResults = + fileFilteredResults?.sarifResults !== undefined + ? fileFilteredResults.sarifResults + : (data.runs[0].results ?? []); + return filterSarifResults( + sourceResults, + resultSet.interpretation.sourceLocationPrefix, + selectionFilter, + ); + }, [selectionFilter, fileFilteredResults, resultSet]); + + const filteredCount = filteredRawRows?.length ?? filteredSarifResults?.length; + return (
@@ -224,8 +266,12 @@ export function ResultTables(props: ResultTablesProps) { Updating results… ) : null} + {isLoadingFilteredResults && ( + + Updating filtered results… + + )}
- {isLoadingFilteredResults && Loading filtered results…} {!isLoadingFilteredResults && resultSet && resultSetName && ( )} diff --git a/extensions/ql-vscode/src/view/results/result-table-utils.ts b/extensions/ql-vscode/src/view/results/result-table-utils.ts index d6107c704ce..2d49faa2ebb 100644 --- a/extensions/ql-vscode/src/view/results/result-table-utils.ts +++ b/extensions/ql-vscode/src/view/results/result-table-utils.ts @@ -1,4 +1,5 @@ import type { + EditorSelection, QueryMetadata, RawResultsSortState, ResultSet, @@ -7,7 +8,16 @@ import type { import { SortDirection } from "../../common/interface-types"; import { assertNever } from "../../common/helpers-pure"; import { vscode } from "../vscode-api"; -import type { UrlValueResolvable } from "../../common/raw-result-types"; +import type { + CellValue, + Row, + UrlValueResolvable, +} from "../../common/raw-result-types"; +import type { Result } from "sarif"; +import { + getLocationsFromSarifResult, + normalizeFileUri, +} from "../../common/sarif-utils"; export interface ResultTableProps { resultSet: ResultSet; @@ -30,6 +40,8 @@ export interface ResultTableProps { */ showRawResults: () => void; + filteredRawRows?: Row[]; + filteredSarifResults?: Result[]; selectionFilter?: EditorSelection; } @@ -109,3 +121,110 @@ export function nextSortDirection( return assertNever(direction); } } + +/** + * Extracts all resolvable locations from a raw result row. + */ +function getLocationsFromRawRow( + row: Row, +): Array<{ uri: string; startLine?: number; endLine?: number }> { + const locations: Array<{ + uri: string; + startLine?: number; + endLine?: number; + }> = []; + + for (const cell of row) { + const loc = getLocationFromCell(cell); + if (loc) { + locations.push(loc); + } + } + + return locations; +} + +function getLocationFromCell( + cell: CellValue, +): { uri: string; startLine?: number; endLine?: number } | undefined { + if (cell.type !== "entity") { + return undefined; + } + const url = cell.value.url; + if (!url) { + return undefined; + } + if (url.type === "wholeFileLocation") { + return { uri: url.uri }; + } + if (url.type === "lineColumnLocation") { + return { + uri: url.uri, + startLine: url.startLine, + endLine: url.endLine, + }; + } + return undefined; +} + +/** + * Checks if a result location overlaps with the editor selection. + * If the selection is empty (just a cursor), matches any result in the same file. + */ +function doesLocationOverlapSelection( + loc: { uri: string; startLine?: number; endLine?: number }, + selection: EditorSelection, +): boolean { + const normalizedLocUri = normalizeFileUri(loc.uri); + const normalizedSelUri = normalizeFileUri(selection.fileUri); + + if (normalizedLocUri !== normalizedSelUri) { + return false; + } + + // If selection is empty (just a cursor), match the whole file + if (selection.isEmpty) { + return true; + } + + // If the result location has no line info, it's a whole-file location — include it + if (loc.startLine === undefined) { + return true; + } + + // Only include results whose starting line falls within the selection range + return ( + loc.startLine >= selection.startLine && loc.startLine <= selection.endLine + ); +} + +/** + * Filters raw result rows to those with at least one location overlapping the selection. + */ +export function filterRawRows( + rows: readonly Row[], + selection: EditorSelection, +): Row[] { + return rows.filter((row) => { + const locations = getLocationsFromRawRow(row); + return locations.some((loc) => + doesLocationOverlapSelection(loc, selection), + ); + }); +} + +/** + * Filters SARIF results to those with at least one location overlapping the selection. + */ +export function filterSarifResults( + results: Result[], + sourceLocationPrefix: string, + selection: EditorSelection, +): Result[] { + return results.filter((result) => { + const locations = getLocationsFromSarifResult(result, sourceLocationPrefix); + return locations.some((loc) => + doesLocationOverlapSelection(loc, selection), + ); + }); +} From 9e6aa15b88eb1da9e28e7b9b6f4483c3a49e3055 Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 10:50:08 +0200 Subject: [PATCH 11/14] Render both filtered and total result count --- .../ql-vscode/src/view/results/ResultCount.tsx | 13 +++++++++++-- .../ql-vscode/src/view/results/ResultTables.tsx | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/extensions/ql-vscode/src/view/results/ResultCount.tsx b/extensions/ql-vscode/src/view/results/ResultCount.tsx index 2311a652ad5..7f64b4f73e0 100644 --- a/extensions/ql-vscode/src/view/results/ResultCount.tsx +++ b/extensions/ql-vscode/src/view/results/ResultCount.tsx @@ -3,6 +3,7 @@ import { tableHeaderItemClassName } from "./result-table-utils"; interface Props { resultSet?: ResultSet; + filteredCount?: number; } function getResultCount(resultSet: ResultSet): number { @@ -19,10 +20,18 @@ export function ResultCount(props: Props): React.JSX.Element | null { return null; } - const resultCount = getResultCount(props.resultSet); + const totalCount = getResultCount(props.resultSet); + if (props.filteredCount !== undefined) { + return ( + + {props.filteredCount} / {totalCount}{" "} + {totalCount === 1 ? "result" : "results"} + + ); + } return ( - {resultCount} {resultCount === 1 ? "result" : "results"} + {totalCount} {totalCount === 1 ? "result" : "results"} ); } diff --git a/extensions/ql-vscode/src/view/results/ResultTables.tsx b/extensions/ql-vscode/src/view/results/ResultTables.tsx index bae6c689182..c276b6a034b 100644 --- a/extensions/ql-vscode/src/view/results/ResultTables.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTables.tsx @@ -255,7 +255,7 @@ export function ResultTables(props: ResultTablesProps) { - + Date: Tue, 14 Apr 2026 11:00:08 +0200 Subject: [PATCH 12/14] Show informative message if filter matched nothing --- .../ql-vscode/src/common/interface-types.ts | 11 +++++ .../src/local-queries/results-view.ts | 25 ++++++++++ .../src/view/results/ResultTable.tsx | 23 +++++++++- .../view/results/SelectionFilterNoResults.tsx | 46 +++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 extensions/ql-vscode/src/view/results/SelectionFilterNoResults.tsx diff --git a/extensions/ql-vscode/src/common/interface-types.ts b/extensions/ql-vscode/src/common/interface-types.ts index e92d6844408..60d87cd7083 100644 --- a/extensions/ql-vscode/src/common/interface-types.ts +++ b/extensions/ql-vscode/src/common/interface-types.ts @@ -220,6 +220,15 @@ interface UntoggleShowProblemsMsg { t: "untoggleShowProblems"; } +export const enum SourceArchiveRelationship { + /** The file is in the source archive of the database the query was run on. */ + CorrectArchive = "correct-archive", + /** The file is in a source archive, but for a different database. */ + WrongArchive = "wrong-archive", + /** The file is not in any source archive. */ + NotInArchive = "not-in-archive", +} + /** * Information about the current editor selection, sent to the results view * so it can filter results to only those overlapping the selection. @@ -233,6 +242,8 @@ export interface EditorSelection { endColumn: number; /** True if the selection is empty (just a cursor), in which case we match the whole file. */ isEmpty: boolean; + /** Describes the relationship between the current file and the query's database source archive. */ + sourceArchiveRelationship: SourceArchiveRelationship; } interface SetEditorSelectionMsg { diff --git a/extensions/ql-vscode/src/local-queries/results-view.ts b/extensions/ql-vscode/src/local-queries/results-view.ts index 5a7d6978af1..f8d514fc6d5 100644 --- a/extensions/ql-vscode/src/local-queries/results-view.ts +++ b/extensions/ql-vscode/src/local-queries/results-view.ts @@ -52,6 +52,7 @@ import { NavigationDirection, getDefaultResultSetName, RAW_RESULTS_LIMIT, + SourceArchiveRelationship, } from "../common/interface-types"; import { extLogger } from "../common/logging/vscode"; import type { Logger } from "../common/logging"; @@ -1103,6 +1104,7 @@ export class ResultsView extends AbstractWebview< startColumn: range.start.character + 1, endColumn: range.end.character + 1, isEmpty: range.isEmpty, + sourceArchiveRelationship: this.getSourceArchiveRelationship(uri), }; } @@ -1129,6 +1131,29 @@ export class ResultsView extends AbstractWebview< return undefined; } + /** + * Determines the relationship between the editor file and the query's database source archive. + */ + private getSourceArchiveRelationship( + editorUri: Uri, + ): SourceArchiveRelationship { + if (editorUri.scheme !== zipArchiveScheme) { + return SourceArchiveRelationship.NotInArchive; + } + const dbItem = this._displayedQuery + ? this.databaseManager.findDatabaseItem( + Uri.parse(this._displayedQuery.initialInfo.databaseInfo.databaseUri), + ) + : undefined; + if ( + dbItem?.sourceArchive && + dbItem.belongsToSourceArchiveExplorerUri(editorUri) + ) { + return SourceArchiveRelationship.CorrectArchive; + } + return SourceArchiveRelationship.WrongArchive; + } + /** * Loads all results from the given table that reference the given file URI, * and sends them to the webview. Called on demand when the webview requests diff --git a/extensions/ql-vscode/src/view/results/ResultTable.tsx b/extensions/ql-vscode/src/view/results/ResultTable.tsx index de8da83f94a..3c716367d9d 100644 --- a/extensions/ql-vscode/src/view/results/ResultTable.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTable.tsx @@ -4,10 +4,29 @@ import { RawTable } from "./RawTable"; import type { ResultTableProps } from "./result-table-utils"; import { AlertTableNoResults } from "./AlertTableNoResults"; import { AlertTableHeader } from "./AlertTableHeader"; +import { SelectionFilterNoResults } from "./SelectionFilterNoResults"; export function ResultTable(props: ResultTableProps) { - const { resultSet, userSettings, filteredRawRows, filteredSarifResults } = - props; + const { + resultSet, + userSettings, + selectionFilter, + filteredRawRows, + filteredSarifResults, + } = props; + + const filteredCount = filteredRawRows?.length ?? filteredSarifResults?.length; + // When filtering is active and the filtered results are empty, show a + // message instead of forwarding to child tables (which would misleadingly + // say the query returned no results). + if (selectionFilter && filteredCount === 0) { + return ( + + ); + } + switch (resultSet.t) { case "RawResultSet": { const filteredResultSet = { diff --git a/extensions/ql-vscode/src/view/results/SelectionFilterNoResults.tsx b/extensions/ql-vscode/src/view/results/SelectionFilterNoResults.tsx new file mode 100644 index 00000000000..d6bc693ed18 --- /dev/null +++ b/extensions/ql-vscode/src/view/results/SelectionFilterNoResults.tsx @@ -0,0 +1,46 @@ +import { styled } from "styled-components"; +import { SourceArchiveRelationship } from "../../common/interface-types"; + +interface Props { + sourceArchiveRelationship: SourceArchiveRelationship; +} + +const Root = styled.div` + height: 300px; + display: flex; + align-items: center; + justify-content: center; +`; + +const Container = styled.span` + max-width: 80%; + font-size: 14px; + text-align: center; +`; + +export function SelectionFilterNoResults({ + sourceArchiveRelationship, +}: Props): React.JSX.Element { + return ( + + + No results match the current selection filter. + {sourceArchiveRelationship === + SourceArchiveRelationship.NotInArchive && ( + <> +
+ This file is not part of a source archive for a database. + + )} + {sourceArchiveRelationship === + SourceArchiveRelationship.WrongArchive && ( + <> +
+ This file is part of the source archive for a different database + than the one this query was run on. + + )} +
+
+ ); +} From e63e601f7f18db8c66df6c15eb50be298ebd8976 Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 11:01:45 +0200 Subject: [PATCH 13/14] Disable pagination UI when filtering is enabled We disable the UI rather than removing it, as removing it causes layout wobbles when toggling the filter checkbox. --- .../src/view/results/ResultTables.tsx | 7 +++-- .../src/view/results/ResultTablesHeader.tsx | 26 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/extensions/ql-vscode/src/view/results/ResultTables.tsx b/extensions/ql-vscode/src/view/results/ResultTables.tsx index c276b6a034b..4ca1c425687 100644 --- a/extensions/ql-vscode/src/view/results/ResultTables.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTables.tsx @@ -240,8 +240,11 @@ export function ResultTables(props: ResultTablesProps) { return (
- -
+
- « + + « + - / {numPages} - + / {disablePagination ? 1 : numPages} + »
{queryName}
From babc2b80f6a90b030bddcc67ce339d08f3e37749 Mon Sep 17 00:00:00 2001 From: Asger F Date: Tue, 14 Apr 2026 12:41:39 +0200 Subject: [PATCH 14/14] Explicitly notify of editor selection when clicking a link Fixes an issue where: - Filtering is disabled - User clicks a link causing a new editor to open - User enables filtering The editor selection would not previously get updated after step 2, leading to incorrect filtering. --- .../src/databases/local-databases/locations.ts | 17 +++++++++++------ .../ql-vscode/src/local-queries/results-view.ts | 13 ++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/extensions/ql-vscode/src/databases/local-databases/locations.ts b/extensions/ql-vscode/src/databases/local-databases/locations.ts index 55961c2a143..b5295f2ed21 100644 --- a/extensions/ql-vscode/src/databases/local-databases/locations.ts +++ b/extensions/ql-vscode/src/databases/local-databases/locations.ts @@ -105,9 +105,9 @@ export async function showResolvableLocation( loc: UrlValueResolvable, databaseItem: DatabaseItem | undefined, logger: Logger, -): Promise { +): Promise { try { - await showLocation(tryResolveLocation(loc, databaseItem)); + return showLocation(tryResolveLocation(loc, databaseItem)); } catch (e) { if (e instanceof Error && e.message.match(/File not found/)) { void Window.showErrorMessage( @@ -116,12 +116,15 @@ export async function showResolvableLocation( } else { void logger.log(`Unable to jump to location: ${getErrorMessage(e)}`); } + return null; } } -export async function showLocation(location?: Location) { +export async function showLocation( + location?: Location, +): Promise { if (!location) { - return; + return null; } const doc = await workspace.openTextDocument(location.uri); @@ -156,6 +159,8 @@ export async function showLocation(location?: Location) { editor.revealRange(range, TextEditorRevealType.InCenter); editor.setDecorations(shownLocationDecoration, [range]); editor.setDecorations(shownLocationLineDecoration, [range]); + + return location; } export async function jumpToLocation( @@ -163,10 +168,10 @@ export async function jumpToLocation( loc: UrlValueResolvable, databaseManager: DatabaseManager, logger: Logger, -) { +): Promise { const databaseItem = databaseUri !== undefined ? databaseManager.findDatabaseItem(Uri.parse(databaseUri)) : undefined; - await showResolvableLocation(loc, databaseItem, logger); + return showResolvableLocation(loc, databaseItem, logger); } diff --git a/extensions/ql-vscode/src/local-queries/results-view.ts b/extensions/ql-vscode/src/local-queries/results-view.ts index f8d514fc6d5..9a47ce2fcf0 100644 --- a/extensions/ql-vscode/src/local-queries/results-view.ts +++ b/extensions/ql-vscode/src/local-queries/results-view.ts @@ -296,12 +296,23 @@ export class ResultsView extends AbstractWebview< this.onWebViewLoaded(); break; case "viewSourceFile": { - await jumpToLocation( + const jumpTarget = await jumpToLocation( msg.databaseUri, msg.loc, this.databaseManager, this.logger, ); + if (jumpTarget != null) { + // For selection-filtering purposes, we want to notify the webview that a new file is being looked at. + await this.postMessage({ + t: "setEditorSelection", + selection: this.rangeToEditorSelection( + jumpTarget.uri, + jumpTarget.range, + ), + wasFromUserInteraction: false, + }); + } break; } case "toggleDiagnostics": {