diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index 9905eecbf..ad787b172 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -340,6 +340,12 @@ "type": "boolean", "default": false, "description": "Allow database to be downloaded via HTTP. Warning: enabling this option will allow downloading from insecure servers." + }, + "codeQL.createQuery.folder": { + "type": "string", + "default": "", + "patternErrorMessage": "Please enter a valid folder", + "markdownDescription": "The name of the folder where we want to create queries and query packs via the \"CodeQL: Create Query\" command. The folder should exist." } } }, diff --git a/extensions/ql-vscode/src/config.ts b/extensions/ql-vscode/src/config.ts index bd6268a88..1239ec905 100644 --- a/extensions/ql-vscode/src/config.ts +++ b/extensions/ql-vscode/src/config.ts @@ -619,3 +619,19 @@ export const ALLOW_HTTP_SETTING = new Setting( export function allowHttp(): boolean { return ALLOW_HTTP_SETTING.getValue() || false; } + +/** + * The name of the folder where we want to create skeleton wizard QL packs. + **/ +const SKELETON_WIZARD_FOLDER = new Setting( + "folder", + new Setting("createQuery", ROOT_SETTING), +); + +export function getSkeletonWizardFolder(): string | undefined { + return SKELETON_WIZARD_FOLDER.getValue() || undefined; +} + +export async function setSkeletonWizardFolder(folder: string | undefined) { + await SKELETON_WIZARD_FOLDER.updateValue(folder, ConfigurationTarget.Global); +} diff --git a/extensions/ql-vscode/src/skeleton-query-wizard.ts b/extensions/ql-vscode/src/skeleton-query-wizard.ts index 3e29e26bb..baa3178f8 100644 --- a/extensions/ql-vscode/src/skeleton-query-wizard.ts +++ b/extensions/ql-vscode/src/skeleton-query-wizard.ts @@ -14,7 +14,12 @@ import { QlPackGenerator } from "./qlpack-generator"; import { DatabaseItem, DatabaseManager } from "./local-databases"; import { ProgressCallback, UserCancellationException } from "./progress"; import { askForGitHubRepo, downloadGitHubDatabase } from "./databaseFetcher"; -import { existsSync } from "fs"; +import { + getSkeletonWizardFolder, + isCodespacesTemplate, + setSkeletonWizardFolder, +} from "./config"; +import { existsSync } from "fs-extra"; type QueryLanguagesToDatabaseMap = Record; @@ -55,7 +60,7 @@ export class SkeletonQueryWizard { return; } - this.qlPackStoragePath = getFirstWorkspaceFolder(); + this.qlPackStoragePath = await this.determineStoragePath(); const skeletonPackAlreadyExists = existsSync(join(this.qlPackStoragePath, this.folderName)) || @@ -97,6 +102,38 @@ export class SkeletonQueryWizard { }); } + public async determineStoragePath() { + const firstStorageFolder = getFirstWorkspaceFolder(); + + if (isCodespacesTemplate()) { + return firstStorageFolder; + } + + let storageFolder = getSkeletonWizardFolder(); + + if (storageFolder === undefined || !existsSync(storageFolder)) { + storageFolder = await Window.showInputBox({ + title: + "Please choose a folder in which to create your new query pack. You can change this in the extension settings.", + value: firstStorageFolder, + ignoreFocusOut: true, + }); + } + + if (storageFolder === undefined) { + throw new UserCancellationException("No storage folder entered."); + } + + if (!existsSync(storageFolder)) { + throw new UserCancellationException( + "Invalid folder. Must be a folder that already exists.", + ); + } + + await setSkeletonWizardFolder(storageFolder); + return storageFolder; + } + private async chooseLanguage() { this.progress({ message: "Choose language", diff --git a/extensions/ql-vscode/test/vscode-tests/cli-integration/skeleton-query-wizard.test.ts b/extensions/ql-vscode/test/vscode-tests/cli-integration/skeleton-query-wizard.test.ts index e101cbf17..87fbbc1e5 100644 --- a/extensions/ql-vscode/test/vscode-tests/cli-integration/skeleton-query-wizard.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/cli-integration/skeleton-query-wizard.test.ts @@ -21,6 +21,7 @@ import { import * as databaseFetcher from "../../../src/databaseFetcher"; import { createMockDB } from "../../factories/databases/databases"; import { asError } from "../../../src/pure/helpers-pure"; +import { Setting } from "../../../src/config"; describe("SkeletonQueryWizard", () => { let mockCli: CodeQLCliServer; @@ -29,6 +30,7 @@ describe("SkeletonQueryWizard", () => { let dir: tmp.DirResult; let storagePath: string; let quickPickSpy: jest.SpiedFunction; + let showInputBoxSpy: jest.SpiedFunction; let generateSpy: jest.SpiedFunction< typeof QlPackGenerator.prototype.generate >; @@ -93,6 +95,9 @@ describe("SkeletonQueryWizard", () => { quickPickSpy = jest .spyOn(window, "showQuickPick") .mockResolvedValueOnce(mockedQuickPickItem(chosenLanguage)); + showInputBoxSpy = jest + .spyOn(window, "showInputBox") + .mockResolvedValue(storagePath); generateSpy = jest .spyOn(QlPackGenerator.prototype, "generate") .mockResolvedValue(undefined); @@ -433,4 +438,116 @@ describe("SkeletonQueryWizard", () => { }); }); }); + + describe("determineStoragePath", () => { + it("should prompt the user to provide a storage path", async () => { + const chosenPath = await wizard.determineStoragePath(); + + expect(showInputBoxSpy).toHaveBeenCalledWith( + expect.objectContaining({ value: storagePath }), + ); + expect(chosenPath).toEqual(storagePath); + }); + + it("should write the chosen folder to settings", async () => { + const updateValueSpy = jest.spyOn(Setting.prototype, "updateValue"); + + await wizard.determineStoragePath(); + + expect(updateValueSpy).toHaveBeenCalledWith(storagePath, 1); + }); + + describe("when the user is using the codespace template", () => { + let originalValue: any; + let storedPath: string; + + beforeEach(async () => { + storedPath = join(dir.name, "pickles-folder"); + ensureDirSync(storedPath); + + originalValue = workspace + .getConfiguration("codeQL.createQuery") + .get("folder"); + + // Set isCodespacesTemplate to true to indicate we are in the codespace template + await workspace + .getConfiguration("codeQL") + .update("codespacesTemplate", true); + }); + + afterEach(async () => { + await workspace + .getConfiguration("codeQL") + .update("codespacesTemplate", originalValue); + }); + + it("should not prompt the user", async () => { + const chosenPath = await wizard.determineStoragePath(); + + expect(showInputBoxSpy).not.toHaveBeenCalled(); + expect(chosenPath).toEqual(storagePath); + }); + }); + + describe("when there is already a saved storage path in settings", () => { + describe("when the saved storage path exists", () => { + let originalValue: any; + let storedPath: string; + + beforeEach(async () => { + storedPath = join(dir.name, "pickles-folder"); + ensureDirSync(storedPath); + + originalValue = workspace + .getConfiguration("codeQL.createQuery") + .get("folder"); + await workspace + .getConfiguration("codeQL.createQuery") + .update("folder", storedPath); + }); + + afterEach(async () => { + await workspace + .getConfiguration("codeQL.createQuery") + .update("folder", originalValue); + }); + + it("should return it and not prompt the user", async () => { + const chosenPath = await wizard.determineStoragePath(); + + expect(showInputBoxSpy).not.toHaveBeenCalled(); + expect(chosenPath).toEqual(storedPath); + }); + }); + + describe("when the saved storage path does not exist", () => { + let originalValue: any; + let storedPath: string; + + beforeEach(async () => { + storedPath = join(dir.name, "this-folder-does-not-exist"); + + originalValue = workspace + .getConfiguration("codeQL.createQuery") + .get("folder"); + await workspace + .getConfiguration("codeQL.createQuery") + .update("folder", storedPath); + }); + + afterEach(async () => { + await workspace + .getConfiguration("codeQL.createQuery") + .update("folder", originalValue); + }); + + it("should prompt the user for to provide a new folder name", async () => { + const chosenPath = await wizard.determineStoragePath(); + + expect(showInputBoxSpy).toHaveBeenCalled(); + expect(chosenPath).toEqual(storagePath); + }); + }); + }); + }); });