Skip to content

Commit

Permalink
File Explorer: Introduce sftp connection method (#139)
Browse files Browse the repository at this point in the history
Currently behind the setting: `tailscale.nodeExplorer.connectionMethod`
which defaults to `ssh`

---------

Signed-off-by: Tyler Smalley <[email protected]>
Co-authored-by: Marwan Sulaiman <[email protected]>
  • Loading branch information
Tyler Smalley and marwan-at-work authored Aug 1, 2023
1 parent 535d928 commit 6eb7604
Show file tree
Hide file tree
Showing 17 changed files with 750 additions and 128 deletions.
22 changes: 22 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@
"command": "tailscale.node.fs.delete",
"title": "Delete"
},
{
"command": "tailscale.node.fs.createDirectory",
"title": "Create Directory"
},
{
"command": "tailscale.node.setUsername",
"title": "Change SSH username"
Expand Down Expand Up @@ -341,6 +345,15 @@
"default": false,
"markdownDescription": "(IN DEVELOPMENT) Enable the Tailscale Node Explorer view."
},
"tailscale.nodeExplorer.connectionMethod": {
"type": "string",
"enum": [
"ssh",
"sftp"
],
"default": "ssh",
"markdownDescription": "The connection method to use interacting with the File system view within the Node Explorer view."
},
"tailscale.ssh.defaultUsername": {
"type": "string",
"default": null,
Expand All @@ -349,6 +362,12 @@
"examples": [
"amelie"
]
},
"tailscale.ssh.connectionTimeout": {
"type": "number",
"default": 30000,
"markdownDescription": "The connection timeout for SSH connections in milliseconds.",
"scope": "window"
}
}
}
Expand All @@ -372,6 +391,7 @@
"@types/node": "16.11.68",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/ssh2": "^1.11.13",
"@types/vscode": "^1.74.0",
"@types/vscode-webview": "^1.57.1",
"@typescript-eslint/eslint-plugin": "^6.1.0",
Expand All @@ -391,11 +411,13 @@
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"node-fetch": "^3.3.1",
"node-loader": "^2.0.0",
"postcss": "^8.4.26",
"postcss-loader": "^7.3.3",
"prettier": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ssh2": "^1.14.0",
"style-loader": "^3.3.3",
"swr": "^2.2.0",
"tailwindcss": "^3.3.3",
Expand Down
2 changes: 1 addition & 1 deletion src/config-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { test, expect, beforeEach } from 'vitest';
import { test, expect, beforeEach, vi } from 'vitest';
import { ConfigManager } from './config-manager';

const fsPath = '/tmp/vscode-tailscale';
Expand Down
4 changes: 2 additions & 2 deletions src/config-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as vscode from 'vscode';
import { Uri } from 'vscode';
import * as fs from 'fs';
import * as path from 'path';

Expand All @@ -24,7 +24,7 @@ export class ConfigManager {
}
}

static withGlobalStorageUri(globalStorageUri: vscode.Uri) {
static withGlobalStorageUri(globalStorageUri: Uri) {
const globalStoragePath = globalStorageUri.fsPath;

if (!fs.existsSync(globalStoragePath)) {
Expand Down
21 changes: 15 additions & 6 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import { Logger } from './logger';
import { errorForType } from './tailscale/error';
import { FileExplorer, NodeExplorerProvider, PeerTree } from './node-explorer-provider';

import { TSFileSystemProvider } from './ts-file-system-provider';
import { FileSystemProviderSFTP } from './filesystem-provider-sftp';
import { ConfigManager } from './config-manager';
import { SSH } from './utils/ssh';
import { parseTsUri } from './utils/uri';
import { EXTENSION_NS } from './constants';
import { FileSystemProviderSSH } from './filesystem-provider-ssh';
import { WithFSTiming } from './filesystem-provider-timing';
import { FileSystemProvider } from './filesystem-provider';

let tailscaleInstance: Tailscale;

Expand All @@ -21,7 +24,6 @@ export async function activate(context: vscode.ExtensionContext) {
tailscaleInstance = await Tailscale.withInit(vscode);

const configManager = ConfigManager.withGlobalStorageUri(context.globalStorageUri);
const ssh = new SSH(configManager);

// walkthrough completion
tailscaleInstance.serveStatus().then((status) => {
Expand Down Expand Up @@ -55,9 +57,16 @@ export async function activate(context: vscode.ExtensionContext) {
tailscaleInstance
);

const tsFileSystemProvider = new TSFileSystemProvider(configManager);
const connMethod = vscode.workspace
.getConfiguration(EXTENSION_NS)
.get('nodeExplorer.connectionMethod');

const Provider = connMethod === 'ssh' ? FileSystemProviderSSH : FileSystemProviderSFTP;
let fileSystemProvider: FileSystemProvider = new Provider(configManager);
fileSystemProvider = new WithFSTiming(fileSystemProvider);

context.subscriptions.push(
vscode.workspace.registerFileSystemProvider('ts', tsFileSystemProvider, {
vscode.workspace.registerFileSystemProvider('ts', fileSystemProvider, {
isCaseSensitive: true,
})
);
Expand All @@ -80,7 +89,7 @@ export async function activate(context: vscode.ExtensionContext) {
const nodeExplorerProvider = new NodeExplorerProvider(
tailscaleInstance,
configManager,
ssh,
fileSystemProvider,
updateNodeExplorerTailnetName
);

Expand Down
108 changes: 108 additions & 0 deletions src/filesystem-provider-sftp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as vscode from 'vscode';
import { Logger } from './logger';
import { ConfigManager } from './config-manager';
import { parseTsUri } from './utils/uri';
import { SshConnectionManager } from './ssh-connection-manager';
import { fileSorter } from './filesystem-provider';

export class FileSystemProviderSFTP implements vscode.FileSystemProvider {
public manager: SshConnectionManager;

constructor(configManager: ConfigManager) {
this.manager = new SshConnectionManager(configManager);
}

// Implementation of the `onDidChangeFile` event
onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]> = new vscode.EventEmitter<
vscode.FileChangeEvent[]
>().event;

watch(): vscode.Disposable {
throw new Error('Watch not supported');
}

async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
Logger.info(`readDirectory: ${uri}`, `tsFs-sftp`);
const { hostname, resourcePath } = parseTsUri(uri);

const sftp = await this.manager.getSftp(hostname);
if (!sftp) {
throw new Error('Unable to establish SFTP connection');
}

const files = await sftp.readDirectory(resourcePath);
return files.sort(fileSorter);
}

async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
Logger.info(`stat: ${uri}`, 'tsFs-sftp');
const { hostname, resourcePath } = parseTsUri(uri);

const sftp = await this.manager.getSftp(hostname);
if (!sftp) {
throw new Error('Unable to establish SFTP connection');
}

return await sftp.stat(resourcePath);
}

async createDirectory(uri: vscode.Uri): Promise<void> {
try {
Logger.info(`createDirectory: ${uri}`, 'tsFs-sftp');
const { hostname, resourcePath } = parseTsUri(uri);

const sftp = await this.manager.getSftp(hostname);
if (!sftp) throw new Error('Failed to establish SFTP connection');

return await sftp.createDirectory(resourcePath);
} catch (err) {
Logger.error(`createDirectory: ${err}`, 'tsFs-sftp');
throw err;
}
}

async getHomeDirectory(hostname: string): Promise<string> {
const sftp = await this.manager.getSftp(hostname);
if (!sftp) throw new Error('Failed to establish SFTP connection');

return await sftp.getHomeDirectory();
}

async readFile(uri: vscode.Uri): Promise<Uint8Array> {
Logger.info(`readFile: ${uri}`, 'tsFs-sftp');
const { hostname, resourcePath } = parseTsUri(uri);

const sftp = await this.manager.getSftp(hostname);
if (!sftp) {
throw new Error('Unable to establish SFTP connection');
}

return await sftp.readFile(resourcePath);
}

async writeFile(uri: vscode.Uri, content: Uint8Array): Promise<void> {
Logger.info(`readFile: ${uri}`, 'tsFs-sftp');
const { hostname, resourcePath } = parseTsUri(uri);

const sftp = await this.manager.getSftp(hostname);
if (!sftp) {
throw new Error('Unable to establish SFTP connection');
}

return await sftp.writeFile(resourcePath, content);
}

async delete(uri: vscode.Uri): Promise<void> {
Logger.info(`delete: ${uri}`, 'tsFs-sftp');
const { hostname, resourcePath } = parseTsUri(uri);

const sftp = await this.manager.getSftp(hostname);
if (!sftp) {
throw new Error('Unable to establish SFTP connection');
}

return await sftp.delete(resourcePath);
}

async rename(): Promise<void> {}
}
39 changes: 17 additions & 22 deletions src/ts-file-system-provider.ts → src/filesystem-provider-ssh.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import * as vscode from 'vscode';
import { exec } from 'child_process';
import { Logger } from './logger';
import { SSH } from './utils/ssh';
import { SSH } from './ssh';
import { ConfigManager } from './config-manager';
import { escapeSpace } from './utils/string';
import { parseTsUri } from './utils/uri';
import { fileSorter } from './filesystem-provider';

export class TSFileSystemProvider implements vscode.FileSystemProvider {
export class FileSystemProviderSSH implements vscode.FileSystemProvider {
private ssh: SSH;

constructor(configManager?: ConfigManager) {
Expand All @@ -23,7 +24,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider {
}

async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
Logger.info(`stat: ${uri.toString()}`, 'tsFs');
Logger.info(`stat: ${uri.toString()}`, 'tsFs-ssh');
const { hostname, resourcePath } = parseTsUri(uri);

if (!hostname) {
Expand All @@ -47,11 +48,11 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider {
}

async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
Logger.info(`readDirectory: ${uri.toString()}`, 'tsFs');
Logger.info(`readDirectory: ${uri.toString()}`, 'tsFs-ssh');

const { hostname, resourcePath } = parseTsUri(uri);
Logger.info(`hostname: ${hostname}`, 'tsFs');
Logger.info(`remotePath: ${resourcePath}`, 'tsFs');
Logger.info(`hostname: ${hostname}`, 'tsFs-ssh');
Logger.info(`remotePath: ${resourcePath}`, 'tsFs-ssh');

if (!hostname) {
throw new Error('hostname is undefined');
Expand All @@ -71,21 +72,15 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider {
files.push([name, type]);
}

return files.sort((a, b) => {
if (a[1] === vscode.FileType.Directory && b[1] !== vscode.FileType.Directory) {
return -1;
}
if (a[1] !== vscode.FileType.Directory && b[1] === vscode.FileType.Directory) {
return 1;
}
return files.sort(fileSorter);
}

// If same type, sort by name
return a[0].localeCompare(b[0]);
});
async getHomeDirectory(hostname: string): Promise<string> {
return (await this.ssh.executeCommand(hostname, 'echo', ['~'])).trim();
}

async readFile(uri: vscode.Uri): Promise<Uint8Array> {
Logger.info(`readFile: ${uri.toString()}`, 'tsFs-readFile');
Logger.info(`readFile: ${uri.toString()}`, 'tsFs-ssh');
const { hostname, resourcePath } = parseTsUri(uri);

if (!hostname) {
Expand All @@ -102,7 +97,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider {
content: Uint8Array,
options: { create: boolean; overwrite: boolean }
): Promise<void> {
Logger.info(`writeFile: ${uri.toString()}`, 'tsFs');
Logger.info(`writeFile: ${uri.toString()}`, 'tsFs-ssh');

const { hostname, resourcePath } = parseTsUri(uri);

Expand All @@ -120,7 +115,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider {
}

async delete(uri: vscode.Uri, options: { recursive: boolean }): Promise<void> {
Logger.info(`delete: ${uri.toString()}`, 'tsFs');
Logger.info(`delete: ${uri.toString()}`, 'tsFs-ssh');

const { hostname, resourcePath } = parseTsUri(uri);

Expand All @@ -135,7 +130,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider {
}

async createDirectory(uri: vscode.Uri): Promise<void> {
Logger.info(`createDirectory: ${uri.toString()}`, 'tsFs');
Logger.info(`createDirectory: ${uri.toString()}`, 'tsFs-ssh');

const { hostname, resourcePath } = parseTsUri(uri);

Expand All @@ -151,7 +146,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider {
newUri: vscode.Uri,
options: { overwrite: boolean }
): Promise<void> {
Logger.info('rename', 'tsFs');
Logger.info('rename', 'tsFs-ssh');

const { hostname: oldHost, resourcePath: oldPath } = parseTsUri(oldUri);
const { hostname: newHost, resourcePath: newPath } = parseTsUri(newUri);
Expand All @@ -176,7 +171,7 @@ export class TSFileSystemProvider implements vscode.FileSystemProvider {
// scp ubuntu@backup:/home/ubuntu/ /Users/Tyler/foo.txt

scp(src: vscode.Uri, dest: vscode.Uri): Promise<void> {
Logger.info('scp', 'tsFs');
Logger.info('scp', 'tsFs-ssh');

const { resourcePath: srcPath } = parseTsUri(src);
const { hostname: destHostName, resourcePath: destPath } = parseTsUri(dest);
Expand Down
Loading

0 comments on commit 6eb7604

Please sign in to comment.