From 061e792a12c911e009e5649de9d339d319973053 Mon Sep 17 00:00:00 2001 From: Tom Hvitved Date: Fri, 8 May 2026 09:45:39 +0200 Subject: [PATCH 1/5] Add new command: 'Go to File in Selected Database' --- extensions/ql-vscode/package.json | 7 ++ extensions/ql-vscode/src/common/commands.ts | 3 + .../src/databases/local-databases-ui.ts | 14 +++ .../databases/source-archive-file-search.ts | 109 ++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 extensions/ql-vscode/src/databases/source-archive-file-search.ts diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index 6e913e6ec71..1b1f98f19a2 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -528,6 +528,10 @@ "command": "codeQL.runQueryContextEditor", "title": "CodeQL: Run Query on Selected Database" }, + { + "command": "codeQL.goToFile", + "title": "CodeQL: Go to File in Selected Database" + }, { "command": "codeQL.runWarmOverlayBaseCacheForQuery", "title": "CodeQL: Warm Overlay-Base Cache for Query" @@ -1874,6 +1878,9 @@ "command": "codeQL.gotoQLContextEditor", "when": "false" }, + { + "command": "codeQL.goToFile" + }, { "command": "codeQL.trimCache" }, diff --git a/extensions/ql-vscode/src/common/commands.ts b/extensions/ql-vscode/src/common/commands.ts index f4126194aa2..00c4129b65f 100644 --- a/extensions/ql-vscode/src/common/commands.ts +++ b/extensions/ql-vscode/src/common/commands.ts @@ -260,6 +260,9 @@ export type LocalDatabasesCommands = { // Internal commands "codeQLDatabases.removeOrphanedDatabases": () => Promise; "codeQL.getCurrentDatabase": () => Promise; + + // Source archive file search + "codeQL.goToFile": () => Promise; }; // Commands tied to variant analysis diff --git a/extensions/ql-vscode/src/databases/local-databases-ui.ts b/extensions/ql-vscode/src/databases/local-databases-ui.ts index 823093bfe49..360501f5fc0 100644 --- a/extensions/ql-vscode/src/databases/local-databases-ui.ts +++ b/extensions/ql-vscode/src/databases/local-databases-ui.ts @@ -45,6 +45,7 @@ import type { QueryRunner } from "../query-server"; import type { App } from "../common/app"; import { redactableError } from "../common/errors"; import type { LocalDatabasesCommands } from "../common/commands"; +import { searchSourceArchiveFiles } from "./source-archive-file-search"; import { createMultiSelectionCommand, createSingleSelectionCommand, @@ -317,9 +318,22 @@ export class DatabaseUI extends DisposableObject { ), "codeQLDatabases.removeOrphanedDatabases": this.handleRemoveOrphanedDatabases.bind(this), + "codeQL.goToFile": this.handleGoToFile.bind(this), }; } + private async handleGoToFile(): Promise { + const currentDb = this.databaseManager.currentDatabaseItem; + if (!currentDb) { + void showAndLogErrorMessage( + this.app.logger, + "No CodeQL database selected. Please select a database first.", + ); + return; + } + await searchSourceArchiveFiles(currentDb); + } + private async handleMakeCurrentDatabase( databaseItem: DatabaseItem, ): Promise { diff --git a/extensions/ql-vscode/src/databases/source-archive-file-search.ts b/extensions/ql-vscode/src/databases/source-archive-file-search.ts new file mode 100644 index 00000000000..635ab2eb8f3 --- /dev/null +++ b/extensions/ql-vscode/src/databases/source-archive-file-search.ts @@ -0,0 +1,109 @@ +import type { QuickPickItem, Uri } from "vscode"; +import { FileType, window, workspace } from "vscode"; +import type { DatabaseItem } from "./local-databases"; +import { + encodeSourceArchiveUri, + decodeSourceArchiveUri, +} from "../common/vscode/archive-filesystem-provider"; + +interface SourceArchiveFileQuickPickItem extends QuickPickItem { + uri: Uri; +} + +/** + * Recursively collects all file URIs from a source archive directory. + */ +async function collectFiles( + dirUri: Uri, + sourceArchiveZipPath: string, + prefix: string, +): Promise { + const entries = await workspace.fs.readDirectory(dirUri); + const items: SourceArchiveFileQuickPickItem[] = []; + + for (const [name, type] of entries) { + const childPath = prefix ? `${prefix}/${name}` : name; + const childUri = encodeSourceArchiveUri({ + sourceArchiveZipPath, + pathWithinSourceArchive: `${decodeSourceArchiveUri(dirUri).pathWithinSourceArchive}/${name}`, + }); + + if (type === FileType.File) { + items.push({ + label: name, + description: prefix, + uri: childUri, + }); + } else if (type === FileType.Directory) { + const subItems = await collectFiles( + childUri, + sourceArchiveZipPath, + childPath, + ); + items.push(...subItems); + } + } + + return items; +} + +/** + * Shows a Quick Pick to search for and open a file from the source archive + * of the given database. + */ +export async function searchSourceArchiveFiles( + databaseItem: DatabaseItem, +): Promise { + let explorerUri: Uri; + try { + explorerUri = databaseItem.getSourceArchiveExplorerUri(); + } catch (e) { + void window.showErrorMessage(e instanceof Error ? e.message : String(e)); + return; + } + const sourceArchiveZipPath = + decodeSourceArchiveUri(explorerUri).sourceArchiveZipPath; + + const quickPick = window.createQuickPick(); + quickPick.placeholder = "Go to File in Selected Database..."; + quickPick.matchOnDescription = true; + quickPick.busy = true; + quickPick.show(); + + try { + const items = await collectFiles(explorerUri, sourceArchiveZipPath, ""); + // Sort items by file name, then by path + items.sort((a, b) => { + const nameCmp = a.label.localeCompare(b.label); + if (nameCmp !== 0) { + return nameCmp; + } + return (a.description ?? "").localeCompare(b.description ?? ""); + }); + quickPick.items = items; + quickPick.busy = false; + } catch (e) { + quickPick.dispose(); + void window.showErrorMessage( + `Failed to read source archive: ${e instanceof Error ? e.message : String(e)}`, + ); + return; + } + + return new Promise((resolve) => { + quickPick.onDidAccept(async () => { + const selected = quickPick.selectedItems[0]; + quickPick.dispose(); + if (selected) { + const doc = await workspace.openTextDocument(selected.uri); + await window.showTextDocument(doc); + } + resolve(); + }); + + quickPick.onDidHide(() => { + quickPick.dispose(); + resolve(); + }); + }); +} From a2d827d34057b21a7b7ebd56eff417a68a343cb4 Mon Sep 17 00:00:00 2001 From: Tom Hvitved Date: Fri, 8 May 2026 10:17:57 +0200 Subject: [PATCH 2/5] Add unit tests for `handleGoToFile` --- .../source-archive-file-search.test.ts | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 extensions/ql-vscode/test/vscode-tests/no-workspace/databases/source-archive-file-search.test.ts diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/source-archive-file-search.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/source-archive-file-search.test.ts new file mode 100644 index 00000000000..ab77c4fdeef --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/source-archive-file-search.test.ts @@ -0,0 +1,106 @@ +import { Uri } from "vscode"; +import { DatabaseUI } from "../../../../src/databases/local-databases-ui"; +import { testDisposeHandler } from "../../test-dispose-handler"; +import { createMockApp } from "../../../__mocks__/appMock"; +import { mockedObject } from "../../utils/mocking.helpers"; +import type { DatabaseFetcher } from "../../../../src/databases/database-fetcher"; +import type { DatabaseItem } from "../../../../src/databases/local-databases"; +import { searchSourceArchiveFiles } from "../../../../src/databases/source-archive-file-search"; + +jest.mock("../../../../src/databases/source-archive-file-search"); +const mockedSearchSourceArchiveFiles = jest.mocked(searchSourceArchiveFiles); + +describe("handleGoToFile", () => { + const app = createMockApp({}); + const storageDir = "/tmp/test-storage"; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("when there is no current database", () => { + const databaseUI = new DatabaseUI( + app, + { + databaseItems: [], + onDidChangeDatabaseItem: () => { + /**/ + }, + onDidChangeCurrentDatabaseItem: () => { + /**/ + }, + setCurrentDatabaseItem: () => {}, + currentDatabaseItem: undefined, + } as any, + mockedObject({}), + { + onLanguageContextChanged: () => { + /**/ + }, + } as any, + {} as any, + storageDir, + storageDir, + ); + + afterAll(() => { + databaseUI.dispose(testDisposeHandler); + }); + + it("should show an error message", async () => { + const commands = databaseUI.getCommands(); + await commands["codeQL.goToFile"](); + + expect(mockedSearchSourceArchiveFiles).not.toHaveBeenCalled(); + expect(app.logger.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("No CodeQL database selected"), + ); + }); + }); + + describe("when there is a current database", () => { + const mockDbItem = mockedObject({ + databaseUri: Uri.file("/test/db"), + name: "test-db", + language: "javascript", + sourceArchive: Uri.file("/test/db/src.zip"), + }); + + const databaseUI = new DatabaseUI( + app, + { + databaseItems: [mockDbItem], + onDidChangeDatabaseItem: () => { + /**/ + }, + onDidChangeCurrentDatabaseItem: () => { + /**/ + }, + setCurrentDatabaseItem: () => {}, + currentDatabaseItem: mockDbItem, + } as any, + mockedObject({}), + { + onLanguageContextChanged: () => { + /**/ + }, + } as any, + {} as any, + storageDir, + storageDir, + ); + + afterAll(() => { + databaseUI.dispose(testDisposeHandler); + }); + + it("should call searchSourceArchiveFiles with the current database", async () => { + mockedSearchSourceArchiveFiles.mockResolvedValue(undefined); + + const commands = databaseUI.getCommands(); + await commands["codeQL.goToFile"](); + + expect(mockedSearchSourceArchiveFiles).toHaveBeenCalledWith(mockDbItem); + }); + }); +}); From dad7f4afcc8ad626d4c1c2a7720bfae10eb9e861 Mon Sep 17 00:00:00 2001 From: Tom Hvitved Date: Mon, 18 May 2026 09:53:46 +0200 Subject: [PATCH 3/5] Address review comments --- .../databases/source-archive-file-search.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/extensions/ql-vscode/src/databases/source-archive-file-search.ts b/extensions/ql-vscode/src/databases/source-archive-file-search.ts index 635ab2eb8f3..5e40d413e8a 100644 --- a/extensions/ql-vscode/src/databases/source-archive-file-search.ts +++ b/extensions/ql-vscode/src/databases/source-archive-file-search.ts @@ -17,9 +17,9 @@ async function collectFiles( dirUri: Uri, sourceArchiveZipPath: string, prefix: string, + items: SourceArchiveFileQuickPickItem[] = [], ): Promise { const entries = await workspace.fs.readDirectory(dirUri); - const items: SourceArchiveFileQuickPickItem[] = []; for (const [name, type] of entries) { const childPath = prefix ? `${prefix}/${name}` : name; @@ -35,12 +35,7 @@ async function collectFiles( uri: childUri, }); } else if (type === FileType.Directory) { - const subItems = await collectFiles( - childUri, - sourceArchiveZipPath, - childPath, - ); - items.push(...subItems); + await collectFiles(childUri, sourceArchiveZipPath, childPath, items); } } @@ -64,6 +59,8 @@ export async function searchSourceArchiveFiles( const sourceArchiveZipPath = decodeSourceArchiveUri(explorerUri).sourceArchiveZipPath; + const filesPromise = collectFiles(explorerUri, sourceArchiveZipPath, ""); + const quickPick = window.createQuickPick(); quickPick.placeholder = "Go to File in Selected Database..."; quickPick.matchOnDescription = true; @@ -71,7 +68,7 @@ export async function searchSourceArchiveFiles( quickPick.show(); try { - const items = await collectFiles(explorerUri, sourceArchiveZipPath, ""); + const items = await filesPromise; // Sort items by file name, then by path items.sort((a, b) => { const nameCmp = a.label.localeCompare(b.label); @@ -94,11 +91,18 @@ export async function searchSourceArchiveFiles( quickPick.onDidAccept(async () => { const selected = quickPick.selectedItems[0]; quickPick.dispose(); - if (selected) { - const doc = await workspace.openTextDocument(selected.uri); - await window.showTextDocument(doc); + try { + if (selected) { + const doc = await workspace.openTextDocument(selected.uri); + await window.showTextDocument(doc); + } + } catch (e) { + void window.showErrorMessage( + `Failed to open source archive file: ${e instanceof Error ? e.message : String(e)}`, + ); + } finally { + resolve(); } - resolve(); }); quickPick.onDidHide(() => { From 2e384c32eeb7468f7dfb77d3def4b069bfbfb1c0 Mon Sep 17 00:00:00 2001 From: Tom Hvitved Date: Mon, 18 May 2026 09:59:10 +0200 Subject: [PATCH 4/5] 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 8801a7e2cf6..ccae6702896 100644 --- a/extensions/ql-vscode/CHANGELOG.md +++ b/extensions/ql-vscode/CHANGELOG.md @@ -4,6 +4,7 @@ - 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) +- Added a new "CodeQL: Go to File in Selected Database" command that allows you to open a file from the source archive of the currently selected database. [#4390](https://github.com/github/vscode-codeql/pull/4390) ## 1.17.7 - 5 December 2025 From 71e36d1b386d73718d2b03bc8affdafc69a6fe22 Mon Sep 17 00:00:00 2001 From: Tom Hvitved Date: Tue, 26 May 2026 09:33:19 +0200 Subject: [PATCH 5/5] Cache source archive list inside `DataBaseItemImpl` --- .../local-databases/database-item-impl.ts | 72 ++++++++++++++++++- .../local-databases/database-item.ts | 16 +++++ .../src/databases/local-databases/index.ts | 2 +- .../databases/source-archive-file-search.ts | 66 +++-------------- 4 files changed, 95 insertions(+), 61 deletions(-) diff --git a/extensions/ql-vscode/src/databases/local-databases/database-item-impl.ts b/extensions/ql-vscode/src/databases/local-databases/database-item-impl.ts index dab6c01d98e..90d2972ab55 100644 --- a/extensions/ql-vscode/src/databases/local-databases/database-item-impl.ts +++ b/extensions/ql-vscode/src/databases/local-databases/database-item-impl.ts @@ -1,6 +1,6 @@ // Exported for testing import type { CodeQLCliServer, DbInfo } from "../../codeql-cli/cli"; -import { Uri, workspace } from "vscode"; +import { FileType, Uri, workspace } from "vscode"; import type { FullDatabaseOptions } from "./database-options"; import { basename, dirname, extname, join } from "path"; import { @@ -9,7 +9,11 @@ import { encodeSourceArchiveUri, zipArchiveScheme, } from "../../common/vscode/archive-filesystem-provider"; -import type { DatabaseItem, PersistedDatabaseItem } from "./database-item"; +import type { + DatabaseItem, + PersistedDatabaseItem, + SourceArchiveFile, +} from "./database-item"; import { isLikelyDatabaseRoot } from "./db-contents-heuristics"; import { stat } from "fs-extra"; import { containsPath, pathsEqual } from "../../common/files"; @@ -22,6 +26,8 @@ export class DatabaseItemImpl implements DatabaseItem { public contents: DatabaseContents | undefined; /** A cache of database info */ private _dbinfo: DbInfo | undefined; + /** A cache of source archive files */ + private _sourceArchiveFiles: SourceArchiveFile[] | undefined; public constructor( public readonly databaseUri: Uri, @@ -234,4 +240,66 @@ export class DatabaseItemImpl implements DatabaseItem { return false; } } + + public async getSourceArchiveFiles(): Promise { + if (this._sourceArchiveFiles === undefined) { + this._sourceArchiveFiles = await this.collectSourceArchiveFiles(); + } + return this._sourceArchiveFiles; + } + + private async collectSourceArchiveFiles(): Promise { + const explorerUri = this.getSourceArchiveExplorerUri(); + const sourceArchiveZipPath = + decodeSourceArchiveUri(explorerUri).sourceArchiveZipPath; + + const items: SourceArchiveFile[] = []; + await this.collectFilesRecursive( + explorerUri, + sourceArchiveZipPath, + "", + items, + ); + // Sort by file name, then by path + items.sort((a, b) => { + const nameCmp = a.name.localeCompare(b.name); + if (nameCmp !== 0) { + return nameCmp; + } + return a.path.localeCompare(b.path); + }); + return items; + } + + private async collectFilesRecursive( + dirUri: Uri, + sourceArchiveZipPath: string, + prefix: string, + items: SourceArchiveFile[], + ): Promise { + const entries = await workspace.fs.readDirectory(dirUri); + + for (const [name, type] of entries) { + const childPath = prefix ? `${prefix}/${name}` : name; + const childUri = encodeSourceArchiveUri({ + sourceArchiveZipPath, + pathWithinSourceArchive: `${decodeSourceArchiveUri(dirUri).pathWithinSourceArchive}/${name}`, + }); + + if (type === FileType.File) { + items.push({ + name, + path: prefix, + uri: childUri, + }); + } else if (type === FileType.Directory) { + await this.collectFilesRecursive( + childUri, + sourceArchiveZipPath, + childPath, + items, + ); + } + } + } } diff --git a/extensions/ql-vscode/src/databases/local-databases/database-item.ts b/extensions/ql-vscode/src/databases/local-databases/database-item.ts index 72f5a37eed7..5c96358cbae 100644 --- a/extensions/ql-vscode/src/databases/local-databases/database-item.ts +++ b/extensions/ql-vscode/src/databases/local-databases/database-item.ts @@ -4,6 +4,16 @@ import type { DatabaseContents } from "./database-contents"; import type { DatabaseOptions } from "./database-options"; import type { DatabaseOrigin } from "./database-origin"; +/** A file entry from the database's source archive. */ +export interface SourceArchiveFile { + /** The file name (basename). */ + name: string; + /** The path prefix (directory path relative to the source archive root). */ + path: string; + /** The URI that can be used to open the file. */ + uri: Uri; +} + /** An item in the list of available databases */ export interface DatabaseItem { /** The URI of the database */ @@ -92,6 +102,12 @@ export interface DatabaseItem { * Verifies that this database item has a zipped source folder. Returns an error message if it does not. */ verifyZippedSources(): string | undefined; + + /** + * Returns all files in the database's source archive. + * The result is lazily computed and cached. + */ + getSourceArchiveFiles(): Promise; } export interface PersistedDatabaseItem { diff --git a/extensions/ql-vscode/src/databases/local-databases/index.ts b/extensions/ql-vscode/src/databases/local-databases/index.ts index fbca66f647c..d426047ae63 100644 --- a/extensions/ql-vscode/src/databases/local-databases/index.ts +++ b/extensions/ql-vscode/src/databases/local-databases/index.ts @@ -4,7 +4,7 @@ export { DatabaseKind, } from "./database-contents"; export { DatabaseChangedEvent, DatabaseEventKind } from "./database-events"; -export { DatabaseItem } from "./database-item"; +export { DatabaseItem, SourceArchiveFile } from "./database-item"; export { DatabaseItemImpl } from "./database-item-impl"; export { DatabaseManager } from "./database-manager"; export { DatabaseResolver } from "./database-resolver"; diff --git a/extensions/ql-vscode/src/databases/source-archive-file-search.ts b/extensions/ql-vscode/src/databases/source-archive-file-search.ts index 5e40d413e8a..cb02afdab16 100644 --- a/extensions/ql-vscode/src/databases/source-archive-file-search.ts +++ b/extensions/ql-vscode/src/databases/source-archive-file-search.ts @@ -1,47 +1,11 @@ import type { QuickPickItem, Uri } from "vscode"; -import { FileType, window, workspace } from "vscode"; +import { window, workspace } from "vscode"; import type { DatabaseItem } from "./local-databases"; -import { - encodeSourceArchiveUri, - decodeSourceArchiveUri, -} from "../common/vscode/archive-filesystem-provider"; interface SourceArchiveFileQuickPickItem extends QuickPickItem { uri: Uri; } -/** - * Recursively collects all file URIs from a source archive directory. - */ -async function collectFiles( - dirUri: Uri, - sourceArchiveZipPath: string, - prefix: string, - items: SourceArchiveFileQuickPickItem[] = [], -): Promise { - const entries = await workspace.fs.readDirectory(dirUri); - - for (const [name, type] of entries) { - const childPath = prefix ? `${prefix}/${name}` : name; - const childUri = encodeSourceArchiveUri({ - sourceArchiveZipPath, - pathWithinSourceArchive: `${decodeSourceArchiveUri(dirUri).pathWithinSourceArchive}/${name}`, - }); - - if (type === FileType.File) { - items.push({ - label: name, - description: prefix, - uri: childUri, - }); - } else if (type === FileType.Directory) { - await collectFiles(childUri, sourceArchiveZipPath, childPath, items); - } - } - - return items; -} - /** * Shows a Quick Pick to search for and open a file from the source archive * of the given database. @@ -49,17 +13,7 @@ async function collectFiles( export async function searchSourceArchiveFiles( databaseItem: DatabaseItem, ): Promise { - let explorerUri: Uri; - try { - explorerUri = databaseItem.getSourceArchiveExplorerUri(); - } catch (e) { - void window.showErrorMessage(e instanceof Error ? e.message : String(e)); - return; - } - const sourceArchiveZipPath = - decodeSourceArchiveUri(explorerUri).sourceArchiveZipPath; - - const filesPromise = collectFiles(explorerUri, sourceArchiveZipPath, ""); + const filesPromise = databaseItem.getSourceArchiveFiles(); const quickPick = window.createQuickPick(); quickPick.placeholder = "Go to File in Selected Database..."; @@ -68,16 +22,12 @@ export async function searchSourceArchiveFiles( quickPick.show(); try { - const items = await filesPromise; - // Sort items by file name, then by path - items.sort((a, b) => { - const nameCmp = a.label.localeCompare(b.label); - if (nameCmp !== 0) { - return nameCmp; - } - return (a.description ?? "").localeCompare(b.description ?? ""); - }); - quickPick.items = items; + const files = await filesPromise; + quickPick.items = files.map((f) => ({ + label: f.name, + description: f.path, + uri: f.uri, + })); quickPick.busy = false; } catch (e) { quickPick.dispose();