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 diff --git a/extensions/ql-vscode/src/common/interface-types.ts b/extensions/ql-vscode/src/common/interface-types.ts index 7d18d4db93c..3939b820ebb 100644 --- a/extensions/ql-vscode/src/common/interface-types.ts +++ b/extensions/ql-vscode/src/common/interface-types.ts @@ -220,6 +220,60 @@ 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. + */ +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; + /** Describes the relationship between the current file and the query's database source archive. */ + sourceArchiveRelationship: SourceArchiveRelationship; +} + +interface SetEditorSelectionMsg { + t: "setEditorSelection"; + selection: EditorSelection | undefined; + 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. */ @@ -229,7 +283,9 @@ export type IntoResultsViewMsg = | SetUserSettingsMsg | ShowInterpretedPageMsg | NavigateMsg - | UntoggleShowProblemsMsg; + | UntoggleShowProblemsMsg + | SetEditorSelectionMsg + | SetFileFilteredResultsMsg; /** * A message sent from the results view. @@ -241,7 +297,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/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/common/vscode/webview-html.ts b/extensions/ql-vscode/src/common/vscode/webview-html.ts index e1eac40024c..68b6751d012 100644 --- a/extensions/ql-vscode/src/common/vscode/webview-html.ts +++ b/extensions/ql-vscode/src/common/vscode/webview-html.ts @@ -80,7 +80,7 @@ export function getHtmlForWebview( diff --git a/extensions/ql-vscode/src/databases/local-databases/locations.ts b/extensions/ql-vscode/src/databases/local-databases/locations.ts index 55961c2a143..ca871175f1f 100644 --- a/extensions/ql-vscode/src/databases/local-databases/locations.ts +++ b/extensions/ql-vscode/src/databases/local-databases/locations.ts @@ -9,6 +9,7 @@ import { window as Window, workspace, } from "vscode"; +import type { TextEditor } from "vscode"; import { assertNever, getErrorMessage } from "../../common/helpers-pure"; import type { Logger } from "../../common/logging"; import type { DatabaseItem } from "./database-item"; @@ -76,6 +77,12 @@ function resolveWholeFileLocation( ); } +/** Returned from `showLocation` and related functions, to indicate which editor and location was ultimately highlighted. */ +interface RevealedLocation { + editor: TextEditor; + location: Location; +} + /** * Try to resolve the specified CodeQL location to a URI into the source archive. If no exact location * can be resolved, returns `undefined`. @@ -105,9 +112,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 +123,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 +166,8 @@ export async function showLocation(location?: Location) { editor.revealRange(range, TextEditorRevealType.InCenter); editor.setDecorations(shownLocationDecoration, [range]); editor.setDecorations(shownLocationLineDecoration, [range]); + + return { editor, location }; } export async function jumpToLocation( @@ -163,10 +175,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 55480c344f8..a5ac1b12848 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, @@ -42,6 +51,8 @@ import { GRAPH_TABLE_NAME, NavigationDirection, getDefaultResultSetName, + RAW_RESULTS_LIMIT, + SourceArchiveRelationship, } from "../common/interface-types"; import { extLogger } from "../common/logging/vscode"; import type { Logger } from "../common/logging"; @@ -53,6 +64,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"; @@ -73,7 +86,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"; @@ -197,6 +210,12 @@ export class ResultsView extends AbstractWebview< ), ); + this.disposableEventListeners.push( + window.onDidChangeActiveTextEditor(() => { + this.sendEditorSelectionToWebview(); + }), + ); + this.disposableEventListeners.push( this.databaseManager.onDidChangeDatabaseItem(({ kind }) => { if (kind === DatabaseEventKind.Remove) { @@ -277,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.location.uri, + jumpTarget.location.range, + ), + wasFromUserInteraction: false, + }); + } break; } case "toggleDiagnostics": { @@ -333,6 +363,9 @@ export class ResultsView extends AbstractWebview< case "openFile": await this.openFile(msg.filePath); break; + case "requestFileFilteredResults": + void this.loadFileFilteredResults(msg.fileUri, msg.selectedTable); + break; case "telemetry": telemetryListener?.sendUIInteraction(msg.action); break; @@ -573,6 +606,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 +1057,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 +1070,178 @@ 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, + sourceArchiveRelationship: this.getSourceArchiveRelationship(uri), + }; + } + + /** + * 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; + } + + /** + * 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 + * 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(); @@ -1039,3 +1250,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/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/ResultTable.tsx b/extensions/ql-vscode/src/view/results/ResultTable.tsx index b6718dd36b8..83c64afdc32 100644 --- a/extensions/ql-vscode/src/view/results/ResultTable.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTable.tsx @@ -4,19 +4,70 @@ import { RawTable } from "./RawTable"; import type { ResultTableProps } from "./result-table-utils"; import { AlertTableNoResults } from "./AlertTableNoResults"; import { AlertTableHeader } from "./AlertTableHeader"; +import { SelectionFilterNoResults } from "./SelectionFilterNoResults"; +import type { Row } from "../../common/raw-result-types"; +import type { Result } from "sarif"; +import type { EditorSelection } from "../../common/interface-types"; + +interface FilteredResultTableProps extends ResultTableProps { + /** + * When selection filtering is active, these hold the pre-filtered results. + * `undefined` means filtering is not active for this result type. + */ + filteredRawRows?: Row[]; + filteredSarifResults?: Result[]; + /** True if file-filtered results are still loading from the extension. */ + isLoadingFilteredResults?: boolean; + selectionFilter?: EditorSelection; +} + +export function ResultTable(props: FilteredResultTableProps) { + const { + resultSet, + userSettings, + selectionFilter, + filteredRawRows, + filteredSarifResults, + isLoadingFilteredResults, + } = props; + + if (isLoadingFilteredResults) { + return Loading filtered results…; + } + + // 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) { + const filteredEmpty = + (filteredRawRows !== undefined && filteredRawRows.length === 0) || + (filteredSarifResults !== undefined && filteredSarifResults.length === 0); + if (filteredEmpty) { + return ( + + ); + } + } -export function ResultTable(props: ResultTableProps) { - const { resultSet, userSettings } = props; switch (resultSet.t) { - case "RawResultSet": - return ; + case "RawResultSet": { + const rows = filteredRawRows ?? resultSet.resultSet.rows; + const filteredResultSet = { + ...resultSet.resultSet, + rows, + }; + return ; + } case "InterpretedResultSet": { const data = resultSet.interpretation.data; switch (data.t) { case "SarifInterpretationData": { + const results = filteredSarifResults ?? data.runs[0].results ?? []; return ( void; + selectionFilter: EditorSelection | undefined; + fileFilteredResults: FileFilteredResults | undefined; + selectionFilterEnabled: boolean; + onSelectionFilterEnabledChange: (value: boolean) => void; + problemsViewSelected: boolean; + onProblemsViewSelectedChange: (selected: boolean) => void; } const UPDATING_RESULTS_TEXT_CLASS_NAME = @@ -101,75 +114,28 @@ export function ResultTables(props: ResultTablesProps) { origResultsPaths, isLoadingNewResults, sortStates, + selectedTable, + onSelectedTableChange, + selectionFilter, + fileFilteredResults, + 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): void => { - const selectedTable = event.target.value; + const table = event.target.value; vscode.postMessage({ t: "changePage", pageNumber: 0, - selectedTable, + selectedTable: table, }); + onSelectedTableChange(table); sendTelemetry("local-results-table-selection"); }, - [], + [onSelectedTableChange], ); const handleCheckboxChanged = useCallback( @@ -178,7 +144,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 +158,14 @@ export function ResultTables(props: ResultTablesProps) { }); } }, - [database, metadata, origResultsPaths, problemsViewSelected, resultsPath], + [ + database, + metadata, + onProblemsViewSelectedChange, + origResultsPaths, + problemsViewSelected, + resultsPath, + ], ); const offset = parsedResultSets.pageNumber * parsedResultSets.pageSize; @@ -223,15 +196,85 @@ 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; + + // Filter rows at line granularity (if filtering is enabled) + const filteredRawRows = useMemo(() => { + if (!selectionFilter || !resultSet || resultSet.t !== "RawResultSet") { + return undefined; + } + if (isLoadingFilteredResults) { + return undefined; + } + const sourceRows = + fileFilteredResults?.rawRows !== undefined + ? fileFilteredResults.rawRows + : resultSet.resultSet.rows; + return filterRawRows(sourceRows, selectionFilter); + }, [ + selectionFilter, + fileFilteredResults, + resultSet, + isLoadingFilteredResults, + ]); + + // 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; + } + if (isLoadingFilteredResults) { + return undefined; + } + const sourceResults = + fileFilteredResults?.sarifResults !== undefined + ? fileFilteredResults.sarifResults + : (data.runs[0].results ?? []); + return filterSarifResults( + sourceResults, + resultSet.interpretation.sourceLocationPrefix, + selectionFilter, + ); + }, [ + selectionFilter, + fileFilteredResults, + resultSet, + isLoadingFilteredResults, + ]); + + const filteredCount = filteredRawRows?.length ?? filteredSarifResults?.length; + return (
- -
+ +
+ onSelectionFilterEnabledChange(e.target.checked)} + /> +
- + { - setSelectedTable(SELECT_TABLE_NAME); + onSelectedTableChange(SELECT_TABLE_NAME); sendTelemetry("local-results-show-raw-results"); }} offset={offset} + selectionFilter={selectionFilter} + filteredRawRows={filteredRawRows} + filteredSarifResults={filteredSarifResults} + isLoadingFilteredResults={isLoadingFilteredResults} /> )}
); } -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/ResultTablesHeader.tsx b/extensions/ql-vscode/src/view/results/ResultTablesHeader.tsx index 602ee6b70db..f69f54fd532 100644 --- a/extensions/ql-vscode/src/view/results/ResultTablesHeader.tsx +++ b/extensions/ql-vscode/src/view/results/ResultTablesHeader.tsx @@ -13,6 +13,7 @@ interface Props { queryPath: string; parsedResultSets: ParsedResultSets; selectedTable: string; + disablePagination?: boolean; } const Container = styled.span` @@ -57,7 +58,13 @@ const OpenQueryLink = styled(TextButton)` `; export function ResultTablesHeader(props: Props) { - const { queryPath, queryName, parsedResultSets, selectedTable } = props; + const { + queryPath, + queryName, + parsedResultSets, + selectedTable, + disablePagination, + } = props; const [selectedPage, setSelectedPage] = useState( `${parsedResultSets.pageNumber + 1}`, @@ -145,19 +152,26 @@ export function ResultTablesHeader(props: Props) { return ( - « + + « + - / {numPages} - + / {disablePagination ? 1 : numPages} + »
{queryName}
diff --git a/extensions/ql-vscode/src/view/results/ResultsApp.tsx b/extensions/ql-vscode/src/view/results/ResultsApp.tsx index 014080a9bdd..baca3c3cbcc 100644 --- a/extensions/ql-vscode/src/view/results/ResultsApp.tsx +++ b/extensions/ql-vscode/src/view/results/ResultsApp.tsx @@ -1,6 +1,8 @@ import { assertNever, getErrorMessage } from "../../common/helpers-pure"; import type { DatabaseInfo, + EditorSelection, + FileFilteredResults, Interpretation, IntoResultsViewMsg, SortedResultSetInfo, @@ -15,13 +17,15 @@ import { ALERTS_TABLE_NAME, DEFAULT_USER_SETTINGS, GRAPH_TABLE_NAME, + getDefaultResultSetName, } from "../../common/interface-types"; import { useMessageFromExtension } from "../common/useMessageFromExtension"; 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 @@ -64,6 +68,10 @@ interface ResultsViewState { displayedResults: ResultsState; nextResultsInfo: ResultsInfo | null; isExpectingResultsUpdate: boolean; + selectionFilterEnabled: boolean; + editorSelection: EditorSelection | undefined; + selectedTable: string | undefined; + fileFilteredResults: FileFilteredResults | undefined; } /** @@ -78,12 +86,76 @@ export function ResultsApp() { }, nextResultsInfo: null, isExpectingResultsUpdate: true, + 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) => { + setState((prev) => { + if (tableName === prev.selectedTable) return prev; + return { + ...prev, + selectedTable: tableName, + fileFilteredResults: undefined, // Discard stale results (they are from another table) + }; + }); + }, []); + + // 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 selectionFilter = state.selectionFilterEnabled + ? state.editorSelection + : undefined; + const updateStateWithNewResultsInfo = useCallback( (resultsInfo: ResultsInfo): void => { let results: Results | null = null; @@ -101,7 +173,8 @@ export function ResultsApp() { statusText = `Error loading results: ${errorMessage}`; } - setState({ + setState((prev) => ({ + ...prev, displayedResults: { resultsInfo, results, @@ -109,7 +182,7 @@ export function ResultsApp() { }, nextResultsInfo: null, isExpectingResultsUpdate: false, - }); + })); }, [], ); @@ -180,9 +253,44 @@ export function ResultsApp() { break; case "untoggleShowProblems": - // noop + setProblemsViewSelected(false); break; + 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, + 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); } @@ -230,6 +338,16 @@ export function ResultsApp() { } queryName={displayedResults.resultsInfo.queryName} queryPath={displayedResults.resultsInfo.queryPath} + selectedTable={state.selectedTable ?? ""} + onSelectedTableChange={onSelectedTableChange} + selectionFilter={selectionFilter} + fileFilteredResults={state.fileFilteredResults} + selectionFilterEnabled={state.selectionFilterEnabled} + onSelectionFilterEnabledChange={(selectionFilterEnabled) => { + setState((prev) => ({ ...prev, selectionFilterEnabled })); + }} + problemsViewSelected={problemsViewSelected} + onProblemsViewSelectedChange={setProblemsViewSelected} /> ); } else { diff --git a/extensions/ql-vscode/src/view/results/SelectionFilterCheckbox.tsx b/extensions/ql-vscode/src/view/results/SelectionFilterCheckbox.tsx new file mode 100644 index 00000000000..e4376faa66f --- /dev/null +++ b/extensions/ql-vscode/src/view/results/SelectionFilterCheckbox.tsx @@ -0,0 +1,31 @@ +import { + alertExtrasClassName, + toggleDiagnosticsClassName, +} from "./result-table-utils"; + +interface Props { + checked: boolean; + onChange: (event: React.ChangeEvent) => void; +} + +export function SelectionFilterCheckbox({ + checked, + onChange, +}: Props): React.JSX.Element { + return ( +
+
+ + +
+
+ ); +} 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. + + )} +
+
+ ); +} 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..114f614fd93 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; @@ -107,3 +117,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), + ); + }); +}