diff --git a/extensions/ql-vscode/src/code-tour.ts b/extensions/ql-vscode/src/code-tour.ts new file mode 100644 index 00000000000..a2faa818d1b --- /dev/null +++ b/extensions/ql-vscode/src/code-tour.ts @@ -0,0 +1,55 @@ +import { AppCommandManager } from "./common/commands"; +import { Uri, workspace } from "vscode"; +import { join } from "path"; +import { pathExists } from "fs-extra"; +import { isCodespacesTemplate } from "./config"; +import { showBinaryChoiceDialog } from "./common/vscode/dialog"; +import { extLogger } from "./common"; + +/** + * Check if the current workspace is the CodeTour and open the workspace folder. + * Without this, we can't run the code tour correctly. + **/ +export async function prepareCodeTour( + commandManager: AppCommandManager, +): Promise { + if (workspace.workspaceFolders?.length) { + const currentFolder = workspace.workspaceFolders[0].uri.fsPath; + + const tutorialWorkspacePath = join( + currentFolder, + "tutorial.code-workspace", + ); + const toursFolderPath = join(currentFolder, ".tours"); + + /** We're opening the tutorial workspace, if we detect it. + * This will only happen if the following three conditions are met: + * - the .tours folder exists + * - the tutorial.code-workspace file exists + * - the CODESPACES_TEMPLATE setting doesn't exist (it's only set if the user has already opened + * the tutorial workspace so it's a good indicator that the user is in the folder but has ignored + * the prompt to open the workspace) + */ + if ( + (await pathExists(tutorialWorkspacePath)) && + (await pathExists(toursFolderPath)) && + !isCodespacesTemplate() + ) { + const answer = await showBinaryChoiceDialog( + "We've detected you're in the CodeQL Tour repo. We will need to open the workspace file to continue. Reload?", + ); + + if (!answer) { + return; + } + + const tutorialWorkspaceUri = Uri.file(tutorialWorkspacePath); + + void extLogger.log( + `In prepareCodeTour() method, going to open the tutorial workspace file: ${tutorialWorkspacePath}`, + ); + + await commandManager.execute("vscode.openFolder", tutorialWorkspaceUri); + } + } +} diff --git a/extensions/ql-vscode/src/common/vscode/dialog.ts b/extensions/ql-vscode/src/common/vscode/dialog.ts new file mode 100644 index 00000000000..b6ad555fe0d --- /dev/null +++ b/extensions/ql-vscode/src/common/vscode/dialog.ts @@ -0,0 +1,135 @@ +import { env, Uri, window } from "vscode"; + +/** + * Opens a modal dialog for the user to make a yes/no choice. + * + * @param message The message to show. + * @param modal If true (the default), show a modal dialog box, otherwise dialog is non-modal and can + * be closed even if the user does not make a choice. + * @param yesTitle The text in the box indicating the affirmative choice. + * @param noTitle The text in the box indicating the negative choice. + * + * @return + * `true` if the user clicks 'Yes', + * `false` if the user clicks 'No' or cancels the dialog, + * `undefined` if the dialog is closed without the user making a choice. + */ +export async function showBinaryChoiceDialog( + message: string, + modal = true, + yesTitle = "Yes", + noTitle = "No", +): Promise { + const yesItem = { title: yesTitle, isCloseAffordance: false }; + const noItem = { title: noTitle, isCloseAffordance: true }; + const chosenItem = await window.showInformationMessage( + message, + { modal }, + yesItem, + noItem, + ); + if (!chosenItem) { + return undefined; + } + return chosenItem?.title === yesItem.title; +} + +/** + * Opens a modal dialog for the user to make a yes/no choice. + * + * @param message The message to show. + * @param modal If true (the default), show a modal dialog box, otherwise dialog is non-modal and can + * be closed even if the user does not make a choice. + * + * @return + * `true` if the user clicks 'Yes', + * `false` if the user clicks 'No' or cancels the dialog, + * `undefined` if the dialog is closed without the user making a choice. + */ +export async function showBinaryChoiceWithUrlDialog( + message: string, + url: string, +): Promise { + const urlItem = { title: "More Information", isCloseAffordance: false }; + const yesItem = { title: "Yes", isCloseAffordance: false }; + const noItem = { title: "No", isCloseAffordance: true }; + let chosenItem; + + // Keep the dialog open as long as the user is clicking the 'more information' option. + // To prevent an infinite loop, if the user clicks 'more information' 5 times, close the dialog and return cancelled + let count = 0; + do { + chosenItem = await window.showInformationMessage( + message, + { modal: true }, + urlItem, + yesItem, + noItem, + ); + if (chosenItem === urlItem) { + await env.openExternal(Uri.parse(url, true)); + } + count++; + } while (chosenItem === urlItem && count < 5); + + if (!chosenItem || chosenItem.title === urlItem.title) { + return undefined; + } + return chosenItem.title === yesItem.title; +} + +/** + * Show an information message with a customisable action. + * @param message The message to show. + * @param actionMessage The call to action message. + * + * @return `true` if the user clicks the action, `false` if the user cancels the dialog. + */ +export async function showInformationMessageWithAction( + message: string, + actionMessage: string, +): Promise { + const actionItem = { title: actionMessage, isCloseAffordance: false }; + const chosenItem = await window.showInformationMessage(message, actionItem); + return chosenItem === actionItem; +} + +/** + * Opens a modal dialog for the user to make a choice between yes/no/never be asked again. + * + * @param message The message to show. + * @param modal If true (the default), show a modal dialog box, otherwise dialog is non-modal and can + * be closed even if the user does not make a choice. + * @param yesTitle The text in the box indicating the affirmative choice. + * @param noTitle The text in the box indicating the negative choice. + * @param neverTitle The text in the box indicating the opt out choice. + * + * @return + * `Yes` if the user clicks 'Yes', + * `No` if the user clicks 'No' or cancels the dialog, + * `No, and never ask me again` if the user clicks 'No, and never ask me again', + * `undefined` if the dialog is closed without the user making a choice. + */ +export async function showNeverAskAgainDialog( + message: string, + modal = true, + yesTitle = "Yes", + noTitle = "No", + neverAskAgainTitle = "No, and never ask me again", +): Promise { + const yesItem = { title: yesTitle, isCloseAffordance: true }; + const noItem = { title: noTitle, isCloseAffordance: false }; + const neverAskAgainItem = { + title: neverAskAgainTitle, + isCloseAffordance: false, + }; + const chosenItem = await window.showInformationMessage( + message, + { modal }, + yesItem, + noItem, + neverAskAgainItem, + ); + + return chosenItem?.title; +} diff --git a/extensions/ql-vscode/src/common/vscode/external-files.ts b/extensions/ql-vscode/src/common/vscode/external-files.ts index bf0d17164ee..220b8d20d4c 100644 --- a/extensions/ql-vscode/src/common/vscode/external-files.ts +++ b/extensions/ql-vscode/src/common/vscode/external-files.ts @@ -1,9 +1,7 @@ import { Uri, window } from "vscode"; import { AppCommandManager } from "../commands"; -import { - showAndLogExceptionWithTelemetry, - showBinaryChoiceDialog, -} from "../../helpers"; +import { showAndLogExceptionWithTelemetry } from "../../helpers"; +import { showBinaryChoiceDialog } from "./dialog"; import { redactableError } from "../../pure/errors"; import { asError, diff --git a/extensions/ql-vscode/src/common/vscode/workspace-folders.ts b/extensions/ql-vscode/src/common/vscode/workspace-folders.ts new file mode 100644 index 00000000000..8a1d03601eb --- /dev/null +++ b/extensions/ql-vscode/src/common/vscode/workspace-folders.ts @@ -0,0 +1,64 @@ +import { dirname, join } from "path"; +import { workspace, WorkspaceFolder } from "vscode"; + +/** Returns true if the specified workspace folder is on the file system. */ +export function isWorkspaceFolderOnDisk( + workspaceFolder: WorkspaceFolder, +): boolean { + return workspaceFolder.uri.scheme === "file"; +} + +/** Gets all active workspace folders that are on the filesystem. */ +export function getOnDiskWorkspaceFoldersObjects() { + const workspaceFolders = workspace.workspaceFolders ?? []; + return workspaceFolders.filter(isWorkspaceFolderOnDisk); +} + +/** Gets all active workspace folders that are on the filesystem. */ +export function getOnDiskWorkspaceFolders() { + return getOnDiskWorkspaceFoldersObjects().map((folder) => folder.uri.fsPath); +} + +/** Check if folder is already present in workspace */ +export function isFolderAlreadyInWorkspace(folderName: string) { + const workspaceFolders = workspace.workspaceFolders || []; + + return !!workspaceFolders.find( + (workspaceFolder) => workspaceFolder.name === folderName, + ); +} + +/** + * Returns the path of the first folder in the workspace. + * This is used to decide where to create skeleton QL packs. + * + * If the first folder is a QL pack, then the parent folder is returned. + * This is because the vscode-codeql-starter repo contains a ql pack in + * the first folder. + * + * This is a temporary workaround until we can retire the + * vscode-codeql-starter repo. + */ +export function getFirstWorkspaceFolder() { + const workspaceFolders = getOnDiskWorkspaceFolders(); + + if (!workspaceFolders || workspaceFolders.length === 0) { + throw new Error("No workspace folders found"); + } + + const firstFolderFsPath = workspaceFolders[0]; + + // For the vscode-codeql-starter repo, the first folder will be a ql pack + // so we need to get the parent folder + if ( + firstFolderFsPath.includes( + join("vscode-codeql-starter", "codeql-custom-queries"), + ) + ) { + // return the parent folder + return dirname(firstFolderFsPath); + } else { + // if the first folder is not a ql pack, then we are in a normal workspace + return firstFolderFsPath; + } +} diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts index d6dfb1d67de..1aa451d6c96 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts @@ -4,11 +4,11 @@ import { dump as dumpYaml, load as loadYaml } from "js-yaml"; import { minimatch } from "minimatch"; import { CancellationToken, window } from "vscode"; import { CodeQLCliServer } from "../codeql-cli/cli"; +import { showAndLogErrorMessage } from "../helpers"; import { getOnDiskWorkspaceFolders, getOnDiskWorkspaceFoldersObjects, - showAndLogErrorMessage, -} from "../helpers"; +} from "../common/vscode/workspace-folders"; import { ProgressCallback } from "../common/vscode/progress"; import { DatabaseItem } from "../databases/local-databases"; import { getQlPackPath, QLPACK_FILENAMES } from "../pure/ql"; diff --git a/extensions/ql-vscode/src/data-extensions-editor/external-api-usage-query.ts b/extensions/ql-vscode/src/data-extensions-editor/external-api-usage-query.ts index 69d51394773..933d9c122c7 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/external-api-usage-query.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/external-api-usage-query.ts @@ -2,10 +2,8 @@ import { CoreCompletedQuery, QueryRunner } from "../query-server"; import { dir } from "tmp-promise"; import { writeFile } from "fs-extra"; import { dump as dumpYaml } from "js-yaml"; -import { - getOnDiskWorkspaceFolders, - showAndLogExceptionWithTelemetry, -} from "../helpers"; +import { showAndLogExceptionWithTelemetry } from "../helpers"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { TeeLogger } from "../common"; import { isQueryLanguage } from "../common/query-language"; import { CancellationToken } from "vscode"; diff --git a/extensions/ql-vscode/src/data-extensions-editor/generate-flow-model.ts b/extensions/ql-vscode/src/data-extensions-editor/generate-flow-model.ts index 3b41b9a9617..62444b975b9 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/generate-flow-model.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/generate-flow-model.ts @@ -6,10 +6,8 @@ import { CodeQLCliServer } from "../codeql-cli/cli"; import { TeeLogger } from "../common"; import { extensiblePredicateDefinitions } from "./predicates"; import { ProgressCallback } from "../common/vscode/progress"; -import { - getOnDiskWorkspaceFolders, - showAndLogExceptionWithTelemetry, -} from "../helpers"; +import { showAndLogExceptionWithTelemetry } from "../helpers"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { ModeledMethodType, ModeledMethodWithSignature, diff --git a/extensions/ql-vscode/src/databases/local-databases/database-manager.ts b/extensions/ql-vscode/src/databases/local-databases/database-manager.ts index 68e2b53191b..687a54fba5a 100644 --- a/extensions/ql-vscode/src/databases/local-databases/database-manager.ts +++ b/extensions/ql-vscode/src/databases/local-databases/database-manager.ts @@ -13,12 +13,12 @@ import { import { join } from "path"; import { FullDatabaseOptions } from "./database-options"; import { DatabaseItemImpl } from "./database-item-impl"; +import { showAndLogExceptionWithTelemetry } from "../../helpers"; +import { showNeverAskAgainDialog } from "../../common/vscode/dialog"; import { getFirstWorkspaceFolder, isFolderAlreadyInWorkspace, - showAndLogExceptionWithTelemetry, - showNeverAskAgainDialog, -} from "../../helpers"; +} from "../../common/vscode/workspace-folders"; import { isQueryLanguage } from "../../common/query-language"; import { existsSync } from "fs"; import { QlPackGenerator } from "../../qlpack-generator"; diff --git a/extensions/ql-vscode/src/databases/qlpack.ts b/extensions/ql-vscode/src/databases/qlpack.ts index 357ef49c4f4..1779c6e538c 100644 --- a/extensions/ql-vscode/src/databases/qlpack.ts +++ b/extensions/ql-vscode/src/databases/qlpack.ts @@ -6,7 +6,7 @@ import { readFile } from "fs-extra"; import { getQlPackPath } from "../pure/ql"; import { CodeQLCliServer, QlpacksInfo } from "../codeql-cli/cli"; import { extLogger } from "../common"; -import { getOnDiskWorkspaceFolders } from "../helpers"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; export interface QlPacksForLanguage { /** The name of the pack containing the dbscheme. */ diff --git a/extensions/ql-vscode/src/debugger/debug-configuration.ts b/extensions/ql-vscode/src/debugger/debug-configuration.ts index 4d4a788a7ef..2bd69cdc7a0 100644 --- a/extensions/ql-vscode/src/debugger/debug-configuration.ts +++ b/extensions/ql-vscode/src/debugger/debug-configuration.ts @@ -4,7 +4,8 @@ import { DebugConfigurationProvider, WorkspaceFolder, } from "vscode"; -import { getOnDiskWorkspaceFolders, showAndLogErrorMessage } from "../helpers"; +import { showAndLogErrorMessage } from "../helpers"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { LocalQueries } from "../local-queries"; import { getQuickEvalContext, validateQueryPath } from "../run-queries-shared"; import * as CodeQLProtocol from "./debug-protocol"; diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index da84fe80706..8f6ec551cf0 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -60,12 +60,14 @@ import { showAndLogExceptionWithTelemetry, showAndLogInformationMessage, showAndLogWarningMessage, - showBinaryChoiceDialog, - showInformationMessageWithAction, tmpDir, tmpDirDisposal, - prepareCodeTour, } from "./helpers"; +import { prepareCodeTour } from "./code-tour"; +import { + showBinaryChoiceDialog, + showInformationMessageWithAction, +} from "./common/vscode/dialog"; import { asError, assertNever, diff --git a/extensions/ql-vscode/src/helpers.ts b/extensions/ql-vscode/src/helpers.ts index 1891376d75e..08b0e2e3f57 100644 --- a/extensions/ql-vscode/src/helpers.ts +++ b/extensions/ql-vscode/src/helpers.ts @@ -1,7 +1,7 @@ -import { ensureDirSync, pathExists, ensureDir, writeFile } from "fs-extra"; -import { join, dirname } from "path"; +import { ensureDirSync, ensureDir, writeFile } from "fs-extra"; +import { join } from "path"; import { dirSync } from "tmp-promise"; -import { Uri, window as Window, workspace, env, WorkspaceFolder } from "vscode"; +import { Uri, window as Window } from "vscode"; import { CodeQLCliServer } from "./codeql-cli/cli"; import { UserCancellationException } from "./common/vscode/progress"; import { extLogger, OutputChannelLogger } from "./common"; @@ -9,8 +9,7 @@ import { QueryMetadata } from "./pure/interface-types"; import { telemetryListener } from "./telemetry"; import { RedactableError } from "./pure/errors"; import { isQueryLanguage, QueryLanguage } from "./common/query-language"; -import { isCodespacesTemplate } from "./config"; -import { AppCommandManager } from "./common/commands"; +import { getOnDiskWorkspaceFolders } from "./common/vscode/workspace-folders"; // Shared temporary folder for the extension. export const tmpDir = dirSync({ @@ -138,214 +137,6 @@ async function internalShowAndLog( return result; } -/** - * Opens a modal dialog for the user to make a yes/no choice. - * - * @param message The message to show. - * @param modal If true (the default), show a modal dialog box, otherwise dialog is non-modal and can - * be closed even if the user does not make a choice. - * @param yesTitle The text in the box indicating the affirmative choice. - * @param noTitle The text in the box indicating the negative choice. - * - * @return - * `true` if the user clicks 'Yes', - * `false` if the user clicks 'No' or cancels the dialog, - * `undefined` if the dialog is closed without the user making a choice. - */ -export async function showBinaryChoiceDialog( - message: string, - modal = true, - yesTitle = "Yes", - noTitle = "No", -): Promise { - const yesItem = { title: yesTitle, isCloseAffordance: false }; - const noItem = { title: noTitle, isCloseAffordance: true }; - const chosenItem = await Window.showInformationMessage( - message, - { modal }, - yesItem, - noItem, - ); - if (!chosenItem) { - return undefined; - } - return chosenItem?.title === yesItem.title; -} - -/** - * Opens a modal dialog for the user to make a yes/no choice. - * - * @param message The message to show. - * @param modal If true (the default), show a modal dialog box, otherwise dialog is non-modal and can - * be closed even if the user does not make a choice. - * - * @return - * `true` if the user clicks 'Yes', - * `false` if the user clicks 'No' or cancels the dialog, - * `undefined` if the dialog is closed without the user making a choice. - */ -export async function showBinaryChoiceWithUrlDialog( - message: string, - url: string, -): Promise { - const urlItem = { title: "More Information", isCloseAffordance: false }; - const yesItem = { title: "Yes", isCloseAffordance: false }; - const noItem = { title: "No", isCloseAffordance: true }; - let chosenItem; - - // Keep the dialog open as long as the user is clicking the 'more information' option. - // To prevent an infinite loop, if the user clicks 'more information' 5 times, close the dialog and return cancelled - let count = 0; - do { - chosenItem = await Window.showInformationMessage( - message, - { modal: true }, - urlItem, - yesItem, - noItem, - ); - if (chosenItem === urlItem) { - await env.openExternal(Uri.parse(url, true)); - } - count++; - } while (chosenItem === urlItem && count < 5); - - if (!chosenItem || chosenItem.title === urlItem.title) { - return undefined; - } - return chosenItem.title === yesItem.title; -} - -/** - * Show an information message with a customisable action. - * @param message The message to show. - * @param actionMessage The call to action message. - * - * @return `true` if the user clicks the action, `false` if the user cancels the dialog. - */ -export async function showInformationMessageWithAction( - message: string, - actionMessage: string, -): Promise { - const actionItem = { title: actionMessage, isCloseAffordance: false }; - const chosenItem = await Window.showInformationMessage(message, actionItem); - return chosenItem === actionItem; -} - -/** Returns true if the specified workspace folder is on the file system. */ -export function isWorkspaceFolderOnDisk( - workspaceFolder: WorkspaceFolder, -): boolean { - return workspaceFolder.uri.scheme === "file"; -} - -/** - * Opens a modal dialog for the user to make a choice between yes/no/never be asked again. - * - * @param message The message to show. - * @param modal If true (the default), show a modal dialog box, otherwise dialog is non-modal and can - * be closed even if the user does not make a choice. - * @param yesTitle The text in the box indicating the affirmative choice. - * @param noTitle The text in the box indicating the negative choice. - * @param neverTitle The text in the box indicating the opt out choice. - * - * @return - * `Yes` if the user clicks 'Yes', - * `No` if the user clicks 'No' or cancels the dialog, - * `No, and never ask me again` if the user clicks 'No, and never ask me again', - * `undefined` if the dialog is closed without the user making a choice. - */ -export async function showNeverAskAgainDialog( - message: string, - modal = true, - yesTitle = "Yes", - noTitle = "No", - neverAskAgainTitle = "No, and never ask me again", -): Promise { - const yesItem = { title: yesTitle, isCloseAffordance: true }; - const noItem = { title: noTitle, isCloseAffordance: false }; - const neverAskAgainItem = { - title: neverAskAgainTitle, - isCloseAffordance: false, - }; - const chosenItem = await Window.showInformationMessage( - message, - { modal }, - yesItem, - noItem, - neverAskAgainItem, - ); - - return chosenItem?.title; -} - -/** Gets all active workspace folders that are on the filesystem. */ -export function getOnDiskWorkspaceFoldersObjects() { - const workspaceFolders = workspace.workspaceFolders ?? []; - return workspaceFolders.filter(isWorkspaceFolderOnDisk); -} - -/** Gets all active workspace folders that are on the filesystem. */ -export function getOnDiskWorkspaceFolders() { - return getOnDiskWorkspaceFoldersObjects().map((folder) => folder.uri.fsPath); -} - -/** Check if folder is already present in workspace */ -export function isFolderAlreadyInWorkspace(folderName: string) { - const workspaceFolders = workspace.workspaceFolders || []; - - return !!workspaceFolders.find( - (workspaceFolder) => workspaceFolder.name === folderName, - ); -} - -/** Check if the current workspace is the CodeTour and open the workspace folder. - * Without this, we can't run the code tour correctly. - **/ -export async function prepareCodeTour( - commandManager: AppCommandManager, -): Promise { - if (workspace.workspaceFolders?.length) { - const currentFolder = workspace.workspaceFolders[0].uri.fsPath; - - const tutorialWorkspacePath = join( - currentFolder, - "tutorial.code-workspace", - ); - const toursFolderPath = join(currentFolder, ".tours"); - - /** We're opening the tutorial workspace, if we detect it. - * This will only happen if the following three conditions are met: - * - the .tours folder exists - * - the tutorial.code-workspace file exists - * - the CODESPACES_TEMPLATE setting doesn't exist (it's only set if the user has already opened - * the tutorial workspace so it's a good indicator that the user is in the folder but has ignored - * the prompt to open the workspace) - */ - if ( - (await pathExists(tutorialWorkspacePath)) && - (await pathExists(toursFolderPath)) && - !isCodespacesTemplate() - ) { - const answer = await showBinaryChoiceDialog( - "We've detected you're in the CodeQL Tour repo. We will need to open the workspace file to continue. Reload?", - ); - - if (!answer) { - return; - } - - const tutorialWorkspaceUri = Uri.file(tutorialWorkspacePath); - - void extLogger.log( - `In prepareCodeTour() method, going to open the tutorial workspace file: ${tutorialWorkspacePath}`, - ); - - await commandManager.execute("vscode.openFolder", tutorialWorkspaceUri); - } - } -} - /** * Finds the language that a query targets. * If it can't be autodetected, prompt the user to specify the language manually. @@ -448,39 +239,3 @@ export async function createTimestampFile(storagePath: string) { await ensureDir(storagePath); await writeFile(timestampPath, Date.now().toString(), "utf8"); } - -/** - * Returns the path of the first folder in the workspace. - * This is used to decide where to create skeleton QL packs. - * - * If the first folder is a QL pack, then the parent folder is returned. - * This is because the vscode-codeql-starter repo contains a ql pack in - * the first folder. - * - * This is a temporary workaround until we can retire the - * vscode-codeql-starter repo. - */ - -export function getFirstWorkspaceFolder() { - const workspaceFolders = getOnDiskWorkspaceFolders(); - - if (!workspaceFolders || workspaceFolders.length === 0) { - throw new Error("No workspace folders found"); - } - - const firstFolderFsPath = workspaceFolders[0]; - - // For the vscode-codeql-starter repo, the first folder will be a ql pack - // so we need to get the parent folder - if ( - firstFolderFsPath.includes( - join("vscode-codeql-starter", "codeql-custom-queries"), - ) - ) { - // return the parent folder - return dirname(firstFolderFsPath); - } else { - // if the first folder is not a ql pack, then we are in a normal workspace - return firstFolderFsPath; - } -} diff --git a/extensions/ql-vscode/src/language-support/contextual/query-resolver.ts b/extensions/ql-vscode/src/language-support/contextual/query-resolver.ts index d5c79fde7dc..30432dc9109 100644 --- a/extensions/ql-vscode/src/language-support/contextual/query-resolver.ts +++ b/extensions/ql-vscode/src/language-support/contextual/query-resolver.ts @@ -3,10 +3,8 @@ import { dump } from "js-yaml"; import { file } from "tmp-promise"; import { basename, dirname, resolve } from "path"; -import { - getOnDiskWorkspaceFolders, - showAndLogExceptionWithTelemetry, -} from "../../helpers"; +import { showAndLogExceptionWithTelemetry } from "../../helpers"; +import { getOnDiskWorkspaceFolders } from "../../common/vscode/workspace-folders"; import { getPrimaryDbscheme, getQlPackForDbscheme, diff --git a/extensions/ql-vscode/src/local-queries/local-queries.ts b/extensions/ql-vscode/src/local-queries/local-queries.ts index 835c0c21b7b..bd629ff2885 100644 --- a/extensions/ql-vscode/src/local-queries/local-queries.ts +++ b/extensions/ql-vscode/src/local-queries/local-queries.ts @@ -19,11 +19,11 @@ import { basename } from "path"; import { createTimestampFile, findLanguage, - getOnDiskWorkspaceFolders, showAndLogErrorMessage, showAndLogWarningMessage, - showBinaryChoiceDialog, } from "../helpers"; +import { showBinaryChoiceDialog } from "../common/vscode/dialog"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { displayQuickQuery } from "./quick-query"; import { CoreCompletedQuery, QueryRunner } from "../query-server"; import { QueryHistoryManager } from "../query-history/query-history-manager"; diff --git a/extensions/ql-vscode/src/local-queries/quick-query.ts b/extensions/ql-vscode/src/local-queries/quick-query.ts index bf52685408a..041a3346eec 100644 --- a/extensions/ql-vscode/src/local-queries/quick-query.ts +++ b/extensions/ql-vscode/src/local-queries/quick-query.ts @@ -5,7 +5,7 @@ import { CancellationToken, window as Window, workspace, Uri } from "vscode"; import { LSPErrorCodes, ResponseError } from "vscode-languageclient"; import { CodeQLCliServer } from "../codeql-cli/cli"; import { DatabaseUI } from "../databases/local-databases-ui"; -import { showBinaryChoiceDialog } from "../helpers"; +import { showBinaryChoiceDialog } from "../common/vscode/dialog"; import { getInitialQueryContents } from "./query-contents"; import { getPrimaryDbscheme, getQlPackForDbscheme } from "../databases/qlpack"; import { diff --git a/extensions/ql-vscode/src/packaging/packaging.ts b/extensions/ql-vscode/src/packaging/packaging.ts index 91f350b5602..56091e0e985 100644 --- a/extensions/ql-vscode/src/packaging/packaging.ts +++ b/extensions/ql-vscode/src/packaging/packaging.ts @@ -1,9 +1,9 @@ import { CodeQLCliServer } from "../codeql-cli/cli"; import { - getOnDiskWorkspaceFolders, showAndLogExceptionWithTelemetry, showAndLogInformationMessage, } from "../helpers"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { QuickPickItem, window } from "vscode"; import { ProgressCallback, diff --git a/extensions/ql-vscode/src/queries-panel/query-discovery.ts b/extensions/ql-vscode/src/queries-panel/query-discovery.ts index 43bfd295587..6a63f2d2912 100644 --- a/extensions/ql-vscode/src/queries-panel/query-discovery.ts +++ b/extensions/ql-vscode/src/queries-panel/query-discovery.ts @@ -5,7 +5,7 @@ import { Event, RelativePattern, Uri, WorkspaceFolder } from "vscode"; import { MultiFileSystemWatcher } from "../common/vscode/multi-file-system-watcher"; import { App } from "../common/app"; import { FileTreeDirectory, FileTreeLeaf } from "../common/file-tree-nodes"; -import { getOnDiskWorkspaceFoldersObjects } from "../helpers"; +import { getOnDiskWorkspaceFoldersObjects } from "../common/vscode/workspace-folders"; import { AppEventEmitter } from "../common/events"; import { QueryDiscoverer } from "./query-tree-data-provider"; import { extLogger } from "../common"; diff --git a/extensions/ql-vscode/src/query-history/query-history-manager.ts b/extensions/ql-vscode/src/query-history/query-history-manager.ts index 19c27077879..e727a8e2a63 100644 --- a/extensions/ql-vscode/src/query-history/query-history-manager.ts +++ b/extensions/ql-vscode/src/query-history/query-history-manager.ts @@ -17,9 +17,11 @@ import { showAndLogErrorMessage, showAndLogInformationMessage, showAndLogWarningMessage, +} from "../helpers"; +import { showBinaryChoiceDialog, showInformationMessageWithAction, -} from "../helpers"; +} from "../common/vscode/dialog"; import { extLogger } from "../common"; import { URLSearchParams } from "url"; import { DisposableObject } from "../pure/disposable-object"; diff --git a/extensions/ql-vscode/src/query-server/legacy/upgrades.ts b/extensions/ql-vscode/src/query-server/legacy/upgrades.ts index 24b19ba8a10..0767ce38af9 100644 --- a/extensions/ql-vscode/src/query-server/legacy/upgrades.ts +++ b/extensions/ql-vscode/src/query-server/legacy/upgrades.ts @@ -1,9 +1,6 @@ import * as vscode from "vscode"; -import { - getOnDiskWorkspaceFolders, - showAndLogExceptionWithTelemetry, - tmpDir, -} from "../../helpers"; +import { showAndLogExceptionWithTelemetry, tmpDir } from "../../helpers"; +import { getOnDiskWorkspaceFolders } from "../../common/vscode/workspace-folders"; import { ProgressCallback, UserCancellationException, diff --git a/extensions/ql-vscode/src/query-server/new-query-runner.ts b/extensions/ql-vscode/src/query-server/new-query-runner.ts index 88ff4e3e887..9932ce8c435 100644 --- a/extensions/ql-vscode/src/query-server/new-query-runner.ts +++ b/extensions/ql-vscode/src/query-server/new-query-runner.ts @@ -16,7 +16,7 @@ import { CoreQueryResults, CoreQueryTarget, QueryRunner } from "./query-runner"; import { QueryServerClient } from "./query-server-client"; import { compileAndRunQueryAgainstDatabaseCore } from "./run-queries"; import * as vscode from "vscode"; -import { getOnDiskWorkspaceFolders } from "../helpers"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { CodeQLCliServer } from "../codeql-cli/cli"; import { Logger } from "../common"; import { QueryOutputDir } from "../run-queries-shared"; diff --git a/extensions/ql-vscode/src/query-testing/test-manager.ts b/extensions/ql-vscode/src/query-testing/test-manager.ts index 073cc717025..24dc08cda03 100644 --- a/extensions/ql-vscode/src/query-testing/test-manager.ts +++ b/extensions/ql-vscode/src/query-testing/test-manager.ts @@ -23,7 +23,7 @@ import { BaseLogger, LogOptions } from "../common"; import { TestRunner } from "./test-runner"; import { TestManagerBase } from "./test-manager-base"; import { App } from "../common/app"; -import { isWorkspaceFolderOnDisk } from "../helpers"; +import { isWorkspaceFolderOnDisk } from "../common/vscode/workspace-folders"; import { FileTreeDirectory, FileTreeLeaf, diff --git a/extensions/ql-vscode/src/query-testing/test-runner.ts b/extensions/ql-vscode/src/query-testing/test-runner.ts index 1dde5ca4f8b..9f3104e5b2a 100644 --- a/extensions/ql-vscode/src/query-testing/test-runner.ts +++ b/extensions/ql-vscode/src/query-testing/test-runner.ts @@ -2,10 +2,10 @@ import { CancellationToken, Uri } from "vscode"; import { CodeQLCliServer, TestCompleted } from "../codeql-cli/cli"; import { DatabaseItem, DatabaseManager } from "../databases/local-databases"; import { - getOnDiskWorkspaceFolders, showAndLogExceptionWithTelemetry, showAndLogWarningMessage, } from "../helpers"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { asError, getErrorMessage } from "../pure/helpers-pure"; import { redactableError } from "../pure/errors"; import { access } from "fs-extra"; diff --git a/extensions/ql-vscode/src/skeleton-query-wizard.ts b/extensions/ql-vscode/src/skeleton-query-wizard.ts index 259318e3461..d1a4d1174c1 100644 --- a/extensions/ql-vscode/src/skeleton-query-wizard.ts +++ b/extensions/ql-vscode/src/skeleton-query-wizard.ts @@ -4,11 +4,11 @@ import { CodeQLCliServer } from "./codeql-cli/cli"; import { OutputChannelLogger } from "./common"; import { Credentials } from "./common/authentication"; import { QueryLanguage } from "./common/query-language"; +import { askForLanguage } from "./helpers"; import { - askForLanguage, getFirstWorkspaceFolder, isFolderAlreadyInWorkspace, -} from "./helpers"; +} from "./common/vscode/workspace-folders"; import { getErrorMessage } from "./pure/helpers-pure"; import { QlPackGenerator } from "./qlpack-generator"; import { DatabaseItem, DatabaseManager } from "./databases/local-databases"; diff --git a/extensions/ql-vscode/src/telemetry.ts b/extensions/ql-vscode/src/telemetry.ts index 597b5e7401c..35c219c7f81 100644 --- a/extensions/ql-vscode/src/telemetry.ts +++ b/extensions/ql-vscode/src/telemetry.ts @@ -17,7 +17,7 @@ import { import * as appInsights from "applicationinsights"; import { extLogger } from "./common"; import { UserCancellationException } from "./common/vscode/progress"; -import { showBinaryChoiceWithUrlDialog } from "./helpers"; +import { showBinaryChoiceWithUrlDialog } from "./common/vscode/dialog"; import { RedactableError } from "./pure/errors"; import { SemVer } from "semver"; diff --git a/extensions/ql-vscode/src/variant-analysis/export-results.ts b/extensions/ql-vscode/src/variant-analysis/export-results.ts index 167ccac8849..f1ff228093d 100644 --- a/extensions/ql-vscode/src/variant-analysis/export-results.ts +++ b/extensions/ql-vscode/src/variant-analysis/export-results.ts @@ -7,7 +7,7 @@ import { UserCancellationException, withProgress, } from "../common/vscode/progress"; -import { showInformationMessageWithAction } from "../helpers"; +import { showInformationMessageWithAction } from "../common/vscode/dialog"; import { extLogger } from "../common"; import { createGist } from "./gh-api/gh-api-client"; import { diff --git a/extensions/ql-vscode/src/variant-analysis/run-remote-query.ts b/extensions/ql-vscode/src/variant-analysis/run-remote-query.ts index 9036d3b1405..37378852e80 100644 --- a/extensions/ql-vscode/src/variant-analysis/run-remote-query.ts +++ b/extensions/ql-vscode/src/variant-analysis/run-remote-query.ts @@ -6,10 +6,10 @@ import { dir, tmpName } from "tmp-promise"; import { askForLanguage, findLanguage, - getOnDiskWorkspaceFolders, tryGetQueryMetadata, tmpDir, } from "../helpers"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { Credentials } from "../common/authentication"; import * as cli from "../codeql-cli/cli"; import { extLogger } from "../common"; diff --git a/extensions/ql-vscode/test/vscode-tests/cli-integration/debugger/debug-controller.ts b/extensions/ql-vscode/test/vscode-tests/cli-integration/debugger/debug-controller.ts index 90a7c1f30ac..ec20bbd5435 100644 --- a/extensions/ql-vscode/test/vscode-tests/cli-integration/debugger/debug-controller.ts +++ b/extensions/ql-vscode/test/vscode-tests/cli-integration/debugger/debug-controller.ts @@ -20,7 +20,7 @@ import { join } from "path"; import { writeFile } from "fs-extra"; import { expect } from "@jest/globals"; import { AppCommandManager } from "../../../../src/common/commands"; -import { getOnDiskWorkspaceFolders } from "../../../../src/helpers"; +import { getOnDiskWorkspaceFolders } from "../../../../src/common/vscode/workspace-folders"; type Resolver = (value: T) => void; diff --git a/extensions/ql-vscode/test/vscode-tests/cli-integration/run-cli.test.ts b/extensions/ql-vscode/test/vscode-tests/cli-integration/run-cli.test.ts index cf368245aae..f1373692916 100644 --- a/extensions/ql-vscode/test/vscode-tests/cli-integration/run-cli.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/cli-integration/run-cli.test.ts @@ -7,7 +7,7 @@ import { QueryInfoByLanguage, } from "../../../src/codeql-cli/cli"; import { itWithCodeQL } from "../cli"; -import { getOnDiskWorkspaceFolders } from "../../../src/helpers"; +import { getOnDiskWorkspaceFolders } from "../../../src/common/vscode/workspace-folders"; import { KeyType, resolveQueries } from "../../../src/language-support"; import { faker } from "@faker-js/faker"; import { getActivatedExtension } from "../global.helper"; 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 949d7958023..7c56775bba9 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 @@ -8,7 +8,7 @@ import * as tmp from "tmp"; import { TextDocument, window, workspace, WorkspaceFolder } from "vscode"; import { extLogger } from "../../../src/common"; import { QlPackGenerator } from "../../../src/qlpack-generator"; -import * as helpers from "../../../src/helpers"; +import * as workspaceFolders from "../../../src/common/vscode/workspace-folders"; import { createFileSync, ensureDirSync, removeSync } from "fs-extra"; import { join } from "path"; import { CancellationTokenSource } from "vscode-jsonrpc"; @@ -139,7 +139,9 @@ describe("SkeletonQueryWizard", () => { describe("if QL pack doesn't exist", () => { beforeEach(() => { - jest.spyOn(helpers, "isFolderAlreadyInWorkspace").mockReturnValue(false); + jest + .spyOn(workspaceFolders, "isFolderAlreadyInWorkspace") + .mockReturnValue(false); }); it("should try to create a new QL pack based on the language", async () => { await wizard.execute(); @@ -166,7 +168,9 @@ describe("SkeletonQueryWizard", () => { describe("if QL pack exists", () => { beforeEach(async () => { - jest.spyOn(helpers, "isFolderAlreadyInWorkspace").mockReturnValue(true); + jest + .spyOn(workspaceFolders, "isFolderAlreadyInWorkspace") + .mockReturnValue(true); // create a skeleton codeql-custom-queries-${language} folder // with an example QL file inside diff --git a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-databases.test.ts b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-databases.test.ts index aa3cafbb719..a28197ac2cb 100644 --- a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-databases.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-databases.test.ts @@ -20,7 +20,7 @@ import { } from "../../../src/common/vscode/archive-filesystem-provider"; import { testDisposeHandler } from "../test-dispose-handler"; import { QueryRunner } from "../../../src/query-server/query-runner"; -import * as helpers from "../../../src/helpers"; +import * as dialog from "../../../src/common/vscode/dialog"; import { Setting } from "../../../src/config"; import { QlPackGenerator } from "../../../src/qlpack-generator"; import { mockedObject } from "../utils/mocking.helpers"; @@ -45,7 +45,7 @@ describe("local databases", () => { let logSpy: jest.Mock; let showNeverAskAgainDialogSpy: jest.SpiedFunction< - typeof helpers.showNeverAskAgainDialog + typeof dialog.showNeverAskAgainDialog >; let dir: tmp.DirResult; @@ -64,7 +64,7 @@ describe("local databases", () => { }); showNeverAskAgainDialogSpy = jest - .spyOn(helpers, "showNeverAskAgainDialog") + .spyOn(dialog, "showNeverAskAgainDialog") .mockResolvedValue("Yes"); extensionContextStoragePath = dir.name; @@ -652,7 +652,7 @@ describe("local databases", () => { it("should return early if the user refuses help", async () => { showNeverAskAgainDialogSpy = jest - .spyOn(helpers, "showNeverAskAgainDialog") + .spyOn(dialog, "showNeverAskAgainDialog") .mockResolvedValue("No"); await (databaseManager as any).createSkeletonPacks(mockDbItem); @@ -662,7 +662,7 @@ describe("local databases", () => { it("should return early and write choice to settings if user wants to never be asked again", async () => { showNeverAskAgainDialogSpy = jest - .spyOn(helpers, "showNeverAskAgainDialog") + .spyOn(dialog, "showNeverAskAgainDialog") .mockResolvedValue("No, and never ask me again"); const updateValueSpy = jest.spyOn(Setting.prototype, "updateValue"); @@ -705,7 +705,7 @@ describe("local databases", () => { it("should exit early", async () => { showNeverAskAgainDialogSpy = jest - .spyOn(helpers, "showNeverAskAgainDialog") + .spyOn(dialog, "showNeverAskAgainDialog") .mockResolvedValue("No"); await (databaseManager as any).createSkeletonPacks(mockDbItem); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/code-tour.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/code-tour.test.ts new file mode 100644 index 00000000000..8d0be7d7144 --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/code-tour.test.ts @@ -0,0 +1,110 @@ +import { Uri, window, workspace, WorkspaceFolder } from "vscode"; +import * as tmp from "tmp"; +import { join } from "path"; +import { mkdir, writeFile } from "fs-extra"; + +import { prepareCodeTour } from "../../../src/code-tour"; +import { Setting } from "../../../src/config"; +import { createMockCommandManager } from "../../__mocks__/commandsMock"; + +describe("prepareCodeTour", () => { + let dir: tmp.DirResult; + let showInformationMessageSpy: jest.SpiedFunction< + typeof window.showInformationMessage + >; + + beforeEach(() => { + dir = tmp.dirSync(); + + const mockWorkspaceFolders = [ + { + uri: Uri.file(dir.name), + name: "test", + index: 0, + }, + ] as WorkspaceFolder[]; + + jest + .spyOn(workspace, "workspaceFolders", "get") + .mockReturnValue(mockWorkspaceFolders); + + showInformationMessageSpy = jest + .spyOn(window, "showInformationMessage") + .mockResolvedValue({ title: "Yes" }); + }); + + afterEach(() => { + dir.removeCallback(); + }); + + describe("if we're in the tour repo", () => { + describe("if the workspace is not already open", () => { + it("should open the tutorial workspace", async () => { + // set up directory to have a 'tutorial.code-workspace' file + const tutorialWorkspacePath = join(dir.name, "tutorial.code-workspace"); + await writeFile(tutorialWorkspacePath, "{}"); + + // set up a .tours directory to indicate we're in the tour codespace + const tourDirPath = join(dir.name, ".tours"); + await mkdir(tourDirPath); + + // spy that we open the workspace file by calling the 'vscode.openFolder' command + const executeCommand = jest.fn(); + await prepareCodeTour(createMockCommandManager({ executeCommand })); + + expect(showInformationMessageSpy).toHaveBeenCalled(); + expect(executeCommand).toHaveBeenCalledWith( + "vscode.openFolder", + expect.objectContaining({ + path: expect.stringMatching(/tutorial.code-workspace$/), + }), + ); + }); + }); + + describe("if the workspace is already open", () => { + it("should not open the tutorial workspace", async () => { + // Set isCodespacesTemplate to true to indicate the workspace has already been opened + jest.spyOn(Setting.prototype, "getValue").mockReturnValue(true); + + // set up directory to have a 'tutorial.code-workspace' file + const tutorialWorkspacePath = join(dir.name, "tutorial.code-workspace"); + await writeFile(tutorialWorkspacePath, "{}"); + + // set up a .tours directory to indicate we're in the tour codespace + const tourDirPath = join(dir.name, ".tours"); + await mkdir(tourDirPath); + + // spy that we open the workspace file by calling the 'vscode.openFolder' command + const executeCommand = jest.fn(); + await prepareCodeTour(createMockCommandManager({ executeCommand })); + + expect(executeCommand).not.toHaveBeenCalled(); + }); + }); + }); + + describe("if we're in a different tour repo", () => { + it("should not open the tutorial workspace", async () => { + // set up a .tours directory + const tourDirPath = join(dir.name, ".tours"); + await mkdir(tourDirPath); + + // spy that we open the workspace file by calling the 'vscode.openFolder' command + const executeCommand = jest.fn(); + await prepareCodeTour(createMockCommandManager({ executeCommand })); + + expect(executeCommand).not.toHaveBeenCalled(); + }); + }); + + describe("if we're in a different repo with no tour", () => { + it("should not open the tutorial workspace", async () => { + // spy that we open the workspace file by calling the 'vscode.openFolder' command + const executeCommand = jest.fn(); + await prepareCodeTour(createMockCommandManager({ executeCommand })); + + expect(executeCommand).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/dialog.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/dialog.test.ts new file mode 100644 index 00000000000..895928b6368 --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/dialog.test.ts @@ -0,0 +1,167 @@ +import { window } from "vscode"; +import { + showBinaryChoiceDialog, + showBinaryChoiceWithUrlDialog, + showInformationMessageWithAction, + showNeverAskAgainDialog, +} from "../../../../../src/common/vscode/dialog"; + +describe("showBinaryChoiceDialog", () => { + let showInformationMessageSpy: jest.SpiedFunction< + typeof window.showInformationMessage + >; + + beforeEach(() => { + showInformationMessageSpy = jest + .spyOn(window, "showInformationMessage") + .mockResolvedValue(undefined); + }); + + const resolveArg = + (index: number) => + (...args: any[]) => + Promise.resolve(args[index]); + + it("should show a binary choice dialog and return `yes`", async () => { + // pretend user chooses 'yes' + showInformationMessageSpy.mockImplementationOnce(resolveArg(2)); + const val = await showBinaryChoiceDialog("xxx"); + expect(val).toBe(true); + }); + + it("should show a binary choice dialog and return `no`", async () => { + // pretend user chooses 'no' + showInformationMessageSpy.mockImplementationOnce(resolveArg(3)); + const val = await showBinaryChoiceDialog("xxx"); + expect(val).toBe(false); + }); +}); + +describe("showInformationMessageWithAction", () => { + let showInformationMessageSpy: jest.SpiedFunction< + typeof window.showInformationMessage + >; + + beforeEach(() => { + showInformationMessageSpy = jest + .spyOn(window, "showInformationMessage") + .mockResolvedValue(undefined); + }); + + const resolveArg = + (index: number) => + (...args: any[]) => + Promise.resolve(args[index]); + + it("should show an info dialog and confirm the action", async () => { + // pretend user chooses to run action + showInformationMessageSpy.mockImplementationOnce(resolveArg(1)); + const val = await showInformationMessageWithAction("xxx", "yyy"); + expect(val).toBe(true); + }); + + it("should show an action dialog and avoid choosing the action", async () => { + // pretend user does not choose to run action + showInformationMessageSpy.mockResolvedValueOnce(undefined); + const val = await showInformationMessageWithAction("xxx", "yyy"); + expect(val).toBe(false); + }); +}); + +describe("showBinaryChoiceWithUrlDialog", () => { + let showInformationMessageSpy: jest.SpiedFunction< + typeof window.showInformationMessage + >; + + beforeEach(() => { + showInformationMessageSpy = jest + .spyOn(window, "showInformationMessage") + .mockResolvedValue(undefined); + }); + + const resolveArg = + (index: number) => + (...args: any[]) => + Promise.resolve(args[index]); + + it("should show a binary choice dialog with a url and return `yes`", async () => { + // pretend user clicks on the url twice and then clicks 'yes' + showInformationMessageSpy + .mockImplementation(resolveArg(2)) + .mockImplementation(resolveArg(2)) + .mockImplementation(resolveArg(3)); + const val = await showBinaryChoiceWithUrlDialog("xxx", "invalid:url"); + expect(val).toBe(true); + }); + + it("should show a binary choice dialog with a url and return `no`", async () => { + // pretend user clicks on the url twice and then clicks 'no' + showInformationMessageSpy + .mockImplementation(resolveArg(2)) + .mockImplementation(resolveArg(2)) + .mockImplementation(resolveArg(4)); + const val = await showBinaryChoiceWithUrlDialog("xxx", "invalid:url"); + expect(val).toBe(false); + }); + + it("should show a binary choice dialog and exit after clcking `more info` 5 times", async () => { + // pretend user clicks on the url twice and then clicks 'no' + showInformationMessageSpy + .mockImplementation(resolveArg(2)) + .mockImplementation(resolveArg(2)) + .mockImplementation(resolveArg(2)) + .mockImplementation(resolveArg(2)) + .mockImplementation(resolveArg(2)); + const val = await showBinaryChoiceWithUrlDialog("xxx", "invalid:url"); + // No choice was made + expect(val).toBeUndefined(); + expect(showInformationMessageSpy).toHaveBeenCalledTimes(5); + }); +}); + +describe("showNeverAskAgainDialog", () => { + let showInformationMessageSpy: jest.SpiedFunction< + typeof window.showInformationMessage + >; + + beforeEach(() => { + showInformationMessageSpy = jest + .spyOn(window, "showInformationMessage") + .mockResolvedValue(undefined); + }); + + const resolveArg = + (index: number) => + (...args: any[]) => + Promise.resolve(args[index]); + + const title = + "We've noticed you don't have a CodeQL pack available to analyze this database. Can we set up a query pack for you?"; + + it("should show a ternary choice dialog and return `Yes`", async () => { + // pretend user chooses 'Yes' + const yesItem = resolveArg(2); + showInformationMessageSpy.mockImplementationOnce(yesItem); + + const answer = await showNeverAskAgainDialog(title); + expect(answer).toBe("Yes"); + }); + + it("should show a ternary choice dialog and return `No`", async () => { + // pretend user chooses 'No' + const noItem = resolveArg(3); + showInformationMessageSpy.mockImplementationOnce(noItem); + + const answer = await showNeverAskAgainDialog(title); + expect(answer).toBe("No"); + }); + + it("should show a ternary choice dialog and return `No, and never ask me again`", async () => { + // pretend user chooses 'No, and never ask me again' + const neverAskAgainItem = resolveArg(4); + showInformationMessageSpy.mockImplementationOnce(neverAskAgainItem); + + const answer = await showNeverAskAgainDialog(title); + expect(answer).toBe("No, and never ask me again"); + }); +}); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/workspace-folders.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/workspace-folders.test.ts new file mode 100644 index 00000000000..b316a1a8bef --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/common/vscode/workspace-folders.test.ts @@ -0,0 +1,63 @@ +import { workspace, WorkspaceFolder } from "vscode"; +import { join } from "path"; +import { + getFirstWorkspaceFolder, + isFolderAlreadyInWorkspace, +} from "../../../../../src/common/vscode/workspace-folders"; + +describe("isFolderAlreadyInWorkspace", () => { + beforeEach(() => { + const folders = [ + { name: "/first/path" }, + { name: "/second/path" }, + ] as WorkspaceFolder[]; + + jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValue(folders); + }); + it("should return true if the folder is already in the workspace", () => { + expect(isFolderAlreadyInWorkspace("/first/path")).toBe(true); + }); + + it("should return false if the folder is not in the workspace", () => { + expect(isFolderAlreadyInWorkspace("/third/path")).toBe(false); + }); +}); + +describe("getFirstWorkspaceFolder", () => { + it("should return the first workspace folder", async () => { + jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValue([ + { + name: "codespaces-codeql", + uri: { fsPath: "codespaces-codeql", scheme: "file" }, + }, + ] as WorkspaceFolder[]); + + expect(getFirstWorkspaceFolder()).toEqual("codespaces-codeql"); + }); + + describe("if user is in vscode-codeql-starter workspace", () => { + it("should set storage path to parent folder", async () => { + jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValue([ + { + name: "codeql-custom-queries-cpp", + uri: { + fsPath: join("vscode-codeql-starter", "codeql-custom-queries-cpp"), + scheme: "file", + }, + }, + { + name: "codeql-custom-queries-csharp", + uri: { + fsPath: join( + "vscode-codeql-starter", + "codeql-custom-queries-csharp", + ), + scheme: "file", + }, + }, + ] as WorkspaceFolder[]); + + expect(getFirstWorkspaceFolder()).toEqual("vscode-codeql-starter"); + }); + }); +}); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/helpers.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/helpers.test.ts index c656c16025f..c460c9ff1f2 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/helpers.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/helpers.test.ts @@ -1,20 +1,4 @@ -import { Uri, window, workspace, WorkspaceFolder } from "vscode"; -import * as tmp from "tmp"; -import { join } from "path"; -import { writeFile, mkdir } from "fs-extra"; - -import { - getFirstWorkspaceFolder, - isFolderAlreadyInWorkspace, - prepareCodeTour, - showBinaryChoiceDialog, - showBinaryChoiceWithUrlDialog, - showInformationMessageWithAction, - showNeverAskAgainDialog, -} from "../../../src/helpers"; import { reportStreamProgress } from "../../../src/common/vscode/progress"; -import { Setting } from "../../../src/config"; -import { createMockCommandManager } from "../../__mocks__/commandsMock"; describe("helpers", () => { it("should report stream progress", () => { @@ -73,323 +57,4 @@ describe("helpers", () => { message: "My prefix (Size unknown)", }); }); - - describe("showBinaryChoiceDialog", () => { - let showInformationMessageSpy: jest.SpiedFunction< - typeof window.showInformationMessage - >; - - beforeEach(() => { - showInformationMessageSpy = jest - .spyOn(window, "showInformationMessage") - .mockResolvedValue(undefined); - }); - - const resolveArg = - (index: number) => - (...args: any[]) => - Promise.resolve(args[index]); - - it("should show a binary choice dialog and return `yes`", async () => { - // pretend user chooses 'yes' - showInformationMessageSpy.mockImplementationOnce(resolveArg(2)); - const val = await showBinaryChoiceDialog("xxx"); - expect(val).toBe(true); - }); - - it("should show a binary choice dialog and return `no`", async () => { - // pretend user chooses 'no' - showInformationMessageSpy.mockImplementationOnce(resolveArg(3)); - const val = await showBinaryChoiceDialog("xxx"); - expect(val).toBe(false); - }); - }); - - describe("showInformationMessageWithAction", () => { - let showInformationMessageSpy: jest.SpiedFunction< - typeof window.showInformationMessage - >; - - beforeEach(() => { - showInformationMessageSpy = jest - .spyOn(window, "showInformationMessage") - .mockResolvedValue(undefined); - }); - - const resolveArg = - (index: number) => - (...args: any[]) => - Promise.resolve(args[index]); - - it("should show an info dialog and confirm the action", async () => { - // pretend user chooses to run action - showInformationMessageSpy.mockImplementationOnce(resolveArg(1)); - const val = await showInformationMessageWithAction("xxx", "yyy"); - expect(val).toBe(true); - }); - - it("should show an action dialog and avoid choosing the action", async () => { - // pretend user does not choose to run action - showInformationMessageSpy.mockResolvedValueOnce(undefined); - const val = await showInformationMessageWithAction("xxx", "yyy"); - expect(val).toBe(false); - }); - }); - - describe("showBinaryChoiceWithUrlDialog", () => { - let showInformationMessageSpy: jest.SpiedFunction< - typeof window.showInformationMessage - >; - - beforeEach(() => { - showInformationMessageSpy = jest - .spyOn(window, "showInformationMessage") - .mockResolvedValue(undefined); - }); - - const resolveArg = - (index: number) => - (...args: any[]) => - Promise.resolve(args[index]); - - it("should show a binary choice dialog with a url and return `yes`", async () => { - // pretend user clicks on the url twice and then clicks 'yes' - showInformationMessageSpy - .mockImplementation(resolveArg(2)) - .mockImplementation(resolveArg(2)) - .mockImplementation(resolveArg(3)); - const val = await showBinaryChoiceWithUrlDialog("xxx", "invalid:url"); - expect(val).toBe(true); - }); - - it("should show a binary choice dialog with a url and return `no`", async () => { - // pretend user clicks on the url twice and then clicks 'no' - showInformationMessageSpy - .mockImplementation(resolveArg(2)) - .mockImplementation(resolveArg(2)) - .mockImplementation(resolveArg(4)); - const val = await showBinaryChoiceWithUrlDialog("xxx", "invalid:url"); - expect(val).toBe(false); - }); - - it("should show a binary choice dialog and exit after clcking `more info` 5 times", async () => { - // pretend user clicks on the url twice and then clicks 'no' - showInformationMessageSpy - .mockImplementation(resolveArg(2)) - .mockImplementation(resolveArg(2)) - .mockImplementation(resolveArg(2)) - .mockImplementation(resolveArg(2)) - .mockImplementation(resolveArg(2)); - const val = await showBinaryChoiceWithUrlDialog("xxx", "invalid:url"); - // No choice was made - expect(val).toBeUndefined(); - expect(showInformationMessageSpy).toHaveBeenCalledTimes(5); - }); - }); - - describe("showNeverAskAgainDialog", () => { - let showInformationMessageSpy: jest.SpiedFunction< - typeof window.showInformationMessage - >; - - beforeEach(() => { - showInformationMessageSpy = jest - .spyOn(window, "showInformationMessage") - .mockResolvedValue(undefined); - }); - - const resolveArg = - (index: number) => - (...args: any[]) => - Promise.resolve(args[index]); - - const title = - "We've noticed you don't have a CodeQL pack available to analyze this database. Can we set up a query pack for you?"; - - it("should show a ternary choice dialog and return `Yes`", async () => { - // pretend user chooses 'Yes' - const yesItem = resolveArg(2); - showInformationMessageSpy.mockImplementationOnce(yesItem); - - const answer = await showNeverAskAgainDialog(title); - expect(answer).toBe("Yes"); - }); - - it("should show a ternary choice dialog and return `No`", async () => { - // pretend user chooses 'No' - const noItem = resolveArg(3); - showInformationMessageSpy.mockImplementationOnce(noItem); - - const answer = await showNeverAskAgainDialog(title); - expect(answer).toBe("No"); - }); - - it("should show a ternary choice dialog and return `No, and never ask me again`", async () => { - // pretend user chooses 'No, and never ask me again' - const neverAskAgainItem = resolveArg(4); - showInformationMessageSpy.mockImplementationOnce(neverAskAgainItem); - - const answer = await showNeverAskAgainDialog(title); - expect(answer).toBe("No, and never ask me again"); - }); - }); -}); - -describe("isFolderAlreadyInWorkspace", () => { - beforeEach(() => { - const folders = [ - { name: "/first/path" }, - { name: "/second/path" }, - ] as WorkspaceFolder[]; - - jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValue(folders); - }); - it("should return true if the folder is already in the workspace", () => { - expect(isFolderAlreadyInWorkspace("/first/path")).toBe(true); - }); - - it("should return false if the folder is not in the workspace", () => { - expect(isFolderAlreadyInWorkspace("/third/path")).toBe(false); - }); -}); - -describe("prepareCodeTour", () => { - let dir: tmp.DirResult; - let showInformationMessageSpy: jest.SpiedFunction< - typeof window.showInformationMessage - >; - - beforeEach(() => { - dir = tmp.dirSync(); - - const mockWorkspaceFolders = [ - { - uri: Uri.file(dir.name), - name: "test", - index: 0, - }, - ] as WorkspaceFolder[]; - - jest - .spyOn(workspace, "workspaceFolders", "get") - .mockReturnValue(mockWorkspaceFolders); - - showInformationMessageSpy = jest - .spyOn(window, "showInformationMessage") - .mockResolvedValue({ title: "Yes" }); - }); - - afterEach(() => { - dir.removeCallback(); - }); - - describe("if we're in the tour repo", () => { - describe("if the workspace is not already open", () => { - it("should open the tutorial workspace", async () => { - // set up directory to have a 'tutorial.code-workspace' file - const tutorialWorkspacePath = join(dir.name, "tutorial.code-workspace"); - await writeFile(tutorialWorkspacePath, "{}"); - - // set up a .tours directory to indicate we're in the tour codespace - const tourDirPath = join(dir.name, ".tours"); - await mkdir(tourDirPath); - - // spy that we open the workspace file by calling the 'vscode.openFolder' command - const executeCommand = jest.fn(); - await prepareCodeTour(createMockCommandManager({ executeCommand })); - - expect(showInformationMessageSpy).toHaveBeenCalled(); - expect(executeCommand).toHaveBeenCalledWith( - "vscode.openFolder", - expect.objectContaining({ - path: expect.stringMatching(/tutorial.code-workspace$/), - }), - ); - }); - }); - - describe("if the workspace is already open", () => { - it("should not open the tutorial workspace", async () => { - // Set isCodespacesTemplate to true to indicate the workspace has already been opened - jest.spyOn(Setting.prototype, "getValue").mockReturnValue(true); - - // set up directory to have a 'tutorial.code-workspace' file - const tutorialWorkspacePath = join(dir.name, "tutorial.code-workspace"); - await writeFile(tutorialWorkspacePath, "{}"); - - // set up a .tours directory to indicate we're in the tour codespace - const tourDirPath = join(dir.name, ".tours"); - await mkdir(tourDirPath); - - // spy that we open the workspace file by calling the 'vscode.openFolder' command - const executeCommand = jest.fn(); - await prepareCodeTour(createMockCommandManager({ executeCommand })); - - expect(executeCommand).not.toHaveBeenCalled(); - }); - }); - }); - - describe("if we're in a different tour repo", () => { - it("should not open the tutorial workspace", async () => { - // set up a .tours directory - const tourDirPath = join(dir.name, ".tours"); - await mkdir(tourDirPath); - - // spy that we open the workspace file by calling the 'vscode.openFolder' command - const executeCommand = jest.fn(); - await prepareCodeTour(createMockCommandManager({ executeCommand })); - - expect(executeCommand).not.toHaveBeenCalled(); - }); - }); - - describe("if we're in a different repo with no tour", () => { - it("should not open the tutorial workspace", async () => { - // spy that we open the workspace file by calling the 'vscode.openFolder' command - const executeCommand = jest.fn(); - await prepareCodeTour(createMockCommandManager({ executeCommand })); - - expect(executeCommand).not.toHaveBeenCalled(); - }); - }); -}); - -describe("getFirstWorkspaceFolder", () => { - it("should return the first workspace folder", async () => { - jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValue([ - { - name: "codespaces-codeql", - uri: { fsPath: "codespaces-codeql", scheme: "file" }, - }, - ] as WorkspaceFolder[]); - - expect(getFirstWorkspaceFolder()).toEqual("codespaces-codeql"); - }); - - describe("if user is in vscode-codeql-starter workspace", () => { - it("should set storage path to parent folder", async () => { - jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValue([ - { - name: "codeql-custom-queries-cpp", - uri: { - fsPath: join("vscode-codeql-starter", "codeql-custom-queries-cpp"), - scheme: "file", - }, - }, - { - name: "codeql-custom-queries-csharp", - uri: { - fsPath: join( - "vscode-codeql-starter", - "codeql-custom-queries-csharp", - ), - scheme: "file", - }, - }, - ] as WorkspaceFolder[]); - - expect(getFirstWorkspaceFolder()).toEqual("vscode-codeql-starter"); - }); - }); }); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/contextual/query-resolver.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/contextual/query-resolver.test.ts index 52e19460d44..98c958dcbe0 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/contextual/query-resolver.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/language-support/contextual/query-resolver.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs-extra"; import { getErrorMessage } from "../../../../../src/pure/helpers-pure"; import * as helpers from "../../../../../src/helpers"; +import * as workspaceFolders from "../../../../../src/common/vscode/workspace-folders"; import * as qlpack from "../../../../../src/databases/qlpack"; import { KeyType, @@ -38,7 +39,9 @@ describe("queryResolver", () => { .spyOn(qlpack, "getPrimaryDbscheme") .mockResolvedValue("primaryDbscheme"); - jest.spyOn(helpers, "getOnDiskWorkspaceFolders").mockReturnValue([]); + jest + .spyOn(workspaceFolders, "getOnDiskWorkspaceFolders") + .mockReturnValue([]); jest.spyOn(helpers, "showAndLogErrorMessage").mockResolvedValue(undefined); }); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts index 2ead1a3c92e..c5d8740266d 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts @@ -22,7 +22,7 @@ import { createMockVariantAnalysisHistoryItem } from "../../../factories/query-h import { VariantAnalysisHistoryItem } from "../../../../src/query-history/variant-analysis-history-item"; import { QueryStatus } from "../../../../src/query-status"; import { VariantAnalysisStatus } from "../../../../src/variant-analysis/shared/variant-analysis"; -import * as helpers from "../../../../src/helpers"; +import * as dialog from "../../../../src/common/vscode/dialog"; import { mockedQuickPickItem } from "../../utils/mocking.helpers"; import { createMockQueryHistoryDirs } from "../../../factories/query-history/query-history-dirs"; import { createMockApp } from "../../../__mocks__/appMock"; @@ -318,20 +318,20 @@ describe("QueryHistoryManager", () => { describe("when the item is a variant analysis", () => { let showBinaryChoiceDialogSpy: jest.SpiedFunction< - typeof helpers.showBinaryChoiceDialog + typeof dialog.showBinaryChoiceDialog >; let showInformationMessageWithActionSpy: jest.SpiedFunction< - typeof helpers.showInformationMessageWithAction + typeof dialog.showInformationMessageWithAction >; beforeEach(() => { // Choose 'Yes' when asked "Are you sure?" showBinaryChoiceDialogSpy = jest - .spyOn(helpers, "showBinaryChoiceDialog") + .spyOn(dialog, "showBinaryChoiceDialog") .mockResolvedValue(true); showInformationMessageWithActionSpy = jest.spyOn( - helpers, + dialog, "showInformationMessageWithAction", ); });