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 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/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 new file mode 100644 index 00000000000..cb02afdab16 --- /dev/null +++ b/extensions/ql-vscode/src/databases/source-archive-file-search.ts @@ -0,0 +1,63 @@ +import type { QuickPickItem, Uri } from "vscode"; +import { window, workspace } from "vscode"; +import type { DatabaseItem } from "./local-databases"; + +interface SourceArchiveFileQuickPickItem extends QuickPickItem { + uri: Uri; +} + +/** + * 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 { + const filesPromise = databaseItem.getSourceArchiveFiles(); + + const quickPick = window.createQuickPick(); + quickPick.placeholder = "Go to File in Selected Database..."; + quickPick.matchOnDescription = true; + quickPick.busy = true; + quickPick.show(); + + try { + 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(); + 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(); + 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(); + } + }); + + quickPick.onDidHide(() => { + quickPick.dispose(); + resolve(); + }); + }); +} 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); + }); + }); +});