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