实战:远程 SSH 文件管理插件
用 ssh2 库把远程服务器变成一块虚拟磁盘:挂载 ssh:// 文件系统,侧边栏浏览远程目录、双击打开远程文件编辑,还能一键打开远程终端。文件系统 API、Webview、Terminal 三套能力在这一篇里完全打通。
目标
- 连接管理:ssh2 建立连接、断线重连、状态提示
- 远程文件系统:
FileSystemProvider挂载ssh://scheme,读写删除目录全部桥接远程命令 - 文件浏览器:Webview 以列表形式展示远程目录
- 远程终端:集成终端里直接执行 ssh 会话命令
- 路径解析:
ssh://host/path与远程绝对路径互转
项目结构
ssh-manager/
├── package.json
├── tsconfig.json
├── src/
│ ├── extension.ts # 激活入口:挂载、命令、终端
│ ├── sshSession.ts # ssh2 连接封装:连接/执行/状态
│ ├── sshFsProvider.ts # FileSystemProvider 实现
│ ├── fileBrowser.ts # Webview 文件浏览器
│ └── remoteTerminal.ts # 远程终端会话
└── media/
└── browser.js # 浏览器端脚本package.json
{
"name": "ssh-file-manager",
"displayName": "SSH File Manager",
"description": "远程 SSH 文件管理与远程终端",
"version": "1.0.0",
"publisher": "mypublisher",
"engines": { "vscode": "^1.80.0" },
"categories": ["Other"],
"main": "./out/extension.js",
"activationEvents": [
"onCommand:sshManager.connect",
"onCommand:sshManager.openTerminal"
],
"contributes": {
"commands": [
{ "command": "sshManager.connect", "title": "SSH: 连接服务器" },
{ "command": "sshManager.disconnect", "title": "SSH: 断开连接" },
{ "command": "sshManager.openTerminal", "title": "SSH: 打开远程终端" },
{ "command": "sshManager.openBrowser", "title": "SSH: 打开远程文件浏览器" }
],
"configuration": {
"title": "SSH 文件管理",
"properties": {
"sshManager.host": { "type": "string", "default": "", "description": "服务器地址" },
"sshManager.username": { "type": "string", "default": "", "description": "用户名" },
"sshManager.port": { "type": "number", "default": 22, "description": "SSH 端口" }
}
}
},
"scripts": { "compile": "tsc -p ./" },
"dependencies": {
"ssh2": "^1.15.0"
},
"devDependencies": {
"@types/vscode": "^1.80.0",
"@types/node": "^20.0.0",
"@types/ssh2": "^1.11.0",
"typescript": "^5.0.0"
}
}ssh2 放进 dependencies(运行时依赖),@types/ssh2 放 devDependencies。打包发布时 vsce 会自动包含依赖。
sshSession.ts:连接封装
把连接、执行命令、状态事件收敛成一个类,文件系统提供者和终端都依赖它:
import * as vscode from 'vscode';
import { Client, ClientChannel, ConnectConfig } from 'ssh2';
export type SshStatus = 'disconnected' | 'connecting' | 'connected';
export class SshSession {
private client: Client | null = null;
private status: SshStatus = 'disconnected';
// 状态变化通知:文件系统、状态栏、Webview 都监听它
private statusEmitter = new vscode.EventEmitter<SshStatus>();
readonly onStatusChange = this.statusEmitter.event;
constructor(
private readonly host: string,
private readonly username: string,
private readonly password: string,
private readonly port = 22
) {}
get connected(): boolean {
return this.status === 'connected';
}
async connect(): Promise<void> {
if (this.connected) return;
this.setStatus('connecting');
this.client = new Client();
const config: ConnectConfig = {
host: this.host,
port: this.port,
username: this.username,
password: this.password
};
await new Promise<void>((resolve, reject) => {
const c = this.client!;
// 连接成功:ed25519/rsa 指纹自动接受,方便演示
c.on('ready', () => resolve());
c.on('error', (err) => reject(err));
c.connect(config);
});
this.setStatus('connected');
vscode.window.showInformationMessage(
`已连接 ${this.username}@${this.host}`
);
}
/**
* 执行一条远程命令,返回 stdout 文本。
* 文件系统桥接的全部操作都走这一个入口。
*/
async exec(command: string): Promise<string> {
if (!this.client || !this.connected) {
throw new Error('SSH 未连接');
}
return new Promise((resolve, reject) => {
this.client!.exec(command, (err, stream: ClientChannel) => {
if (err) {
reject(err);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (data: Buffer) => (stdout += data.toString()));
stream.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
stream.on('close', () => {
if (stderr) {
reject(new Error(stderr.trim()));
} else {
resolve(stdout);
}
});
stream.on('error', reject);
});
});
}
disconnect(): void {
this.client?.end();
this.client = null;
this.setStatus('disconnected');
}
private setStatus(status: SshStatus): void {
this.status = status;
this.statusEmitter.fire(status);
}
dispose(): void {
this.disconnect();
this.statusEmitter.dispose();
}
}sshFsProvider.ts:远程文件系统
核心思路:把 FileSystemProvider 的每个方法翻译成一条远程 shell 命令。ls -la 出目录列表,cat 读文件,mkdir 建目录,rm -rf 删文件,mv 重命名。
import * as vscode from 'vscode';
import { SshSession } from './sshSession';
// 远程路径与 ssh:// URI 互转
// 示例:ssh://myhost/home/user/a.ts → /home/user/a.ts
export function toRemotePath(uri: vscode.Uri): string {
return uri.path; // ssh URI 的 path 部分就是远程绝对路径
}
export class SshFsProvider implements vscode.FileSystemProvider {
private emitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
readonly onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]> =
this.emitter.event;
constructor(private readonly session: SshSession) {}
// ---------- 读 ----------
async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
const out = await this.session.exec(
`stat -c '%F|%s|%Y' "${toRemotePath(uri)}"`
);
// 输出形如:regular file|1024|1718000000
const [typeStr, size, mtime] = out.trim().split('|');
const type = typeStr.startsWith('directory')
? vscode.FileType.Directory
: typeStr.startsWith('symbolic')
? vscode.FileType.SymbolicLink
: vscode.FileType.File;
return {
type,
size: Number(size),
ctime: Number(mtime) * 1000,
mtime: Number(mtime) * 1000
};
}
async readDirectory(
uri: vscode.Uri
): Promise<[string, vscode.FileType][]> {
// -1 按行输出,避免通配符干扰;-a 显示隐藏文件
const out = await this.session.exec(
`ls -1a "${toRemotePath(uri)}"`
);
const result: [string, vscode.FileType][] = [];
for (const line of out.split('\n')) {
const name = line.trim();
if (!name || name === '.' || name === '..') continue;
// 逐项 stat 判断类型(ls -F 的尾部斜杠不可靠,这里直接 stat)
try {
const childUri = vscode.Uri.parse(`ssh:${toRemotePath(uri)}/${name}`);
const stat = await this.stat(childUri);
result.push([name, stat.type]);
} catch {
result.push([name, vscode.FileType.File]);
}
}
return result;
}
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
const out = await this.session.exec(`cat "${toRemotePath(uri)}"`);
return Buffer.from(out, 'utf8');
}
// ---------- 写 ----------
async writeFile(
uri: vscode.Uri,
content: Uint8Array,
options: { create: boolean; overwrite: boolean }
): Promise<void> {
const path = toRemotePath(uri);
if (!options.create && !options.overwrite) {
throw vscode.FileSystemError.FileExists(uri);
}
// 内容经 base64 传参,规避特殊字符与引号问题
const b64 = Buffer.from(content).toString('base64');
await this.session.exec(
`echo ${b64} | base64 -d > "${path}"`
);
this.emitter.fire([{ type: vscode.FileChangeType.Changed, uri }]);
}
async createDirectory(uri: vscode.Uri): Promise<void> {
await this.session.exec(`mkdir -p "${toRemotePath(uri)}"`);
this.emitter.fire([{ type: vscode.FileChangeType.Created, uri }]);
}
async delete(uri: vscode.Uri, options: { recursive: boolean }): Promise<void> {
const path = toRemotePath(uri);
const stat = await this.stat(uri);
// 目录需要 -r,且禁止删除非空目录时误删文件
if (stat.type === vscode.FileType.Directory) {
await this.session.exec(`rm -rf "${path}"`);
} else {
await this.session.exec(`rm -f "${path}"`);
}
this.emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]);
}
async rename(
oldUri: vscode.Uri,
newUri: vscode.Uri,
options: { overwrite: boolean }
): Promise<void> {
const flag = options.overwrite ? '' : '-n';
await this.session.exec(
`mv ${flag} "${toRemotePath(oldUri)}" "${toRemotePath(newUri)}"`
);
this.emitter.fire([
{ type: vscode.FileChangeType.Deleted, uri: oldUri },
{ type: vscode.FileChangeType.Created, uri: newUri }
]);
}
watch(_uri: vscode.Uri): vscode.Disposable {
// 远程文件系统无法像本地一样监听,返回空实现
return new vscode.Disposable(() => {});
}
dispose(): void {
this.emitter.dispose();
}
}watch 返回空实现意味着编辑器不会收到远程文件的实时变更推送,这是 SSH 方案的固有边界——写操作完成后手动触发 onDidChangeFile 保证视图一致。
extension.ts:挂载与命令
import * as vscode from 'vscode';
import { SshSession } from './sshSession';
import { SshFsProvider } from './sshFsProvider';
import { FileBrowser } from './fileBrowser';
import { openRemoteTerminal } from './remoteTerminal';
// 全局会话:连接状态是跨视图共享的
let session: SshSession | null = null;
let fsProvider: SshFsProvider | null = null;
export async function activate(context: vscode.ExtensionContext) {
// 连接服务器:读取配置 → 密码用输入框(或 SecretStorage)→ 建立会话
context.subscriptions.push(
vscode.commands.registerCommand('sshManager.connect', async () => {
if (session?.connected) {
vscode.window.showInformationMessage('已连接');
return;
}
const cfg = vscode.workspace.getConfiguration('sshManager');
const host = cfg.get<string>('host') || await prompt('服务器地址');
const username = cfg.get<string>('username') || await prompt('用户名');
const password = await vscode.window.showInputBox({
prompt: '密码',
password: true,
ignoreFocusOut: true
});
if (!host || !username || !password) return;
session = new SshSession(host, username, password,
cfg.get<number>('port') ?? 22);
fsProvider = new SshFsProvider(session);
try {
await session.connect();
// 挂载 ssh:// scheme:此后工作区就能打开 ssh:// 路径
context.subscriptions.push(
vscode.workspace.registerFileSystemProvider('ssh', fsProvider, {
isCaseSensitive: true
})
);
// 打开远程根目录
const rootUri = vscode.Uri.parse('ssh:///');
await vscode.commands.executeCommand(
'vscode.openFolder',
rootUri,
{ forceNewWindow: true }
);
} catch (err) {
vscode.window.showErrorMessage(
`连接失败: ${err instanceof Error ? err.message : err}`
);
}
})
);
// 状态栏:随连接状态更新
const statusBar = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left
);
statusBar.command = 'sshManager.openBrowser';
context.subscriptions.push(statusBar);
// 打开 Webview 文件浏览器
context.subscriptions.push(
vscode.commands.registerCommand('sshManager.openBrowser', () => {
if (!session?.connected) {
vscode.window.showWarningMessage('请先连接服务器');
return;
}
FileBrowser.create(context.extensionUri, session!, fsProvider!);
})
);
// 打开远程终端
context.subscriptions.push(
vscode.commands.registerCommand('sshManager.openTerminal', () => {
if (!session?.connected) {
vscode.window.showWarningMessage('请先连接服务器');
return;
}
openRemoteTerminal();
})
);
// 断开连接
context.subscriptions.push(
vscode.commands.registerCommand('sshManager.disconnect', () => {
session?.dispose();
session = null;
fsProvider = null;
statusBar.text = '';
})
);
}
function prompt(title: string): Promise<string | undefined> {
return vscode.window.showInputBox({ prompt: title, ignoreFocusOut: true });
}fileBrowser.ts:Webview 远程文件浏览器
面板负责两件事:把目录列表按 JSON 消息发给页面渲染;接收页面消息执行「打开/进入目录/删除」等操作:
import * as vscode from 'vscode';
import { SshSession } from './sshSession';
import { SshFsProvider, toRemotePath } from './sshFsProvider';
export class FileBrowser {
static create(
extensionUri: vscode.Uri,
session: SshSession,
fsProvider: SshFsProvider
): void {
const panel = vscode.window.createWebviewPanel(
'sshBrowser',
'远程文件浏览器',
vscode.ViewColumn.One,
{ enableScripts: true, retainContextWhenHidden: true }
);
new FileBrowser(panel, extensionUri, session, fsProvider);
}
private currentDir = '/';
constructor(
private readonly panel: vscode.WebviewPanel,
extensionUri: vscode.Uri,
private readonly session: SshSession,
private readonly fsProvider: SshFsProvider
) {
const scriptUri = panel.webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, 'media', 'browser.js')
);
panel.webview.html = this.getHtml(scriptUri);
panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg));
this.refresh();
}
private async refresh(): Promise<void> {
const entries = await this.fsProvider.readDirectory(
vscode.Uri.parse(`ssh:${this.currentDir}`)
);
// 按目录优先排序
entries.sort((a, b) => b[1] - a[1]);
this.panel.webview.postMessage({
type: 'list',
dir: this.currentDir,
entries: entries.map(([name, type]) => ({
name,
isDirectory: type === vscode.FileType.Directory
}))
});
}
private async onMessage(msg: { type: string; name?: string }): Promise<void> {
switch (msg.type) {
case 'cd':
// 进入子目录
this.currentDir = `${this.currentDir}/${msg.name}`.replace(/\/+/g, '/');
this.refresh();
break;
case 'up':
// 返回上级
const parts = this.currentDir.split('/').filter(Boolean);
parts.pop();
this.currentDir = '/' + parts.join('/');
this.refresh();
break;
case 'open':
// 双击文件:交给编辑器打开(自动走 ssh:// 提供者)
const uri = vscode.Uri.parse(`ssh:${this.currentDir}/${msg.name}`);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
break;
case 'delete':
const full = vscode.Uri.parse(`ssh:${this.currentDir}/${msg.name}`);
await this.fsProvider.delete(full, { recursive: true });
this.refresh();
break;
}
}
private getHtml(scriptUri: vscode.Uri): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
style-src ${this.panel.webview.cspSource};
script-src ${this.panel.webview.cspSource};">
</head>
<body>
<div id="path">/</div>
<ul id="list"></ul>
<script src="${scriptUri}"></script>
</body>
</html>`;
}
}media/browser.js
// @ts-check
const vscode = acquireVsCodeApi();
const listEl = document.getElementById('list');
const pathEl = document.getElementById('path');
function render(entries) {
listEl.innerHTML = '';
for (const entry of entries) {
const li = document.createElement('li');
li.textContent = `${entry.isDirectory ? '📁' : '📄'} ${entry.name}`;
li.className = entry.isDirectory ? 'dir' : 'file';
li.addEventListener('click', () => {
if (entry.isDirectory) {
vscode.postMessage({ type: 'cd', name: entry.name });
} else {
vscode.postMessage({ type: 'open', name: entry.name });
}
});
// 文件右键:删除
if (!entry.isDirectory) {
li.addEventListener('contextmenu', (e) => {
e.preventDefault();
vscode.postMessage({ type: 'delete', name: entry.name });
});
}
listEl.appendChild(li);
}
}
// 返回上级
pathEl.addEventListener('click', () => {
vscode.postMessage({ type: 'up' });
});
window.addEventListener('message', (event) => {
if (event.data.type === 'list') {
pathEl.textContent = event.data.dir;
render(event.data.entries);
}
});remoteTerminal.ts:远程终端会话
VS Code 集成终端本身支持 SSH Remote,但插件也可以直接用 ssh 命令启动一个终端会话:创建终端后把 ssh 命令作为首个输入发进去,用户的按键都落在远程 shell 上:
import * as vscode from 'vscode';
import { sessionState } from './extension';
export function openRemoteTerminal(): void {
const cfg = vscode.workspace.getConfiguration('sshManager');
const host = cfg.get<string>('host') || '';
const username = cfg.get<string>('username') || '';
// 1. 新建终端,命名"SSH: host"
const terminal = vscode.window.createTerminal({
name: `SSH: ${host}`,
// 关键:用 ssh 交互式命令作为终端首条命令
// 之后终端直接变成远程 shell
});
terminal.show(true);
// 2. 发送 ssh 登录命令(需要密钥或 ssh-agent,密码场景会交互输入)
terminal.sendText(`ssh ${username}@${host}`);
// 3. 可选的快速导航:让远程终端 cd 到浏览器当前目录
// 通过与文件浏览器联动实现,此处演示固定路径
terminal.sendText('cd ~ && pwd');
}终端附加的正确姿势:ssh 是交互式程序,用 sendText 发送后它接管整个终端,后续无需再发命令;若需要把远程路径传给终端,直接拼接在命令里即可。
与连接状态联动的状态栏
import * as vscode from 'vscode';
// 在 activate 中把状态栏与会话事件绑定:
export function bindStatusBar(
statusBar: vscode.StatusBarItem,
onStatus: (s: string) => void
): void {
statusBar.text = '$(plug) SSH 未连接';
statusBar.show();
onStatus((status) => {
statusBar.text = status === 'connected'
? '$(check) SSH 已连接'
: '$(plug) SSH 未连接';
});
}路径解析规则
| 场景 | 示例 | 说明 |
|---|---|---|
| URI 构造 | ssh:/home/user/a.ts | path 即远程绝对路径 |
| 目录列举 | ssh:/home/user | 读远程 /home/user |
| 打开文件 | vscode.workspace.openTextDocument(ssh:/...) | 编辑器自动经提供者读取 |
| 根目录 | ssh:/ | 对应远程 / |
运行与验证
按 F5 启动调试:
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 配置 host/username,执行「SSH: 连接服务器」 | 输入密码后提示已连接 |
| 2 | 连接成功自动打开远程根目录窗口 | 资源管理器显示远程文件树 |
| 3 | 双击远程文件 | 编辑器打开并显示内容 |
| 4 | 修改文件保存 | 写操作经 base64 回写远程 |
| 5 | 执行「SSH: 打开远程文件浏览器」 | Webview 列出远程目录 |
| 6 | 点击目录进入、右键文件删除 | 浏览器与资源管理器同步刷新 |
| 7 | 执行「SSH: 打开远程终端」 | 终端进入远程 shell |
注意事项
| 项目 | 说明 |
|---|---|
| 密码存储 | 演示用输入框;生产环境建议 SecretStorage 或密钥文件 |
| 大文件 | cat 全量读取适合中小文件,超大文件应改用 SFTP 流式 |
| 特殊字符 | 文件名含引号/空格时命令拼接必须转义,演示用双引号包裹 |
| 断线处理 | exec 抛错后应触发重连或提示,可监听 ready 与 error 事件 |
| 并发 | 文件树批量 stat 会并发执行命令,注意 ssh2 会话的并发上限 |
本实战展示了「远程资源本地化」的完整套路:FileSystemProvider 是 VS Code 连接任意存储后端(SSH、FTP、云盘)的统一接口,桥接命令只是其中一种实现,换成 SFTP 或 REST 调用即可扩展出更多后端。