diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cd5194b60f..d66963a303 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,9 @@ updates: - "esbuild" - "eslint" - "@typescript-eslint/*" + ignore: + - dependency-name: "untildify" + versions: ["5.x"] - package-ecosystem: github-actions directory: "/" schedule: diff --git a/.mocharc.json b/.mocharc.json index 2bd21c2f98..ef0c638ee7 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -6,7 +6,7 @@ ".jsx" ], "require": "source-map-support/register", - "timeout": 60000, + "timeout": 600000, "slow": 2000, "spec": "out/test/**/*.test.js" } diff --git a/extension-dev.code-workspace b/extension-dev.code-workspace index f698c754ef..b2dd02bc63 100644 --- a/extension-dev.code-workspace +++ b/extension-dev.code-workspace @@ -59,7 +59,7 @@ "mochaExplorer.autoload": false, // The test instance pops up every time discovery or run is done, this could be annoying on startup. "mochaExplorer.debuggerPort": 59229, // Matches the launch config, we dont want to use the default port as we are launching a duplicate instance of vscode and it might conflict. "mochaExplorer.ipcRole": "server", - "mochaExplorer.ipcTimeout": 10000, + "mochaExplorer.ipcTimeout": 30000, // 30 seconds "testExplorer.useNativeTesting": true, "mochaExplorer.env": { "VSCODE_VERSION": "insiders", diff --git a/package-lock.json b/package-lock.json index e42425ec1f..db832984b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@vscode/extension-telemetry": "0.8.2", "node-fetch": "2.6.12", "semver": "7.5.4", + "untildify": "4.0.0", "uuid": "9.0.0", "vscode-languageclient": "8.1.0", "vscode-languageserver-protocol": "3.17.3" @@ -5318,6 +5319,14 @@ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", "dev": true }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "engines": { + "node": ">=8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -9469,6 +9478,11 @@ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", "dev": true }, + "untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==" + }, "uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index e568f476eb..9485e50d60 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@vscode/extension-telemetry": "0.8.2", "node-fetch": "2.6.12", "semver": "7.5.4", + "untildify": "4.0.0", "uuid": "9.0.0", "vscode-languageclient": "8.1.0", "vscode-languageserver-protocol": "3.17.3" @@ -734,7 +735,7 @@ "powershell.cwd": { "type": "string", "default": "", - "markdownDescription": "An explicit start path where the Extension Terminal will be launched. Both the PowerShell process's and the shell's location will be set to this directory. **Path must be fully resolved: variables are not supported!**" + "markdownDescription": "A path where the Extension Terminal will be launched. Both the PowerShell process's and the shell's location will be set to this directory. Does not support variables, but does support the use of '~' and paths relative to a single workspace. **For multi-root workspaces, use the name of the folder you wish to have as the cwd.**" }, "powershell.scriptAnalysis.enable": { "type": "boolean", diff --git a/src/features/PesterTests.ts b/src/features/PesterTests.ts index 7a49df2cf2..f5bd839c94 100644 --- a/src/features/PesterTests.ts +++ b/src/features/PesterTests.ts @@ -5,7 +5,7 @@ import * as path from "path"; import vscode = require("vscode"); import { ILogger } from "../logging"; import { SessionManager } from "../session"; -import { getSettings, chosenWorkspace, validateCwdSetting } from "../settings"; +import { getSettings, getChosenWorkspace } from "../settings"; import utils = require("../utils"); enum LaunchType { @@ -132,8 +132,7 @@ export class PesterTestsFeature implements vscode.Disposable { // Ensure the necessary script exists (for testing). The debugger will // start regardless, but we also pass its success along. - await validateCwdSetting(this.logger); return await utils.checkIfFileExists(this.invokePesterStubScriptPath) - && vscode.debug.startDebugging(chosenWorkspace, launchConfig); + && vscode.debug.startDebugging(await getChosenWorkspace(this.logger), launchConfig); } } diff --git a/src/features/RunCode.ts b/src/features/RunCode.ts index 896ae7dd0f..652cf55daf 100644 --- a/src/features/RunCode.ts +++ b/src/features/RunCode.ts @@ -4,7 +4,7 @@ import vscode = require("vscode"); import { SessionManager } from "../session"; import { ILogger } from "../logging"; -import { getSettings, chosenWorkspace, validateCwdSetting } from "../settings"; +import { getSettings, getChosenWorkspace } from "../settings"; enum LaunchType { Debug, @@ -40,9 +40,7 @@ export class RunCodeFeature implements vscode.Disposable { // Create or show the interactive console // TODO: #367: Check if "newSession" mode is configured this.sessionManager.showDebugTerminal(true); - - await validateCwdSetting(this.logger); - await vscode.debug.startDebugging(chosenWorkspace, launchConfig); + await vscode.debug.startDebugging(await getChosenWorkspace(this.logger), launchConfig); } } diff --git a/src/logging.ts b/src/logging.ts index 6a8b984f74..846fcfc5b8 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -100,8 +100,8 @@ export class Logger implements ILogger { public async writeAndShowInformation(message: string, ...additionalMessages: string[]): Promise { this.write(message, ...additionalMessages); - const selection = await vscode.window.showInformationMessage(message, "Show Logs"); - if (selection !== undefined) { + const selection = await vscode.window.showInformationMessage(message, "Show Logs", "Okay"); + if (selection === "Show Logs") { this.showLogPanel(); } } diff --git a/src/main.ts b/src/main.ts index b0ce924884..5b0fee117e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,7 +26,7 @@ import { ShowHelpFeature } from "./features/ShowHelp"; import { SpecifyScriptArgsFeature } from "./features/DebugSession"; import { Logger } from "./logging"; import { SessionManager } from "./session"; -import { LogLevel, getSettings, validateCwdSetting } from "./settings"; +import { LogLevel, getSettings } from "./settings"; import { PowerShellLanguageId } from "./utils"; import { LanguageClientConsumer } from "./languageClientConsumer"; @@ -57,8 +57,6 @@ export async function activate(context: vscode.ExtensionContext): Promise { for (const versionName in this.additionalPowerShellExes) { if (Object.prototype.hasOwnProperty.call(this.additionalPowerShellExes, versionName)) { - const exePath = utils.stripQuotePair(this.additionalPowerShellExes[versionName]); + let exePath = utils.stripQuotePair(this.additionalPowerShellExes[versionName]); if (!exePath) { continue; } + exePath = untildify(exePath); + // Always search for what the user gave us first yield new PossiblePowerShellExe(exePath, versionName); diff --git a/src/process.ts b/src/process.ts index 0d69d40ba1..e523152723 100644 --- a/src/process.ts +++ b/src/process.ts @@ -5,7 +5,7 @@ import cp = require("child_process"); import path = require("path"); import vscode = require("vscode"); import { ILogger } from "./logging"; -import Settings = require("./settings"); +import { Settings, validateCwdSetting } from "./settings"; import utils = require("./utils"); import { IEditorServicesSessionDetails } from "./session"; import { promisify } from "util"; @@ -29,7 +29,7 @@ export class PowerShellProcess { private logger: ILogger, private startPsesArgs: string, private sessionFilePath: vscode.Uri, - private sessionSettings: Settings.Settings) { + private sessionSettings: Settings) { this.onExited = this.onExitedEmitter.event; } @@ -103,7 +103,7 @@ export class PowerShellProcess { name: this.title, shellPath: this.exePath, shellArgs: powerShellArgs, - cwd: this.sessionSettings.cwd, + cwd: await validateCwdSetting(this.logger), iconPath: new vscode.ThemeIcon("terminal-powershell"), isTransient: true, hideFromUser: this.sessionSettings.integratedConsole.startInBackground, diff --git a/src/session.ts b/src/session.ts index 330309a67f..0d3cc093d0 100644 --- a/src/session.ts +++ b/src/session.ts @@ -283,8 +283,7 @@ export class SessionManager implements Middleware { this.logger.write("Restarting session..."); await this.stop(); - // Re-load and validate the settings. - await validateCwdSetting(this.logger); + // Re-load the settings. this.sessionSettings = getSettings(); await this.start(exeNameOverride); @@ -470,12 +469,12 @@ export class SessionManager implements Middleware { this.logger.updateLogLevel(settings.developer.editorServicesLogLevel); // Detect any setting changes that would affect the session. - if (!this.suppressRestartPrompt && + if (!this.suppressRestartPrompt && this.sessionStatus === SessionStatus.Running && (settings.cwd.toLowerCase() !== this.sessionSettings.cwd.toLowerCase() || settings.powerShellDefaultVersion.toLowerCase() !== this.sessionSettings.powerShellDefaultVersion.toLowerCase() || settings.developer.editorServicesLogLevel.toLowerCase() !== this.sessionSettings.developer.editorServicesLogLevel.toLowerCase() || settings.developer.bundledModulesPath.toLowerCase() !== this.sessionSettings.developer.bundledModulesPath.toLowerCase() - || settings.developer.editorServicesWaitForDebugger !== this.sessionSettings.developer.editorServicesWaitForDebugger + || settings.developer.editorServicesWaitForDebugger !== this.sessionSettings.developer.editorServicesWaitForDebugger || settings.integratedConsole.useLegacyReadLine !== this.sessionSettings.integratedConsole.useLegacyReadLine || settings.integratedConsole.startInBackground !== this.sessionSettings.integratedConsole.startInBackground || settings.integratedConsole.startLocation !== this.sessionSettings.integratedConsole.startLocation)) { @@ -644,7 +643,7 @@ export class SessionManager implements Middleware { // NOTE: Some settings are only applicable on startup, so we send them during initialization. initializationOptions: { enableProfileLoading: this.sessionSettings.enableProfileLoading, - initialWorkingDirectory: this.sessionSettings.cwd, + initialWorkingDirectory: await validateCwdSetting(this.logger), shellIntegrationEnabled: vscode.workspace.getConfiguration("terminal.integrated.shellIntegration").get("enabled"), }, errorHandler: { diff --git a/src/settings.ts b/src/settings.ts index 3e200227dd..8b838f5a4d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -5,6 +5,8 @@ import vscode = require("vscode"); import utils = require("./utils"); import os = require("os"); import { ILogger } from "./logging"; +import untildify from "untildify"; +import path = require("path"); // TODO: Quite a few of these settings are unused in the client and instead // exist just for the server. Those settings do not need to be represented in @@ -36,7 +38,7 @@ export class Settings extends PartialSettings { sideBar = new SideBarSettings(); pester = new PesterSettings(); buttons = new ButtonSettings(); - cwd = ""; + cwd = ""; // NOTE: use validateCwdSetting() instead of this directly! enableReferencesCodeLens = true; analyzeOpenDocumentsOnly = false; // TODO: Add (deprecated) useX86Host (for testing) @@ -213,44 +215,99 @@ export async function changeSetting( } // We don't want to query the user more than once, so this is idempotent. -let hasPrompted = false; -export let chosenWorkspace: vscode.WorkspaceFolder | undefined = undefined; - -export async function validateCwdSetting(logger: ILogger): Promise { - let cwd: string | undefined = utils.stripQuotePair( - vscode.workspace.getConfiguration(utils.PowerShellLanguageId).get("cwd")); - - // Only use the cwd setting if it exists. - if (cwd !== undefined && await utils.checkIfDirectoryExists(cwd)) { - return cwd; +let hasChosen = false; +let chosenWorkspace: vscode.WorkspaceFolder | undefined = undefined; +export async function getChosenWorkspace(logger: ILogger | undefined): Promise { + if (hasChosen) { + return chosenWorkspace; } // If there is no workspace, or there is but it has no folders, fallback. if (vscode.workspace.workspaceFolders === undefined || vscode.workspace.workspaceFolders.length === 0) { - cwd = undefined; + chosenWorkspace = undefined; // If there is exactly one workspace folder, use that. } else if (vscode.workspace.workspaceFolders.length === 1) { - cwd = vscode.workspace.workspaceFolders[0].uri.fsPath; + chosenWorkspace = vscode.workspace.workspaceFolders[0]; // If there is more than one workspace folder, prompt the user once. - } else if (vscode.workspace.workspaceFolders.length > 1 && !hasPrompted) { - hasPrompted = true; + } else if (vscode.workspace.workspaceFolders.length > 1) { const options: vscode.WorkspaceFolderPickOptions = { placeHolder: "Select a workspace folder to use for the PowerShell Extension.", }; + chosenWorkspace = await vscode.window.showWorkspaceFolderPick(options); - cwd = chosenWorkspace?.uri.fsPath; - // Save the picked 'cwd' to the workspace settings. - // We have to check again because the user may not have picked. - if (cwd !== undefined && await utils.checkIfDirectoryExists(cwd)) { - await changeSetting("cwd", cwd, undefined, logger); + + logger?.writeVerbose(`User selected workspace: '${chosenWorkspace?.name}'`); + if (chosenWorkspace === undefined) { + chosenWorkspace = vscode.workspace.workspaceFolders[0]; + } else { + const response = await vscode.window.showInformationMessage( + `Would you like to save this choice by setting this workspace's 'powershell.cwd' value to '${chosenWorkspace.name}'?`, + "Yes", "No"); + + if (response === "Yes") { + await changeSetting("cwd", chosenWorkspace.name, vscode.ConfigurationTarget.Workspace, logger); + } } } - // If there were no workspace folders, or somehow they don't exist, use - // the home directory. - if (cwd === undefined || !await utils.checkIfDirectoryExists(cwd)) { + // NOTE: We don't rely on checking if `chosenWorkspace` is undefined because + // that may be the case if the user dismissed the prompt, and we don't want + // to show it again this session. + hasChosen = true; + return chosenWorkspace; +} + +export async function validateCwdSetting(logger: ILogger | undefined): Promise { + let cwd = utils.stripQuotePair( + vscode.workspace.getConfiguration(utils.PowerShellLanguageId).get("cwd")) + ?? ""; + + // Replace ~ with home directory. + cwd = untildify(cwd); + + // Use the cwd setting if it's absolute and exists. We don't use or resolve + // relative paths here because it'll be relative to the Code process's cwd, + // which is not what the user is expecting. + if (path.isAbsolute(cwd) && await utils.checkIfDirectoryExists(cwd)) { + return cwd; + } + + // If the cwd matches the name of a workspace folder, use it. Essentially + // "choose" a workspace folder based off the cwd path, and so set the state + // appropriately for `getChosenWorkspace`. + if (vscode.workspace.workspaceFolders) { + for (const workspaceFolder of vscode.workspace.workspaceFolders) { + // TODO: With some more work, we could support paths relative to a + // workspace folder name too. + if (cwd === workspaceFolder.name) { + hasChosen = true; + chosenWorkspace = workspaceFolder; + cwd = ""; + } + } + } + + // Otherwise get a cwd from the workspace, if possible. + const workspace = await getChosenWorkspace(logger); + if (workspace === undefined) { + logger?.writeVerbose("Workspace was undefined, using homedir!"); return os.homedir(); } - return cwd; + + const workspacePath = workspace.uri.fsPath; + + // Use the chosen workspace's root to resolve the cwd. + const relativePath = path.join(workspacePath, cwd); + if (await utils.checkIfDirectoryExists(relativePath)) { + return relativePath; + } + + // Just use the workspace path. + if (await utils.checkIfDirectoryExists(workspacePath)) { + return workspacePath; + } + + // If all else fails, use the home directory. + return os.homedir(); } diff --git a/test/TestEnvironment.code-workspace b/test/TestEnvironment.code-workspace index b4035e33c5..c204beb6d1 100644 --- a/test/TestEnvironment.code-workspace +++ b/test/TestEnvironment.code-workspace @@ -8,6 +8,6 @@ "settings": { "git.openRepositoryInParentFolders": "never", "csharp.suppressDotnetRestoreNotification": true, - "extensions.ignoreRecommendations": true + "extensions.ignoreRecommendations": true, } } diff --git a/test/core/platform.test.ts b/test/core/platform.test.ts index b5c082ec57..c8d05bacd6 100644 --- a/test/core/platform.test.ts +++ b/test/core/platform.test.ts @@ -451,6 +451,7 @@ if (process.platform === "win32") { additionalPowerShellExes = { "pwsh": "C:\\Users\\test\\pwsh\\pwsh.exe", + "pwsh-tilde": "~\\pwsh\\pwsh.exe", "pwsh-no-exe": "C:\\Users\\test\\pwsh\\pwsh", "pwsh-folder": "C:\\Users\\test\\pwsh\\", "pwsh-folder-no-slash": "C:\\Users\\test\\pwsh", @@ -466,15 +467,18 @@ if (process.platform === "win32") { isOS64Bit: true, isProcess64Bit: true, }, - environmentVars: { - "USERPROFILE": "C:\\Users\\test", - }, + environmentVars: {}, expectedPowerShellSequence: [ { exePath: "C:\\Users\\test\\pwsh\\pwsh.exe", displayName: "pwsh", supportsProperArguments: true }, + { + exePath: path.join(os.homedir(), "pwsh", "pwsh.exe"), + displayName: "pwsh-tilde", + supportsProperArguments: true + }, { exePath: "C:\\Users\\test\\pwsh\\pwsh", displayName: "pwsh-no-exe", @@ -747,6 +751,7 @@ if (process.platform === "win32") { additionalPowerShellExes = { "pwsh": "/home/bin/pwsh", + "pwsh-tilde": "~/bin/pwsh", "pwsh-folder": "/home/bin/", "pwsh-folder-no-slash": "/home/bin", "pwsh-single-quotes": "'/home/bin/pwsh'", @@ -761,15 +766,18 @@ if (process.platform === "win32") { isOS64Bit: true, isProcess64Bit: true, }, - environmentVars: { - "HOME": "/home/test", - }, + environmentVars: {}, expectedPowerShellSequence: [ { exePath: "/home/bin/pwsh", displayName: "pwsh", supportsProperArguments: true }, + { + exePath: path.join(os.homedir(), "bin", "pwsh"), + displayName: "pwsh-tilde", + supportsProperArguments: true + }, { exePath: "/home/bin/", displayName: "pwsh-folder", diff --git a/test/core/settings.test.ts b/test/core/settings.test.ts index 082f4cc626..306872a2dc 100644 --- a/test/core/settings.test.ts +++ b/test/core/settings.test.ts @@ -2,32 +2,104 @@ // Licensed under the MIT License. import * as assert from "assert"; +import * as os from "os"; import * as vscode from "vscode"; -import { Settings, getSettings, getEffectiveConfigurationTarget, changeSetting, CommentType } from "../../src/settings"; +import { + Settings, + getSettings, + getEffectiveConfigurationTarget, + changeSetting, + CommentType, + validateCwdSetting +} from "../../src/settings"; +import path from "path"; +import { ensureEditorServicesIsConnected } from "../utils"; describe("Settings E2E", function () { - it("Loads without error", function () { - assert.doesNotThrow(getSettings); + async function changeCwdSetting(cwd: string | undefined): Promise { + await changeSetting("cwd", cwd, vscode.ConfigurationTarget.Workspace, undefined); + } + + async function resetCwdSetting(): Promise { + await changeCwdSetting(undefined); + } + + describe("The 'getSettings' method loads the 'Settings' class", function () { + before(resetCwdSetting); + + it("Loads without error", function () { + assert.doesNotThrow(getSettings); + }); + + it("Loads the correct defaults", function () { + const testSettings = new Settings(); + const actualSettings = getSettings(); + assert.deepStrictEqual(actualSettings, testSettings); + }); }); - it("Loads the correct defaults", function () { - const testSettings = new Settings(); - const actualSettings = getSettings(); - assert.deepStrictEqual(actualSettings, testSettings); + describe("The 'changeSetting' method", function () { + it("Updates correctly", async function () { + await changeSetting("helpCompletion", CommentType.LineComment, vscode.ConfigurationTarget.Workspace, undefined); + assert.strictEqual(getSettings().helpCompletion, CommentType.LineComment); + }); }); - it("Updates correctly", async function () { - await changeSetting("helpCompletion", CommentType.LineComment, false, undefined); - assert.strictEqual(getSettings().helpCompletion, CommentType.LineComment); + describe("The 'getEffectiveConfigurationTarget' method'", function () { + it("Works for 'Workspace' target", async function () { + await changeSetting("helpCompletion", CommentType.LineComment, vscode.ConfigurationTarget.Workspace, undefined); + const target = getEffectiveConfigurationTarget("helpCompletion"); + assert.strictEqual(target, vscode.ConfigurationTarget.Workspace); + }); + + it("Works for 'undefined' target", async function () { + await changeSetting("helpCompletion", undefined, vscode.ConfigurationTarget.Workspace, undefined); + const target = getEffectiveConfigurationTarget("helpCompletion"); + assert.strictEqual(target, undefined); + }); }); - it("Gets the effective configuration target", async function () { - await changeSetting("helpCompletion", CommentType.LineComment, false, undefined); - let target = getEffectiveConfigurationTarget("helpCompletion"); - assert.strictEqual(target, vscode.ConfigurationTarget.Workspace); + describe("The CWD setting", function () { + // We're relying on this to be sure that the workspace is loaded. + before(ensureEditorServicesIsConnected); + before(resetCwdSetting); + afterEach(resetCwdSetting); + + const workspace = vscode.workspace.workspaceFolders![0].uri.fsPath; + + it("Defaults to the 'mocks' workspace folder", async function () { + assert.strictEqual(await validateCwdSetting(undefined), workspace); + }); + + it("Uses the default when given a non-existent folder", async function () { + await changeCwdSetting("/a/totally/fake/folder"); + assert.strictEqual(await validateCwdSetting(undefined), workspace); + }); + + it("Uses the given folder when it exists", async function () { + // A different than default folder that definitely exists + const cwd = path.resolve(path.join(process.cwd(), "..")); + await changeCwdSetting(cwd); + assert.strictEqual(await validateCwdSetting(undefined), cwd); + }); + + it("Uses the home folder for ~ (tilde)", async function () { + await changeCwdSetting("~"); + assert.strictEqual(await validateCwdSetting(undefined), os.homedir()); + }); + + it("Accepts relative paths", async function () { + // A different than default folder that definitely exists and is relative + const cwd = path.join("~", "somewhere", ".."); + const expected = path.join(os.homedir(), "somewhere", ".."); + await changeCwdSetting(cwd); + assert.strictEqual(await validateCwdSetting(undefined), expected); + }); - await changeSetting("helpCompletion", undefined, false, undefined); - target = getEffectiveConfigurationTarget("helpCompletion"); - assert.strictEqual(target, undefined); + it("Handles relative paths", async function () { + await changeCwdSetting("./BinaryModule"); + const expected = path.join(workspace, "./BinaryModule"); + assert.strictEqual(await validateCwdSetting(undefined), expected); + }); }); });