diff --git a/extensions/ql-vscode/CHANGELOG.md b/extensions/ql-vscode/CHANGELOG.md index 538c1713604..8478f79a229 100644 --- a/extensions/ql-vscode/CHANGELOG.md +++ b/extensions/ql-vscode/CHANGELOG.md @@ -2,6 +2,7 @@ ## [UNRELEASED] +- Fix Quick Query failing to start when no workspace is open. - After an extension-managed CodeQL CLI update, the notification now offers to open the CLI release notes instead of the extension log. [#1095](https://github.com/github/vscode-codeql/issues/1095) ## 1.17.8 - 17 July 2026 diff --git a/extensions/ql-vscode/src/local-queries/quick-query-dir.ts b/extensions/ql-vscode/src/local-queries/quick-query-dir.ts new file mode 100644 index 00000000000..b78cc6eecbd --- /dev/null +++ b/extensions/ql-vscode/src/local-queries/quick-query-dir.ts @@ -0,0 +1,12 @@ +import { ensureDir } from "fs-extra"; +import { join } from "path"; +import type { App } from "../common/app"; + +const QUICK_QUERIES_DIR_NAME = "quick-queries"; + +export async function getQuickQueriesDir(app: App): Promise { + const storagePath = app.workspaceStoragePath ?? app.globalStoragePath; + const queriesPath = join(storagePath, QUICK_QUERIES_DIR_NAME); + await ensureDir(queriesPath, { mode: 0o700 }); + return queriesPath; +} diff --git a/extensions/ql-vscode/src/local-queries/quick-query.ts b/extensions/ql-vscode/src/local-queries/quick-query.ts index 8e0c55d885b..88df4591699 100644 --- a/extensions/ql-vscode/src/local-queries/quick-query.ts +++ b/extensions/ql-vscode/src/local-queries/quick-query.ts @@ -1,4 +1,4 @@ -import { ensureDir, writeFile, pathExists, readFile } from "fs-extra"; +import { writeFile, pathExists, readFile } from "fs-extra"; import { dump, load } from "js-yaml"; import { basename, join } from "path"; import { window as Window, workspace, Uri } from "vscode"; @@ -11,11 +11,10 @@ import type { ProgressCallback } from "../common/vscode/progress"; import { UserCancellationException } from "../common/vscode/progress"; import { getErrorMessage } from "../common/helpers-pure"; import { FALLBACK_QLPACK_FILENAME, getQlPackFilePath } from "../common/ql"; -import type { App } from "../common/app"; import type { ExtensionApp } from "../common/vscode/extension-app"; import type { QlPackFile } from "../packaging/qlpack-file"; +import { getQuickQueriesDir } from "./quick-query-dir"; -const QUICK_QUERIES_DIR_NAME = "quick-queries"; const QUICK_QUERY_QUERY_NAME = "quick-query.ql"; const QUICK_QUERY_WORKSPACE_FOLDER_NAME = "Quick Queries"; const QLPACK_FILE_HEADER = "# This is an automatically generated file.\n\n"; @@ -24,16 +23,6 @@ export function isQuickQueryPath(queryPath: string): boolean { return basename(queryPath) === QUICK_QUERY_QUERY_NAME; } -async function getQuickQueriesDir(app: App): Promise { - const storagePath = app.workspaceStoragePath; - if (storagePath === undefined) { - throw new Error("Workspace storage path is undefined"); - } - const queriesPath = join(storagePath, QUICK_QUERIES_DIR_NAME); - await ensureDir(queriesPath, { mode: 0o700 }); - return queriesPath; -} - function updateQuickQueryDir(queriesDir: string, index: number, len: number) { workspace.updateWorkspaceFolders(index, len, { uri: Uri.file(queriesDir), diff --git a/extensions/ql-vscode/test/unit-tests/local-queries/quick-query.test.ts b/extensions/ql-vscode/test/unit-tests/local-queries/quick-query.test.ts new file mode 100644 index 00000000000..62f6bd69897 --- /dev/null +++ b/extensions/ql-vscode/test/unit-tests/local-queries/quick-query.test.ts @@ -0,0 +1,64 @@ +import type { DirResult } from "tmp"; +import { dirSync } from "tmp"; +import { pathExists, readFile, writeFile } from "fs-extra"; +import { join } from "path"; +import { createMockApp } from "../../__mocks__/appMock"; +import { getQuickQueriesDir } from "../../../src/local-queries/quick-query-dir"; + +describe("getQuickQueriesDir", () => { + let dir: DirResult; + + beforeEach(() => { + dir = dirSync({ + unsafeCleanup: true, + }); + }); + + afterEach(() => { + dir.removeCallback(); + }); + + it("uses global storage when no workspace is open", async () => { + const app = { + ...createMockApp({ globalStoragePath: dir.name }), + workspaceStoragePath: undefined, + }; + + const quickQueriesDir = await getQuickQueriesDir(app); + + expect(quickQueriesDir).toBe(join(dir.name, "quick-queries")); + expect(await pathExists(quickQueriesDir)).toBe(true); + }); + + it("preserves existing query files across repeated calls", async () => { + const app = createMockApp({ workspaceStoragePath: dir.name }); + const quickQueriesDir = await getQuickQueriesDir(app); + const queryPath = join(quickQueriesDir, "quick-query.ql"); + await writeFile(queryPath, "select 1"); + + expect(await getQuickQueriesDir(app)).toBe(quickQueriesDir); + expect(await readFile(queryPath, "utf8")).toBe("select 1"); + }); + + it("propagates an error when the storage path is a file", async () => { + const storagePath = join(dir.name, "not-a-directory"); + await writeFile(storagePath, "existing file"); + const app = createMockApp({ workspaceStoragePath: storagePath }); + + await expect(getQuickQueriesDir(app)).rejects.toThrow(); + expect(await readFile(storagePath, "utf8")).toBe("existing file"); + }); + + it("prefers workspace storage when a workspace is open", async () => { + const workspaceStoragePath = join(dir.name, "workspace-storage"); + const app = createMockApp({ + workspaceStoragePath, + globalStoragePath: dir.name, + }); + + const quickQueriesDir = await getQuickQueriesDir(app); + + expect(quickQueriesDir).toBe(join(workspaceStoragePath, "quick-queries")); + expect(await pathExists(quickQueriesDir)).toBe(true); + }); +}); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/local-queries/quick-query.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/local-queries/quick-query.test.ts new file mode 100644 index 00000000000..a02bee5cd62 --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/local-queries/quick-query.test.ts @@ -0,0 +1,48 @@ +import { window, workspace } from "vscode"; +import { dirSync } from "tmp"; +import type { CodeQLCliServer } from "../../../../src/codeql-cli/cli"; +import type { ExtensionApp } from "../../../../src/common/vscode/extension-app"; +import type { DatabaseUI } from "../../../../src/databases/local-databases-ui"; +import { displayQuickQuery } from "../../../../src/local-queries/quick-query"; +import { createMockApp } from "../../../__mocks__/appMock"; +import { mockedObject } from "../../utils/mocking.helpers"; + +describe("Quick Query without a workspace", () => { + it("shows the existing warning instead of a storage-path error", async () => { + const dir = dirSync({ unsafeCleanup: true }); + const warning = jest + .spyOn(window, "showWarningMessage") + .mockResolvedValue(undefined); + + try { + expect(workspace.workspaceFile).toBeUndefined(); + expect(workspace.workspaceFolders ?? []).toHaveLength(0); + + const app = mockedObject({ + ...createMockApp({ globalStoragePath: dir.name }), + workspaceStoragePath: undefined, + }); + + await displayQuickQuery( + app, + mockedObject({}), + mockedObject({}), + jest.fn(), + ); + + expect(warning).toHaveBeenCalledWith( + '"Quick query" requires reloading your workspace as a multi-root workspace, which may cause query history and databases to be lost.', + { + modal: true, + detail: + 'The "Create query" command does not require reloading the workspace.', + }, + 'Run "Create query"', + 'Run "Quick query" anyway', + ); + } finally { + warning.mockRestore(); + dir.removeCallback(); + } + }); +});