Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions extensions/ql-vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 23 additions & 1 deletion extensions/ql-vscode/src/common/interface-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -229,7 +250,8 @@ export type IntoResultsViewMsg =
| SetUserSettingsMsg
| ShowInterpretedPageMsg
| NavigateMsg
| UntoggleShowProblemsMsg;
| UntoggleShowProblemsMsg
| SetEditorSelectionMsg;

/**
* A message sent from the results view.
Expand Down
96 changes: 94 additions & 2 deletions extensions/ql-vscode/src/local-queries/results-view.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -35,6 +43,7 @@ import type {
InterpretedResultsSortState,
RawResultsSortState,
ParsedResultSets,
EditorSelection,
} from "../common/interface-types";
import {
SortDirection,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am surprised by the early return here. How would the user revert from selection filtering to whole-file filtering?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

selection being undefined means there is no active editor. An empty selection (just a cursor position) is represented as an selection object with .isEmpty = true and will reset to whole-file filtering.

}
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();

Expand Down
102 changes: 34 additions & 68 deletions extensions/ql-vscode/src/view/results/ResultTables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ import type {
InterpretedResultsSortState,
ResultSet,
ParsedResultSets,
IntoResultsViewMsg,
UserSettings,
} from "../../common/interface-types";
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";
Expand All @@ -24,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";

/**
Expand All @@ -43,6 +42,12 @@ interface ResultTablesProps {
isLoadingNewResults: boolean;
queryName: string;
queryPath: string;
selectedTable: string;
onSelectedTableChange: (tableName: string) => void;
selectionFilterEnabled: boolean;
onSelectionFilterEnabledChange: (value: boolean) => void;
problemsViewSelected: boolean;
onProblemsViewSelectedChange: (selected: boolean) => void;
}

const UPDATING_RESULTS_TEXT_CLASS_NAME =
Expand Down Expand Up @@ -101,64 +106,14 @@ export function ResultTables(props: ResultTablesProps) {
origResultsPaths,
isLoadingNewResults,
sortStates,
selectedTable,
onSelectedTableChange,
selectionFilterEnabled,
onSelectionFilterEnabledChange,
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) ||
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<HTMLSelectElement>): void => {
const selectedTable = event.target.value;
Expand All @@ -167,9 +122,10 @@ export function ResultTables(props: ResultTablesProps) {
pageNumber: 0,
selectedTable,
});
onSelectedTableChange(selectedTable);
sendTelemetry("local-results-table-selection");
},
[],
[onSelectedTableChange],
);

const handleCheckboxChanged = useCallback(
Expand All @@ -178,7 +134,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");
}
Expand All @@ -192,7 +148,14 @@ export function ResultTables(props: ResultTablesProps) {
});
}
},
[database, metadata, origResultsPaths, problemsViewSelected, resultsPath],
[
database,
metadata,
onProblemsViewSelectedChange,
origResultsPaths,
problemsViewSelected,
resultsPath,
],
);

const offset = parsedResultSets.pageNumber * parsedResultSets.pageSize;
Expand Down Expand Up @@ -227,6 +190,15 @@ export function ResultTables(props: ResultTablesProps) {
<div>
<ResultTablesHeader {...props} selectedTable={selectedTable} />
<div className={tableHeaderClassName}></div>
<div
className={tableHeaderClassName}
style={{ justifyContent: "flex-end" }}
>
<SelectionFilterCheckbox
checked={selectionFilterEnabled}
onChange={(e) => onSelectionFilterEnabledChange(e.target.checked)}
/>
</div>
<div className={tableHeaderClassName}>
<select value={selectedTable} onChange={onTableSelectionChange}>
{resultSetOptions}
Expand All @@ -253,7 +225,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}
Expand All @@ -263,12 +235,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":
Expand Down
Loading