Skip to content

Commit c737d88

Browse files
authored
Merge pull request #4390 from hvitved/go-to-file-selected-db
Add new command: 'Go to File in Selected Database'
2 parents f6de54e + 71e36d1 commit c737d88

9 files changed

Lines changed: 281 additions & 3 deletions

File tree

extensions/ql-vscode/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Remove support for CodeQL CLI versions older than 2.22.4. [#4344](https://github.com/github/vscode-codeql/pull/4344)
66
- 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)
7+
- 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)
78

89
## 1.17.7 - 5 December 2025
910

extensions/ql-vscode/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,10 @@
528528
"command": "codeQL.runQueryContextEditor",
529529
"title": "CodeQL: Run Query on Selected Database"
530530
},
531+
{
532+
"command": "codeQL.goToFile",
533+
"title": "CodeQL: Go to File in Selected Database"
534+
},
531535
{
532536
"command": "codeQL.runWarmOverlayBaseCacheForQuery",
533537
"title": "CodeQL: Warm Overlay-Base Cache for Query"
@@ -1874,6 +1878,9 @@
18741878
"command": "codeQL.gotoQLContextEditor",
18751879
"when": "false"
18761880
},
1881+
{
1882+
"command": "codeQL.goToFile"
1883+
},
18771884
{
18781885
"command": "codeQL.trimCache"
18791886
},

extensions/ql-vscode/src/common/commands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,9 @@ export type LocalDatabasesCommands = {
260260
// Internal commands
261261
"codeQLDatabases.removeOrphanedDatabases": () => Promise<void>;
262262
"codeQL.getCurrentDatabase": () => Promise<string | undefined>;
263+
264+
// Source archive file search
265+
"codeQL.goToFile": () => Promise<void>;
263266
};
264267

265268
// Commands tied to variant analysis

extensions/ql-vscode/src/databases/local-databases-ui.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import type { QueryRunner } from "../query-server";
4545
import type { App } from "../common/app";
4646
import { redactableError } from "../common/errors";
4747
import type { LocalDatabasesCommands } from "../common/commands";
48+
import { searchSourceArchiveFiles } from "./source-archive-file-search";
4849
import {
4950
createMultiSelectionCommand,
5051
createSingleSelectionCommand,
@@ -317,9 +318,22 @@ export class DatabaseUI extends DisposableObject {
317318
),
318319
"codeQLDatabases.removeOrphanedDatabases":
319320
this.handleRemoveOrphanedDatabases.bind(this),
321+
"codeQL.goToFile": this.handleGoToFile.bind(this),
320322
};
321323
}
322324

325+
private async handleGoToFile(): Promise<void> {
326+
const currentDb = this.databaseManager.currentDatabaseItem;
327+
if (!currentDb) {
328+
void showAndLogErrorMessage(
329+
this.app.logger,
330+
"No CodeQL database selected. Please select a database first.",
331+
);
332+
return;
333+
}
334+
await searchSourceArchiveFiles(currentDb);
335+
}
336+
323337
private async handleMakeCurrentDatabase(
324338
databaseItem: DatabaseItem,
325339
): Promise<void> {

extensions/ql-vscode/src/databases/local-databases/database-item-impl.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Exported for testing
22
import type { CodeQLCliServer, DbInfo } from "../../codeql-cli/cli";
3-
import { Uri, workspace } from "vscode";
3+
import { FileType, Uri, workspace } from "vscode";
44
import type { FullDatabaseOptions } from "./database-options";
55
import { basename, dirname, extname, join } from "path";
66
import {
@@ -9,7 +9,11 @@ import {
99
encodeSourceArchiveUri,
1010
zipArchiveScheme,
1111
} from "../../common/vscode/archive-filesystem-provider";
12-
import type { DatabaseItem, PersistedDatabaseItem } from "./database-item";
12+
import type {
13+
DatabaseItem,
14+
PersistedDatabaseItem,
15+
SourceArchiveFile,
16+
} from "./database-item";
1317
import { isLikelyDatabaseRoot } from "./db-contents-heuristics";
1418
import { stat } from "fs-extra";
1519
import { containsPath, pathsEqual } from "../../common/files";
@@ -22,6 +26,8 @@ export class DatabaseItemImpl implements DatabaseItem {
2226
public contents: DatabaseContents | undefined;
2327
/** A cache of database info */
2428
private _dbinfo: DbInfo | undefined;
29+
/** A cache of source archive files */
30+
private _sourceArchiveFiles: SourceArchiveFile[] | undefined;
2531

2632
public constructor(
2733
public readonly databaseUri: Uri,
@@ -234,4 +240,66 @@ export class DatabaseItemImpl implements DatabaseItem {
234240
return false;
235241
}
236242
}
243+
244+
public async getSourceArchiveFiles(): Promise<SourceArchiveFile[]> {
245+
if (this._sourceArchiveFiles === undefined) {
246+
this._sourceArchiveFiles = await this.collectSourceArchiveFiles();
247+
}
248+
return this._sourceArchiveFiles;
249+
}
250+
251+
private async collectSourceArchiveFiles(): Promise<SourceArchiveFile[]> {
252+
const explorerUri = this.getSourceArchiveExplorerUri();
253+
const sourceArchiveZipPath =
254+
decodeSourceArchiveUri(explorerUri).sourceArchiveZipPath;
255+
256+
const items: SourceArchiveFile[] = [];
257+
await this.collectFilesRecursive(
258+
explorerUri,
259+
sourceArchiveZipPath,
260+
"",
261+
items,
262+
);
263+
// Sort by file name, then by path
264+
items.sort((a, b) => {
265+
const nameCmp = a.name.localeCompare(b.name);
266+
if (nameCmp !== 0) {
267+
return nameCmp;
268+
}
269+
return a.path.localeCompare(b.path);
270+
});
271+
return items;
272+
}
273+
274+
private async collectFilesRecursive(
275+
dirUri: Uri,
276+
sourceArchiveZipPath: string,
277+
prefix: string,
278+
items: SourceArchiveFile[],
279+
): Promise<void> {
280+
const entries = await workspace.fs.readDirectory(dirUri);
281+
282+
for (const [name, type] of entries) {
283+
const childPath = prefix ? `${prefix}/${name}` : name;
284+
const childUri = encodeSourceArchiveUri({
285+
sourceArchiveZipPath,
286+
pathWithinSourceArchive: `${decodeSourceArchiveUri(dirUri).pathWithinSourceArchive}/${name}`,
287+
});
288+
289+
if (type === FileType.File) {
290+
items.push({
291+
name,
292+
path: prefix,
293+
uri: childUri,
294+
});
295+
} else if (type === FileType.Directory) {
296+
await this.collectFilesRecursive(
297+
childUri,
298+
sourceArchiveZipPath,
299+
childPath,
300+
items,
301+
);
302+
}
303+
}
304+
}
237305
}

extensions/ql-vscode/src/databases/local-databases/database-item.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import type { DatabaseContents } from "./database-contents";
44
import type { DatabaseOptions } from "./database-options";
55
import type { DatabaseOrigin } from "./database-origin";
66

7+
/** A file entry from the database's source archive. */
8+
export interface SourceArchiveFile {
9+
/** The file name (basename). */
10+
name: string;
11+
/** The path prefix (directory path relative to the source archive root). */
12+
path: string;
13+
/** The URI that can be used to open the file. */
14+
uri: Uri;
15+
}
16+
717
/** An item in the list of available databases */
818
export interface DatabaseItem {
919
/** The URI of the database */
@@ -92,6 +102,12 @@ export interface DatabaseItem {
92102
* Verifies that this database item has a zipped source folder. Returns an error message if it does not.
93103
*/
94104
verifyZippedSources(): string | undefined;
105+
106+
/**
107+
* Returns all files in the database's source archive.
108+
* The result is lazily computed and cached.
109+
*/
110+
getSourceArchiveFiles(): Promise<SourceArchiveFile[]>;
95111
}
96112

97113
export interface PersistedDatabaseItem {

extensions/ql-vscode/src/databases/local-databases/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export {
44
DatabaseKind,
55
} from "./database-contents";
66
export { DatabaseChangedEvent, DatabaseEventKind } from "./database-events";
7-
export { DatabaseItem } from "./database-item";
7+
export { DatabaseItem, SourceArchiveFile } from "./database-item";
88
export { DatabaseItemImpl } from "./database-item-impl";
99
export { DatabaseManager } from "./database-manager";
1010
export { DatabaseResolver } from "./database-resolver";
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { QuickPickItem, Uri } from "vscode";
2+
import { window, workspace } from "vscode";
3+
import type { DatabaseItem } from "./local-databases";
4+
5+
interface SourceArchiveFileQuickPickItem extends QuickPickItem {
6+
uri: Uri;
7+
}
8+
9+
/**
10+
* Shows a Quick Pick to search for and open a file from the source archive
11+
* of the given database.
12+
*/
13+
export async function searchSourceArchiveFiles(
14+
databaseItem: DatabaseItem,
15+
): Promise<void> {
16+
const filesPromise = databaseItem.getSourceArchiveFiles();
17+
18+
const quickPick = window.createQuickPick<SourceArchiveFileQuickPickItem>();
19+
quickPick.placeholder = "Go to File in Selected Database...";
20+
quickPick.matchOnDescription = true;
21+
quickPick.busy = true;
22+
quickPick.show();
23+
24+
try {
25+
const files = await filesPromise;
26+
quickPick.items = files.map((f) => ({
27+
label: f.name,
28+
description: f.path,
29+
uri: f.uri,
30+
}));
31+
quickPick.busy = false;
32+
} catch (e) {
33+
quickPick.dispose();
34+
void window.showErrorMessage(
35+
`Failed to read source archive: ${e instanceof Error ? e.message : String(e)}`,
36+
);
37+
return;
38+
}
39+
40+
return new Promise<void>((resolve) => {
41+
quickPick.onDidAccept(async () => {
42+
const selected = quickPick.selectedItems[0];
43+
quickPick.dispose();
44+
try {
45+
if (selected) {
46+
const doc = await workspace.openTextDocument(selected.uri);
47+
await window.showTextDocument(doc);
48+
}
49+
} catch (e) {
50+
void window.showErrorMessage(
51+
`Failed to open source archive file: ${e instanceof Error ? e.message : String(e)}`,
52+
);
53+
} finally {
54+
resolve();
55+
}
56+
});
57+
58+
quickPick.onDidHide(() => {
59+
quickPick.dispose();
60+
resolve();
61+
});
62+
});
63+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Uri } from "vscode";
2+
import { DatabaseUI } from "../../../../src/databases/local-databases-ui";
3+
import { testDisposeHandler } from "../../test-dispose-handler";
4+
import { createMockApp } from "../../../__mocks__/appMock";
5+
import { mockedObject } from "../../utils/mocking.helpers";
6+
import type { DatabaseFetcher } from "../../../../src/databases/database-fetcher";
7+
import type { DatabaseItem } from "../../../../src/databases/local-databases";
8+
import { searchSourceArchiveFiles } from "../../../../src/databases/source-archive-file-search";
9+
10+
jest.mock("../../../../src/databases/source-archive-file-search");
11+
const mockedSearchSourceArchiveFiles = jest.mocked(searchSourceArchiveFiles);
12+
13+
describe("handleGoToFile", () => {
14+
const app = createMockApp({});
15+
const storageDir = "/tmp/test-storage";
16+
17+
afterEach(() => {
18+
jest.restoreAllMocks();
19+
});
20+
21+
describe("when there is no current database", () => {
22+
const databaseUI = new DatabaseUI(
23+
app,
24+
{
25+
databaseItems: [],
26+
onDidChangeDatabaseItem: () => {
27+
/**/
28+
},
29+
onDidChangeCurrentDatabaseItem: () => {
30+
/**/
31+
},
32+
setCurrentDatabaseItem: () => {},
33+
currentDatabaseItem: undefined,
34+
} as any,
35+
mockedObject<DatabaseFetcher>({}),
36+
{
37+
onLanguageContextChanged: () => {
38+
/**/
39+
},
40+
} as any,
41+
{} as any,
42+
storageDir,
43+
storageDir,
44+
);
45+
46+
afterAll(() => {
47+
databaseUI.dispose(testDisposeHandler);
48+
});
49+
50+
it("should show an error message", async () => {
51+
const commands = databaseUI.getCommands();
52+
await commands["codeQL.goToFile"]();
53+
54+
expect(mockedSearchSourceArchiveFiles).not.toHaveBeenCalled();
55+
expect(app.logger.showErrorMessage).toHaveBeenCalledWith(
56+
expect.stringContaining("No CodeQL database selected"),
57+
);
58+
});
59+
});
60+
61+
describe("when there is a current database", () => {
62+
const mockDbItem = mockedObject<DatabaseItem>({
63+
databaseUri: Uri.file("/test/db"),
64+
name: "test-db",
65+
language: "javascript",
66+
sourceArchive: Uri.file("/test/db/src.zip"),
67+
});
68+
69+
const databaseUI = new DatabaseUI(
70+
app,
71+
{
72+
databaseItems: [mockDbItem],
73+
onDidChangeDatabaseItem: () => {
74+
/**/
75+
},
76+
onDidChangeCurrentDatabaseItem: () => {
77+
/**/
78+
},
79+
setCurrentDatabaseItem: () => {},
80+
currentDatabaseItem: mockDbItem,
81+
} as any,
82+
mockedObject<DatabaseFetcher>({}),
83+
{
84+
onLanguageContextChanged: () => {
85+
/**/
86+
},
87+
} as any,
88+
{} as any,
89+
storageDir,
90+
storageDir,
91+
);
92+
93+
afterAll(() => {
94+
databaseUI.dispose(testDisposeHandler);
95+
});
96+
97+
it("should call searchSourceArchiveFiles with the current database", async () => {
98+
mockedSearchSourceArchiveFiles.mockResolvedValue(undefined);
99+
100+
const commands = databaseUI.getCommands();
101+
await commands["codeQL.goToFile"]();
102+
103+
expect(mockedSearchSourceArchiveFiles).toHaveBeenCalledWith(mockDbItem);
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)