diff --git a/src/manager.ts b/src/manager.ts index 9a7c0cd..55c8b2c 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { getConfig, loadConfigsRaw } from './config'; import { Connection, ConnectionManager } from './connection'; import type { FileSystemConfig } from './fileSystemConfig'; -import { Logging } from './logging'; +import { Logging, LOGGING_NO_STACKTRACE } from './logging'; import { isSSHPseudoTerminal } from './pseudoTerminal'; import type { SSHFileSystem } from './sshFileSystem'; import { catchingPromise, toPromise } from './toPromise'; @@ -29,7 +29,7 @@ function commandArgumentToName(arg?: string | FileSystemConfig | Connection): st return `FileSystemConfig(${arg.name})`; } -interface SSHShellTaskOptions { +interface SSHShellTaskOptions extends vscode.TaskDefinition { host: string; command: string; workingDirectory?: string; @@ -134,7 +134,12 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider throw e; }); } - public getRemotePath(config: FileSystemConfig, relativePath: string) { + public getRemotePath(config: FileSystemConfig, relativePath: string | vscode.Uri) { + if (relativePath instanceof vscode.Uri) { + if (relativePath.authority !== config.name) + throw new Error(`Uri authority for '${relativePath}' does not match config with name '${config.name}'`); + relativePath = relativePath.path; + } if (relativePath.startsWith('/')) relativePath = relativePath.substr(1); if (!config.root) return '/' + relativePath; const result = path.posix.join(config.root, relativePath); @@ -147,8 +152,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider // Create connection (early so we have .actualConfig.root) const con = (config && 'client' in config) ? config : await this.connectionManager.createConnection(name, config); // Calculate working directory if applicable - let workingDirectory: string | undefined = uri && uri.path; - if (workingDirectory) workingDirectory = this.getRemotePath(con.actualConfig, workingDirectory); + const workingDirectory = uri && this.getRemotePath(con.actualConfig, uri); // Create pseudo terminal this.connectionManager.update(con, con => con.pendingUserCount++); const pty = await createTerminal({ client: con.client, config: con.actualConfig, workingDirectory }); @@ -169,41 +173,137 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider } public async promptReconnect(name: string) { const config = getConfig(name); - console.log('config', name, config); if (!config) return; const choice = await vscode.window.showWarningMessage(`SSH FS ${config.label || config.name} disconnected`, 'Ignore', 'Disconnect'); if (choice === 'Disconnect') this.commandDisconnect(name); } /* TaskProvider */ + protected async replaceTaskVariables(value: string, config: FileSystemConfig): Promise { + return value.replace(/\$\{(.*?)\}/g, (str, match: string) => { + if (!match.startsWith('remote')) return str; // Our variables always start with "remote" + // https://github.com/microsoft/vscode/blob/bebd06640734c37f6d5f1a82b13297ce1d297dd1/src/vs/workbench/services/configurationResolver/common/variableResolver.ts#L156 + const [key, argument] = match.split(':') as [string, string?]; + const getFilePath = (): vscode.Uri => { + const uri = vscode.window.activeTextEditor?.document?.uri; + if (uri && uri.scheme === 'ssh') return uri; + if (uri) throw new Error(`Variable ${str}: Active editor is not a ssh:// file`); + throw new Error(`Variable ${str} can not be resolved. Please open an editor.`); + } + const getFolderPathForFile = (): vscode.Uri => { + const filePath = getFilePath(); + const uri = vscode.workspace.getWorkspaceFolder(filePath)?.uri; + if (uri) return uri; + throw new Error(`Variable ${str}: can not find workspace folder of '${filePath}'.`); + } + const { workspaceFolders = [] } = vscode.workspace; + const sshFolders = workspaceFolders.filter(ws => ws.uri.scheme === 'ssh'); + const sshFolder = sshFolders.length === 1 ? sshFolders[0] : undefined; + const getFolderUri = (): vscode.Uri => { + const { workspaceFolders = [] } = vscode.workspace; + if (argument) { + const uri = workspaceFolders.find(ws => ws.name === argument)?.uri; + if (uri && uri.scheme === 'ssh') return uri; + if (uri) throw new Error(`Variable ${str}: Workspace folder '${argument}' is not a ssh:// folder`); + throw new Error(`Variable ${str} can not be resolved. No such folder '${argument}'.`); + } + if (sshFolder) return sshFolder.uri; + if (sshFolders.length > 1) { + throw new Error(`Variable ${str} can not be resolved in a multi ssh:// folder workspace. Scope this variable using ':' and a workspace folder name.`); + } + throw new Error(`Variable ${str} can not be resolved. Please open an ssh:// folder.`); + }; + switch (key) { + case 'remoteWorkspaceRoot': + case 'remoteWorkspaceFolder': + return this.getRemotePath(config, getFolderUri()); + case 'remoteWorkspaceRootFolderName': + case 'remoteWorkspaceFolderBasename': + return path.basename(getFolderUri().path); + case 'remoteFile': + return this.getRemotePath(config, getFilePath()); + case 'remoteFileWorkspaceFolder': + return this.getRemotePath(config, getFolderPathForFile()); + case 'remoteRelativeFile': + if (sshFolder || argument) + return path.relative(getFolderUri().path, getFilePath().path); + return getFilePath().path; + case 'remoteRelativeFileDirname': { + const dirname = path.dirname(getFilePath().path); + if (sshFolder || argument) { + const relative = path.relative(getFolderUri().path, dirname); + return relative.length === 0 ? '.' : relative; + } + return dirname; + } + case 'remoteFileDirname': + return path.dirname(getFilePath().path); + case 'remoteFileExtname': + return path.extname(getFilePath().path); + case 'remoteFileBasename': + return path.basename(getFilePath().path); + case 'remoteFileBasenameNoExtension': { + const basename = path.basename(getFilePath().path); + return (basename.slice(0, basename.length - path.extname(basename).length)); + } + case 'remoteFileDirnameBasename': + return path.basename(path.dirname(getFilePath().path)); + case 'remotePathSeparator': + // Not sure if we even need/want this variable, but sure + return path.posix.sep; + default: + const msg = `Unrecognized task variable '${str}' starting with 'remote', ignoring`; + Logging.warning(msg, LOGGING_NO_STACKTRACE); + vscode.window.showWarningMessage(msg); + return str; + } + }); + } + protected async replaceTaskVariablesRecursive(object: T, handler: (value: string) => string | Promise): Promise { + if (typeof object === 'string') return handler(object) as any; + if (Array.isArray(object)) return object.map(v => this.replaceTaskVariablesRecursive(v, handler)) as any; + if (typeof object == 'object' && object !== null && !(object instanceof RegExp) && !(object instanceof Date)) { + // ^ Same requirements VS Code applies: https://github.com/microsoft/vscode/blob/bebd06640734c37f6d5f1a82b13297ce1d297dd1/src/vs/base/common/types.ts#L34 + const result: any = {}; + for (let key in object) { + const value = await this.replaceTaskVariablesRecursive(object[key], handler); + key = await this.replaceTaskVariablesRecursive(key, handler); + result[key] = value; + } + return result; + } + return object; + } public provideTasks(token?: vscode.CancellationToken | undefined): vscode.ProviderResult { return []; } public async resolveTask(task: vscode.Task, token?: vscode.CancellationToken | undefined): Promise { - let { host, command, workingDirectory } = task.definition as unknown as SSHShellTaskOptions; - if (!host) throw new Error('Missing field \'host\' for ssh-shell task'); - if (!command) throw new Error('Missing field \'command\' for ssh-shell task'); - const config = getConfig(host); - if (!config) throw new Error(`No configuration with the name '${host}' found for ssh-shell task`); - // Calculate working directory if applicable - if (workingDirectory) workingDirectory = this.getRemotePath(config, workingDirectory); return new vscode.Task( - task.definition, + task.definition, // Can't replace/modify this, otherwise we're not contributing to "this" task vscode.TaskScope.Workspace, - `SSH Task '${task.name}' for ${host}`, + `SSH Task '${task.name}'`, 'ssh', - new vscode.CustomExecution(async () => { - const connection = await this.connectionManager.createConnection(host); - this.connectionManager.update(connection, con => con.pendingUserCount++); - const { createTerminal } = await import('./pseudoTerminal'); - const pty = await createTerminal({ - command, workingDirectory, - client: connection.client, - config: connection.actualConfig, - }); - this.connectionManager.update(connection, con => (con.pendingUserCount--, con.terminals.push(pty))); - pty.onDidClose(() => this.connectionManager.update(connection, - con => con.terminals = con.terminals.filter(t => t !== pty))); - return pty; + new vscode.CustomExecution(async (resolved: SSHShellTaskOptions) => { + const { createTerminal, createTextTerminal } = await import('./pseudoTerminal'); + try { + if (!resolved.host) throw new Error('Missing field \'host\' in task description'); + if (!resolved.command) throw new Error('Missing field \'command\' in task description'); + const connection = await this.connectionManager.createConnection(resolved.host); + resolved = await this.replaceTaskVariablesRecursive(resolved, value => this.replaceTaskVariables(value, connection.actualConfig)); + const { command, workingDirectory } = resolved; + //if (workingDirectory) workingDirectory = this.getRemotePath(config, workingDirectory); + this.connectionManager.update(connection, con => con.pendingUserCount++); + const pty = await createTerminal({ + command, workingDirectory, + client: connection.client, + config: connection.actualConfig, + }); + this.connectionManager.update(connection, con => (con.pendingUserCount--, con.terminals.push(pty))); + pty.onDidClose(() => this.connectionManager.update(connection, + con => con.terminals = con.terminals.filter(t => t !== pty))); + return pty; + } catch (e) { + return createTextTerminal(`Error: ${e.message || e}`); + } }) ) } diff --git a/src/pseudoTerminal.ts b/src/pseudoTerminal.ts index c4ef2d1..4e34d0f 100644 --- a/src/pseudoTerminal.ts +++ b/src/pseudoTerminal.ts @@ -116,3 +116,24 @@ export async function createTerminal(options: TerminalOptions): Promise; // Redeclaring that it isn't undefined + onDidOpen: vscode.Event; +} + +export function createTextTerminal(initialText?: string): TextTerminal { + const onDidWrite = new vscode.EventEmitter(); + const onDidClose = new vscode.EventEmitter(); + const onDidOpen = new vscode.EventEmitter(); + return { + write: onDidWrite.fire.bind(onDidWrite), + close: onDidClose.fire.bind(onDidClose), + onDidWrite: onDidWrite.event, + onDidClose: onDidClose.event, + onDidOpen: onDidOpen.event, + open: () => initialText && (onDidWrite.fire(initialText + '\r\n'), onDidClose.fire(1)), + }; +}