diff --git a/src/config.ts b/src/config.ts index fdd001d..148113b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -376,6 +376,8 @@ function parseFlagList(list: string[] | undefined, origin: string): Record c && c.trim()).join(separator); } +async function tryGetHome(ssh: Client): Promise { + const exec = await toPromise(cb => ssh.exec('echo Home: ~', cb)); + let home = ''; + exec.stdout.on('data', (chunk: any) => home += chunk); + await toPromise(cb => exec.on('close', cb)); + if (!home) return null; + const mat = home.match(/^Home: (.*?)\r?\n?$/); + if (!mat) return null; + return mat[1]; +} + export class ConnectionManager { protected onConnectionAddedEmitter = new vscode.EventEmitter(); protected onConnectionRemovedEmitter = new vscode.EventEmitter(); @@ -81,22 +94,32 @@ export class ConnectionManager { const logging = Logging.scope(`createConnection(${name},${config ? 'config' : 'undefined'})`); logging.info(`Creating a new connection for '${name}'`); const { createSSH, calculateActualConfig } = await import('./connect'); + // Query and calculate the actual config config = config || (await loadConfigs()).find(c => c.name === name); if (!config) throw new Error(`No configuration with name '${name}' found`); const actualConfig = await calculateActualConfig(config); if (!actualConfig) throw new Error('Connection cancelled'); + // Start the actual SSH connection const client = await createSSH(actualConfig); if (!client) throw new Error(`Could not create SSH session for '${name}'`); + // Query home directory + const home = await tryGetHome(client); + if (!home) { + await vscode.window.showErrorMessage(`Couldn't detect the home directory for '${name}'`, 'Okay'); + throw new Error(`Could not detect home directory`); + } + // Calculate the environment const environment: EnvironmentVariable[] = mergeEnvironment([], config.environment); + // Set up the Connection object let timeoutCounter = 0; const con: Connection = { - config, client, actualConfig, environment, + config, client, actualConfig, home, environment, terminals: [], filesystems: [], pendingUserCount: 0, idleTimer: setInterval(() => { // Automatically close connection when idle for a while timeoutCounter = timeoutCounter ? timeoutCounter - 1 : 0; - if (con.pendingUserCount) return; + if (con.pendingUserCount) return; // Still got starting filesystems/terminals on this connection con.filesystems = con.filesystems.filter(fs => !fs.closed && !fs.closing); if (con.filesystems.length) return; // Still got active filesystems on this connection if (con.terminals.length) return; // Still got active terminals on this connection diff --git a/src/fileSystemConfig.ts b/src/fileSystemConfig.ts index d11fea2..f35f7dc 100644 --- a/src/fileSystemConfig.ts +++ b/src/fileSystemConfig.ts @@ -88,7 +88,7 @@ export interface FileSystemConfig extends ConnectConfig { group?: string; /** Whether to merge this "lower" config (e.g. from workspace settings) into higher configs (e.g. from global settings) */ merge?: boolean; - /** Path on the remote server where the root path in vscode should point to. Defaults to / */ + /** Path on the remote server that should be opened by default when creating a terminal or using the `Add as Workspace folder` command/button. Defaults to `/` */ root?: string; /** A name of a PuTTY session, or `true` to find the PuTTY session from the host address */ putty?: string | boolean; diff --git a/src/fileSystemRouter.ts b/src/fileSystemRouter.ts index dd6c442..f7c259d 100644 --- a/src/fileSystemRouter.ts +++ b/src/fileSystemRouter.ts @@ -1,6 +1,4 @@ -import * as path from 'path'; import * as vscode from 'vscode'; -import type { FileSystemConfig } from './fileSystemConfig'; import { Logging } from './logging'; import type { Manager } from './manager'; @@ -12,20 +10,6 @@ function isWorkspaceStale(uri: vscode.Uri) { return true; } -export function 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); - if (result.startsWith('~')) return result; // Home directory, leave the ~/ - if (result.startsWith('/')) return result; // Already starts with / - return '/' + result; // Add the / to make sure it isn't seen as a relative path -} - export class FileSystemRouter implements vscode.FileSystemProvider { public onDidChangeFile: vscode.Event; protected onDidChangeFileEmitter = new vscode.EventEmitter(); diff --git a/src/manager.ts b/src/manager.ts index 67a9406..4df0568 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -1,28 +1,14 @@ -import * as path from 'path'; -import type { Client, ClientChannel } from 'ssh2'; import * as vscode from 'vscode'; import { getConfig, getFlagBoolean, loadConfigsRaw } from './config'; import { Connection, ConnectionManager, joinCommands } from './connection'; import type { FileSystemConfig } from './fileSystemConfig'; -import { getRemotePath } from './fileSystemRouter'; import { Logging, LOGGING_NO_STACKTRACE } from './logging'; import { isSSHPseudoTerminal, replaceVariables, replaceVariablesRecursive } from './pseudoTerminal'; import type { SSHFileSystem } from './sshFileSystem'; -import { catchingPromise, toPromise } from './toPromise'; +import { catchingPromise } from './toPromise'; import type { Navigation } from './webviewMessages'; -async function tryGetHome(ssh: Client): Promise { - const exec = await toPromise(cb => ssh.exec('echo Home: ~', cb)); - let home = ''; - exec.stdout.on('data', (chunk: any) => home += chunk); - await toPromise(cb => exec.on('close', cb)); - if (!home) return null; - const mat = home.match(/^Home: (.*?)\r?\n?$/); - if (!mat) return null; - return mat[1]; -} - function commandArgumentToName(arg?: string | FileSystemConfig | Connection): string { if (!arg) return 'undefined'; if (typeof arg === 'string') return arg; @@ -71,37 +57,10 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider config = con.actualConfig; const { getSFTP } = await import('./connect'); const { SSHFileSystem } = await import('./sshFileSystem'); - // Query/calculate the root directory - let root = config!.root || '/'; - if (root.startsWith('~')) { - const home = await tryGetHome(con.client); - if (!home) { - await vscode.window.showErrorMessage(`Couldn't detect the home directory for '${name}'`, 'Okay'); - return reject(); - } - root = root.replace(/^~/, home.replace(/\/$/, '')); - } // Create the actual SFTP session (using the connection's actualConfig, otherwise it'll reprompt for passwords etc) const sftp = await getSFTP(con.client, con.actualConfig); - const fs = new SSHFileSystem(name, sftp, root, config!); + const fs = new SSHFileSystem(name, sftp, con.actualConfig); Logging.info(`Created SSHFileSystem for ${name}, reading root directory...`); - // Sanity check that we can actually access the root directory (maybe it requires permissions we don't have) - try { - const rootUri = vscode.Uri.parse(`ssh://${name}/`); - const stat = await fs.stat(rootUri); - // tslint:disable-next-line:no-bitwise - if (!(stat.type & vscode.FileType.Directory)) { - throw vscode.FileSystemError.FileNotADirectory(rootUri); - } - } catch (e) { - let message = `Couldn't read the root directory '${fs.root}' on the server for SSH FS '${name}'`; - if (e instanceof vscode.FileSystemError) { - message = `Path '${fs.root}' in SSH FS '${name}' is not a directory`; - } - Logging.error(e); - await vscode.window.showErrorMessage(message, 'Okay'); - return reject(); - } this.connectionManager.update(con, con => con.filesystems.push(fs)); this.fileSystems.push(fs); delete this.creatingFileSystems[name]; @@ -112,6 +71,23 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer'); // con.client.once('close', hadError => !fs.closing && this.promptReconnect(name)); this.connectionManager.update(con, con => con.pendingUserCount--); + // Sanity check that we can access the home directory + const [flagCH] = getFlagBoolean('CHECK_HOME', true, config.flags); + if (flagCH) try { + const homeUri = vscode.Uri.parse(`ssh://${name}/${con.home}`); + const stat = await fs.stat(homeUri); + if (!(stat.type & vscode.FileType.Directory)) { + throw vscode.FileSystemError.FileNotADirectory(homeUri); + } + } catch (e) { + let message = `Couldn't read the home directory '${con.home}' on the server for SSH FS '${name}', this might be a sign of bad permissions`; + if (e instanceof vscode.FileSystemError) { + message = `The home directory '${con.home}' in SSH FS '${name}' is not a directory, this might be a sign of bad permissions`; + } + Logging.error(e); + const answer = await vscode.window.showWarningMessage(message, 'Stop', 'Ignore'); + if (answer === 'Okay') return reject(new Error('User stopped filesystem creation after unaccessible home directory error')); + } return resolve(fs); }).catch((e) => { if (con) this.connectionManager.update(con, con => con.pendingUserCount--); // I highly doubt resolve(fs) will error @@ -139,11 +115,9 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider const { createTerminal } = await import('./pseudoTerminal'); // 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 - const workingDirectory = uri && getRemotePath(con.actualConfig, uri); // Create pseudo terminal this.connectionManager.update(con, con => con.pendingUserCount++); - const pty = await createTerminal({ connection: con, workingDirectory }); + const pty = await createTerminal({ connection: con, workingDirectory: uri?.path || con.actualConfig.root }); pty.onDidClose(() => this.connectionManager.update(con, con => con.terminals = con.terminals.filter(t => t !== pty))); this.connectionManager.update(con, con => (con.terminals.push(pty), con.pendingUserCount--)); // Create and show the graphical representation @@ -221,22 +195,14 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider while (true) { const match = PATH_REGEX.exec(line); if (!match) break; - const [filepath] = match; - let relative: string | undefined; - for (const fs of conn.filesystems) { - const rel = path.posix.relative(fs.root, filepath); - if (!rel.startsWith('../') && !path.posix.isAbsolute(rel)) { - relative = rel; - break; - } - } - const uri = relative ? vscode.Uri.parse(`ssh://${conn.actualConfig.name}/${relative}`) : undefined; - // TODO: Support absolute path stuff, maybe `ssh://${conn.actualConfig.name}:root//${filepath}` or so? + let [filepath] = match; + if (filepath.startsWith('~')) filepath = conn.home + filepath.substring(1); + const uri = vscode.Uri.parse(`ssh://${conn.actualConfig.name}/${filepath}`); links.push({ uri, startIndex: match.index, length: filepath.length, - tooltip: uri ? '[SSH FS] Open file' : '[SSH FS] Cannot open remote file outside configured root directory', + tooltip: '[SSH FS] Open file', }); } return links; @@ -246,13 +212,19 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider await vscode.window.showTextDocument(link.uri); } /* Commands (stuff for e.g. context menu for ssh-configs tree) */ - public commandConnect(config: FileSystemConfig) { + public async commandConnect(config: FileSystemConfig) { Logging.info(`Command received to connect ${config.name}`); const folders = vscode.workspace.workspaceFolders!; const folder = folders && folders.find(f => f.uri.scheme === 'ssh' && f.uri.authority === config.name); if (folder) return vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer'); + let { root = '/' } = config; + if (root.startsWith('~')) { + const con = this.connectionManager.getActiveConnection(config.name, config); + if (con) root = con.home + root.substring(1); + } + if (root.startsWith('/')) root = root.substring(1); vscode.workspace.updateWorkspaceFolders(folders ? folders.length : 0, 0, { - uri: vscode.Uri.parse(`ssh://${config.name}/`), + uri: vscode.Uri.parse(`ssh://${config.name}/${root}`), name: `SSH FS - ${config.label || config.name}`, }); } @@ -286,8 +258,6 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider public async commandTerminal(target: FileSystemConfig | Connection, uri?: vscode.Uri) { Logging.info(`Command received to open a terminal for ${commandArgumentToName(target)}${uri ? ` in ${uri}` : ''}`); const config = 'client' in target ? target.actualConfig : target; - // If no Uri is given, default to ssh:/// which should respect config.root - uri = uri || vscode.Uri.parse(`ssh://${config.name}/`, true); try { await this.createTerminal(config.label || config.name, target, uri); } catch (e) { diff --git a/src/pseudoTerminal.ts b/src/pseudoTerminal.ts index 9ca2aee..fbb7a18 100644 --- a/src/pseudoTerminal.ts +++ b/src/pseudoTerminal.ts @@ -5,7 +5,6 @@ import * as vscode from "vscode"; import { getFlagBoolean } from './config'; import { Connection, environmentToExportString, joinCommands, mergeEnvironment } from './connection'; import type { EnvironmentVariable, FileSystemConfig } from "./fileSystemConfig"; -import { getRemotePath } from './fileSystemRouter'; import { Logging, LOGGING_NO_STACKTRACE } from "./logging"; import { toPromise } from "./toPromise"; @@ -77,14 +76,14 @@ export function replaceVariables(value: string, config: FileSystemConfig): strin switch (key) { case 'remoteWorkspaceRoot': case 'remoteWorkspaceFolder': - return getRemotePath(config, getFolderUri()); + return getFolderUri().path; case 'remoteWorkspaceRootFolderName': case 'remoteWorkspaceFolderBasename': return path.basename(getFolderUri().path); case 'remoteFile': - return getRemotePath(config, getFilePath()); + return getFilePath().path; case 'remoteFileWorkspaceFolder': - return getRemotePath(config, getFolderPathForFile()); + return getFolderPathForFile().path; case 'remoteRelativeFile': if (sshFolder || argument) return path.relative(getFolderUri().path, getFilePath().path); @@ -181,6 +180,7 @@ export async function createTerminal(options: TerminalOptions): Promise (this.closed = true, this.onCloseEmitter.fire())); this.logging.info('SSHFileSystem created'); } @@ -52,14 +51,6 @@ export class SSHFileSystem implements vscode.FileSystemProvider { this.closing = true; this.sftp.end(); } - public relative(relPath: string) { - // ssh://a/b/c.d should result in relPath being "/b/c.d" - // So // means absolute path, / means relative path - // NOTE: Apparently VSCode automatically replaces multiple slashes with a single / - // (so the // part is useless right now) - if (relPath.startsWith('//')) return relPath.substr(1); - return path.posix.resolve(this.root, relPath.substr(1)); - } public continuePromise(func: (cb: (err: Error | null | undefined, res?: T) => void) => boolean): Promise { return new Promise((resolve, reject) => { const exec = () => { @@ -85,7 +76,7 @@ export class SSHFileSystem implements vscode.FileSystemProvider { return new vscode.Disposable(() => { }); } public async stat(uri: vscode.Uri): Promise { - const stat = await this.continuePromise(cb => this.sftp.stat(this.relative(uri.path), cb)) + const stat = await this.continuePromise(cb => this.sftp.stat(uri.path, cb)) .catch(e => this.handleError(uri, e, true) as never); const { mtime, size } = stat; let type = vscode.FileType.Unknown; @@ -100,7 +91,7 @@ export class SSHFileSystem implements vscode.FileSystemProvider { }; } public async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { - const entries = await this.continuePromise(cb => this.sftp.readdir(this.relative(uri.path), cb)) + const entries = await this.continuePromise(cb => this.sftp.readdir(uri.path, cb)) .catch((e) => this.handleError(uri, e, true) as never); return Promise.all(entries.map(async (file) => { const furi = uri.with({ path: `${uri.path}${uri.path.endsWith('/') ? '' : '/'}${file.filename}` }); @@ -121,11 +112,11 @@ export class SSHFileSystem implements vscode.FileSystemProvider { })); } public createDirectory(uri: vscode.Uri): void | Promise { - return this.continuePromise(cb => this.sftp.mkdir(this.relative(uri.path), cb)).catch(e => this.handleError(uri, e, true)); + return this.continuePromise(cb => this.sftp.mkdir(uri.path, cb)).catch(e => this.handleError(uri, e, true)); } public readFile(uri: vscode.Uri): Uint8Array | Promise { return new Promise((resolve, reject) => { - const stream = this.sftp.createReadStream(this.relative(uri.path), { autoClose: true }); + const stream = this.sftp.createReadStream(uri.path, { autoClose: true }); const bufs = []; stream.on('data', bufs.push.bind(bufs)); stream.on('error', e => this.handleError(uri, e, reject)); @@ -139,7 +130,7 @@ export class SSHFileSystem implements vscode.FileSystemProvider { let mode: number | string | undefined; let fileExists = false; try { - const stat = await this.continuePromise(cb => this.sftp.stat(this.relative(uri.path), cb)); + const stat = await this.continuePromise(cb => this.sftp.stat(uri.path, cb)); mode = stat.mode; fileExists = true; } catch (e) { @@ -147,11 +138,11 @@ export class SSHFileSystem implements vscode.FileSystemProvider { mode = this.config.newFileMode; } else { this.handleError(uri, e); - vscode.window.showWarningMessage(`Couldn't read the permissions for '${this.relative(uri.path)}', permissions might be overwritten`); + vscode.window.showWarningMessage(`Couldn't read the permissions for '${uri.path}', permissions might be overwritten`); } } mode = mode as number | undefined; // ssh2-streams supports an octal number as string, but ssh2's typings don't reflect this - const stream = this.sftp.createWriteStream(this.relative(uri.path), { mode, flags: 'w' }); + const stream = this.sftp.createWriteStream(uri.path, { mode, flags: 'w' }); stream.on('error', e => this.handleError(uri, e, reject)); stream.end(content, () => { this.onDidChangeFileEmitter.fire([{ uri, type: fileExists ? vscode.FileChangeType.Changed : vscode.FileChangeType.Created }]); @@ -164,18 +155,18 @@ export class SSHFileSystem implements vscode.FileSystemProvider { const fireEvent = () => this.onDidChangeFileEmitter.fire([{ uri, type: vscode.FileChangeType.Deleted }]); // tslint:disable no-bitwise */ if (stats.type & (vscode.FileType.SymbolicLink | vscode.FileType.File)) { - return this.continuePromise(cb => this.sftp.unlink(this.relative(uri.path), cb)) + return this.continuePromise(cb => this.sftp.unlink(uri.path, cb)) .then(fireEvent).catch(e => this.handleError(uri, e, true)); } else if ((stats.type & vscode.FileType.Directory) && options.recursive) { - return this.continuePromise(cb => this.sftp.rmdir(this.relative(uri.path), cb)) + return this.continuePromise(cb => this.sftp.rmdir(uri.path, cb)) .then(fireEvent).catch(e => this.handleError(uri, e, true)); } - return this.continuePromise(cb => this.sftp.unlink(this.relative(uri.path), cb)) + return this.continuePromise(cb => this.sftp.unlink(uri.path, cb)) .then(fireEvent).catch(e => this.handleError(uri, e, true)); // tslint:enable no-bitwise */ } public rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean; }): void | Promise { - return this.continuePromise(cb => this.sftp.rename(this.relative(oldUri.path), this.relative(newUri.path), cb)) + return this.continuePromise(cb => this.sftp.rename(oldUri.path, newUri.path, cb)) .then(() => this.onDidChangeFileEmitter.fire([ { uri: oldUri, type: vscode.FileChangeType.Deleted }, { uri: newUri, type: vscode.FileChangeType.Created } diff --git a/src/ui-utils.ts b/src/ui-utils.ts index d3e9828..6c5aea0 100644 --- a/src/ui-utils.ts +++ b/src/ui-utils.ts @@ -65,8 +65,10 @@ export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem | contextValue: 'connection', }; } else if ('onDidChangeFile' in item) { // SSHFileSystem + const { label, name, group } = item.config; + const description = group ? `${group}.${name} ` : (label && name); return { - item, description: item.root, contextValue: 'filesystem', + item, description, contextValue: 'filesystem', label: `${iconInLabel ? '$(root-folder) ' : ''}ssh://${item.authority}/`, iconPath: asAbsolutePath?.('resources/icon.svg'), } diff --git a/webview/src/ConfigEditor/fields.tsx b/webview/src/ConfigEditor/fields.tsx index 53a94b0..19e924d 100644 --- a/webview/src/ConfigEditor/fields.tsx +++ b/webview/src/ConfigEditor/fields.tsx @@ -70,7 +70,7 @@ export function port(config: FileSystemConfig, onChange: FSCChanged<'port'>): Re export function root(config: FileSystemConfig, onChange: FSCChanged<'root'>): React.ReactElement { const callback = (value: string) => onChange('root', value); - const description = 'Path on the remote server where the root path in vscode should point to. Defaults to /'; + const description = 'Path on the remote server that should be opened by default when creating a terminal or using the `Add as Workspace folder` command/button. Defaults to `/`'; return } diff --git a/webview/src/types/fileSystemConfig.ts b/webview/src/types/fileSystemConfig.ts index d11fea2..f35f7dc 100644 --- a/webview/src/types/fileSystemConfig.ts +++ b/webview/src/types/fileSystemConfig.ts @@ -88,7 +88,7 @@ export interface FileSystemConfig extends ConnectConfig { group?: string; /** Whether to merge this "lower" config (e.g. from workspace settings) into higher configs (e.g. from global settings) */ merge?: boolean; - /** Path on the remote server where the root path in vscode should point to. Defaults to / */ + /** Path on the remote server that should be opened by default when creating a terminal or using the `Add as Workspace folder` command/button. Defaults to `/` */ root?: string; /** A name of a PuTTY session, or `true` to find the PuTTY session from the host address */ putty?: string | boolean;