Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions extensions/ql-vscode/src/code-tour.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
}
135 changes: 135 additions & 0 deletions extensions/ql-vscode/src/common/vscode/dialog.ts
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined> {
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<boolean | undefined> {
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<boolean> {
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<string | undefined> {
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;
}
6 changes: 2 additions & 4 deletions extensions/ql-vscode/src/common/vscode/external-files.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
64 changes: 64 additions & 0 deletions extensions/ql-vscode/src/common/vscode/workspace-folders.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion extensions/ql-vscode/src/databases/qlpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
3 changes: 2 additions & 1 deletion extensions/ql-vscode/src/debugger/debug-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 5 additions & 3 deletions extensions/ql-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading