定位:插件在 DSH 界面中的全部出入口与交互。
侧边栏面板,注册在 sidebar.panellist 插槽,入口图标为服务器图标(ServerIcon),标题「远程工作区」。
主机卡片逐条展示存储与 ~/.ssh/config 回退的全部主机,字段为别名、user@host:port、认证方式、来源(store / ssh-config),以及「密钥未就绪」提示(key 认证且 keyPath 在本机不存在时)。每张卡片提供:
- 测试连接:调用
POST /api/dsh-ssh-workspace/test实连一次,然后释放这条连接——「测试」回答的是「这台主机是否可达」,不是「替我连上」。结果以页面顶部 toast 呈现(成功附往返毫秒数),成功后数秒(约 3.5 s)自动消失,点击可提前关闭;失败消息同样处理。连接指示点因此不会被一次探测点亮:刷新读的是实际剩下的连接,而不是假定测试留下了一条。 - 断开:仅在该主机存在池化 SSH 连接时显示(连接状态指示绿点内联在主机名之后)。点击调用
POST /api/dsh-ssh-workspace/hosts/disconnect关闭该主机的池化连接,并连带拆除该主机上的全部端口转发(响应里的closedForwards报告拆了几条,toast 据此改写为「已断开连接,同时关闭 N 条端口转发」)。拆除的结果与在端口转发行上点「断开」完全一致:映射保留、意图置为断开、不记任何失败原因——主动断开不是故障,面板不该对它亮红字或提示「下次启动会自动开启」。连接上有操作进行中(进行中数量大于 0)时按钮禁用并给出提示。断开无需确认——下一次操作会按需重连。 - 忘记主机密钥:调用
POST /api/dsh-ssh-workspace/hosts/forget-key清掉该别名的 TOFU 主机密钥记录(需就地确认),用于主机确实重装系统或更换密钥之后——见 hosts.md 的「主机密钥校验」。 - 浏览目录:打开该主机的目录浏览器(见下节)。它与「新建远程工作区」里的目录选择是两个不同的组件(这里是
DirectoryBrowser,那里是RemoteWorkspaceDialog):后者只列目录、只为选路径,前者还带上传与逐行下载。共同点是挂载入口在两边都有——毕竟从主机卡片点进来,多半就是为了挂载。 - 编辑 / 删除:编辑打开主机表单;删除为就地确认,删除只影响存储条目,不会自动卸载已挂载的工作区。
表单要点:密码与密钥口令从不回传浏览器,编辑时留空表示保留原值(见 hosts.md 的「认证与凭据」);切换认证方式时必须重新填写新凭据。
插件占用两个 directoryFlow 插槽:侧边栏的 sidebar.workspaces.directoryFlow 与会话页的 conversation.hero.workspace.directoryFlow,以负优先级(-100)遮蔽部署自带的本地选择器——插槽核心渲染优先级最低的占用者,负优先级因此胜出,且不卸载内建实现。
触发后弹出锚定菜单(宽约 300px),包含两行:
- 本地(FolderIcon):转交 DSH 原生目录选择器——桌面端走 preload 桥
__DSH_DIRECTORY_PICKER__,否则走uiWorkspace.pickDirectory()。 - 远程(远程标记图标):打开「新建远程工作区」对话框。
锚定机制:触发按钮不传给插槽占用者,因此插件在捕获阶段监听 mousedown 记住最近一次按钮位置(3 秒内有效,避免键盘快捷键打开时菜单"瞬移"),弹出层定位在按钮右侧 8px 处,并钳制在视口内;窗口 resize 时重新定位。点击菜单外部或按 Esc 关闭。
点主机卡片的「浏览目录」打开 DirectoryBrowser(lib/client.js)。它与「新建远程工作区」里的目录选择不是同一个组件(后者是 RemoteWorkspaceDialog,只列目录、只为选路径),因此两者能力不同:
- 顶部:返回上级、当前绝对路径、上传文件(
POST /upload,原始字节流); - 列表:目录在前、按名排序;文件行显示大小并带 下载到本机(
GET /download,流式返回原始字节),目录行没有下载按钮——整个目录不能作为一个文件拉下来; - 底部:「挂载此目录」——按当前路径创建远程工作区,成功后附能力提示(
capabilityNotes:rg/git/inotifywait的缺失项,只报探测出的确定缺失)。
首帧路径为 null,首次 GET /ls(不带 path)返回 SSH 登录目录后回填,所以「挂载此目录」永远指向一个真实绝对路径。加载中 / 空目录 / 无法加载是三种独立状态。
上传与下载各有自己的忙碌标记:一次只跑一个传输,第二个点击会被忽略,且上传失败不会清掉下载的错误,反之亦然。失败信息常驻对话框内而不是走 toast——对话框开着时用户不会去看页面角落。
一次完成"选主机 → 选目录 → 命名 → 挂载":
- 拉取主机列表,默认选中第一台;主机列表为空或出错时给出对应空态。
- 目录浏览:
GET /api/dsh-ssh-workspace/ls?alias=…&path=…逐级浏览远端目录,只列目录、按名称排序;提供「返回上级」、加载中 / 空目录 / 无法加载三种状态。 - 可写探测:
/ls同时回报writable。目录不可写时显示红色警告("当前 SSH 账号对这个目录没有写入权限……")并禁止创建——写入能力是 Agent 工作的前提。 - 创建:
POST /api/dsh-ssh-workspace/mount,携带{alias, remotePath, title, register: true}。先注册、后接纳:挂载路由在宿主接纳该路径之前就按所填名称登记工作区,宿主随后解析到既有记录,避免以派生标题重复建条目。成功后刷新标记数据源,把路径交还吸附链路;对话框在接纳期间保持禁用,背景点击可取消(创建中除外)。
远程工作区不以文件夹图标示人,而是显示 SSH 标记。DSH 的行图标没有插件插槽,实现为 DOM 装饰(lib/client.js 的 "Remote-workspace marks" 段),三条原则:
- React 安全:从不删除或搬迁 React 拥有的节点——仅通过内联
style隐藏文件夹字形(React 不会重写这些节点的style),在旁插入自带data-sshwsp-mark的 svg。 - 自愈:
MutationObserver监听document.body,React 重建行节点后自动重新施加替换;工作区不再远程时同样自动还原。 - 身份匹配:从 live
workspacesstore 的getSnapshot()取 id / title 集合(服务不可达时退回/status的workspaces数组)。侧栏行按工作区 id 匹配;选择器菜单行与会话页 hero 工作区按钮只有标题,按 title 匹配,hero 在会话刚建立时可能显示路径 basename,故 basename 也计入标题集合。
装饰同时覆盖三处展示:侧栏工作区行、侧栏选择器菜单行、会话页 hero 工作区按钮。
已停用(detach 过)的工作区,侧栏那一行会被打上 data-sshwsp-disabled="1",
由 CSS 降到 50% 不透明度并在行尾加一枚 ⏻ 标记,同时把这一行里的按钮变成不可点:
[data-sshwsp-disabled="1"] button,
[data-sshwsp-disabled="1"] [role="button"] { pointer-events: none; opacity: 0.4; }置灰只是装饰,真正拒绝点击的是这条规则。一个看灰了却仍能触发「新建会话」「重命名」
「删除」的行是在谎报,用户只有点下去、拿到错误之后才会发现。用 CSS 而不是拦 DSH 的
点击处理器,是因为后者属于和宿主抢控制权,宿主一改版就会崩;选择器写成通用的 button
而不是本插件自己的类,是因为那些按钮是 DSH 画的。
工作区标题那一行本身仍然可点击:展开分组是看清「停用保留了哪些会话」的唯一途径。
所以规则只落在 button / [role="button"] 上,不落在标题行上。
它下面的会话行则整行不可点——这里的情况相反:会话行本身就是「打开会话」那个控件, 只把它的按钮置灰会把唯一要紧的动作留着。这类行按行键单独作用域:
[data-row-key^="session:"][data-sshwsp-disabled="1"] { pointer-events: none; cursor: not-allowed; }pointer-events 只挡鼠标,键盘路径由脚本补 inert + tabindex="-1"(见
unmount-modes.md 的「置灰」一节)。会话行是 session:<id>,
不携带任何工作区信息,所以成员关系由 /status 的 workspaces[].sessionIds 下发。
真正的恢复入口是面板里的「启用」按钮。
身份匹配需要 id 与标题两套集合。这里有个容易踩的坑:readLiveWorkspaces() 会从
DSH 自己的 store 重新收集身份,而那个 store 不知道本插件的停用清单,所以停用
路径单独保存在 knownDisabledPaths 里并在每次重新收集时传回去——否则置灰会在
下一次 MutationObserver 回调时消失。
「远程工作区」面板把工作区分成两段,互斥:
| 分区 | 内容 | 每行操作 |
|---|---|---|
| 已挂载的工作区 | /status 的 mounted[] |
「卸载」→ 就地展开确认行 |
| 已停用的工作区 | /status 的 disabled[] |
「启用」 |
卸载是两步的:第一次点击只展开确认行(附带「同时删除工作区记录」复选框), 第二次点击「确认卸载」才真正执行。这个动作会影响到该工作区登记的全部会话,一次点击 就执行不合适。
两种模式的后果只写在「卸载」按钮的悬停提示里(mounted.detachHint,展开确认行后
由「确认卸载」沿用同一个 title):未勾选则工作区记录与会话都保留、左侧那一行仍在
(置灰)、随时可重新启用;勾选则工作区记录被删除、会话归档。不再在确认行里另起一行
常驻文字——那行说明会随复选框变化而要重写一遍,而提示只需要说清两种模式的区别。
两个模式分别对应什么,是这一段的验收口径:
| 复选框 | 模式 | 侧栏那一行 | 面板这一行 | 会话 |
|---|---|---|---|---|
| 不勾(默认) | detach |
保留,置灰且按钮不可点 | 移到「已停用」分区,可「启用」 | 保留,不归档 |
| 勾选 | deregister |
删除 | 删除 | 归档,不删除 |
「保留记录却不保留会话」是自相矛盾的:detach 留住侧栏那个分组,就是为了让用户还能
点开它;把会话归档等于把分组内容清空——行还在,点开却是空的,比删掉更让人困惑。所以
归档跟随「删除记录」这个勾选,而不是独立开关。
勾选时的「两边都删除」是必须同时成立的:删掉工作区记录已经让侧栏那一行消失,
而面板的「已停用」分区由持久化的停用标记驱动——如果只删记录、不清标记,面板会继续
为一条在任何地方都不存在的记录提供「启用」按钮,而运行时围栏又拒绝这个已经无法
再启用的路径。所以 deregister 会同时清掉持久清单与运行时围栏两处标记。
「已停用分区始终渲染」只针对 detach:这是用户得知「卸载之后还留了个东西可以
启用」以及「它去哪了」的唯一途径——如果只在有内容时才渲染,第一次卸载的用户会
以为工作区被永久删除了。
已停用行的样式是 opacity: .62 加一个「已停用」小标签,不使用
pointer-events: none:这一行存在的意义就是它的「启用」按钮。
主机区每行一个 alias,右侧是「断开」;端口转发区列出已存映射与临时隧道, 顶部有「启动时自动开启隧道」总开关。
面板读的只有三个字段:live(是否真有 listener)、enabled(用户的意图)、
error(是否需要解释)。两个入口都指向同一个关闭方法,所以三者逐字段相同:
| 入口 | 走的路径 | live |
enabled |
error |
面板那一行 |
|---|---|---|---|---|---|
| 主机行的「断开」 | savedForwards.closeAlias(alias) → 逐条 closeById |
false |
false |
无 | 已断开 + 开启,无红字 |
| 端口行的「断开」 | closeById(id) |
false |
false |
无 | 同上 |
| 连接意外掉线 | ForwardManager.closeForAlias() → onClosed |
false |
true |
"SSH connection closed" |
已断开 + 开启 + 红字「连接已断开」 |
第三行才是故障:连接断得不是时候,所以要保留意图(下次可一键恢复)并留下原因向用户
解释。前两行是用户主动完成的动作,留下原因等于把成功的操作报成故障,后面还会跟着
「启动时自动开启隧道」的暗示——而用户刚刚明确关掉了它。forwards.lost
(「连接已断开」)只在 error 里含 connection 时渲染,因此主动断开后不会出现。
主机行「断开」的 toast 在拆掉了隧道时报数:hosts.disconnectOkForwards
(「已断开连接,同时关闭 {n} 条端口转发」),n 来自响应的 closedForwards;
没有隧道时用 hosts.disconnectOk。
status.shells 非空时才出现「持久远端会话」一区,每行 alias [session] + 状态
(存活 / 已结束)。它是一个只读列表——这一区不提供开关或关闭按钮,
因为会话的生命周期归 agent 的 ssh_workspace_shell 管(同一 alias 可并存多个
命名会话)。空列表整区不渲染,避免暗示面板拥有一个它并不拥有的控制项。
两处纯展示的替换,把 ssh://alias/path 显示为 ssh://alias@/path(读起来像 SSH 地址):
- 工作区悬停卡片(hover card)中的路径;
- DSH
PathLabel单元格:文件面板头部、文档预览标题、交付物列表。
只改渲染文本,复制行为与所有功能消费方保持真实拼写。规则细节见 protocol.md 的「显示规则」。
侧边栏文件树的文件与文件夹(li[data-files-entry="file"|"directory"])有两种手势把路径加入当前会话的任务:
-
左键拖到输入框:拖动经过输入卡(
[data-composer-card])时卡片显示虚线高亮(内联outline,拖拽结束/离开即清除),松开即插入引用 chip; -
右键 → 菜单:行上弹出锚定菜单(复用
.sshwsp-flowmenu样式,视口内钳制)。菜单内容取决于该行是否落在远端工作区:菜单项 出现条件 说明 「添加到任务」 总是 菜单项副标题直接显示将要插入的 @拼写,点击插入「下载到本机」 仅远端行 目录行禁用并说明「暂不支持下载整个目录」(整棵树请用 recursive上传反向、或远端打包)「上传文件到此目录」 仅远端行 文件行传到它的父目录,目录行传到它自己 本地工作区的行只有「添加到任务」——
GET /download与POST /upload对本地路径没有任何可对话的对象,摆出这两项等于承诺一个该行并不具备的能力。
插入与「@ 补全里选中候选」走同一条管线:取主会话 composer 的 captureInsertion() 跨度,经会话作用域的 slash/input-insert-reference 事件交给编辑器——chip 节点、序列化与失效判定因此与补全完全一致(插入后可继续输入、照常提交)。会话监听不声称该事件时降级为纯文本插入;输入框不可用或路径不可表示时给出 toast。
拼写规则与 @ 补全一致:树根即会话 cwd 时用工作区相对路径(/ 分隔),否则保留绝对拼写;含空格按引号语法(目录保留开引号)。带控制字符或双引号的路径拒绝插入。
实现要点(lib/client.js 的 "Composer file bridge" 段):行的 draggable 标记并入 DOM 装饰的同一趟 pass(MutationObserver 触发、rAF 合并);拖拽只写入自定义 MIME、不写 text/plain,因此不触发输入框的文本粘贴,也不会与 DSH 的「拖入文件」浮层冲突;文档级 capture 监听 dragover/drop 判定输入卡并转发手势;右键菜单在滚动 / resize / 失焦 / Esc / 点击外部时关闭。手势面向主会话的输入框(文件树跟随主会话,二者工作区根一致时用相对拼写)。
「工作区文件」侧边栏与文档预览无需插件改动——它们经 ctx.fs 读取,远程工作区下自然由 SFTP 提供(列目录、读文本、写回均同本地工具一致),大文件预览按块流式读取。
插件往这个面板加了一个按钮:sidebar.right.tab.files.actions 插槽里的「上传文件到此目录」。它读槽位给的 absolutePath(面板正在显示的目录),所以上传落在用户眼前那个目录,而不是猜一个根。目录不能作为一个文件下载,因此头部没有下载按钮——下载在上面的行菜单里,那里目标明确。
非远端的工作区列表不渲染这个按钮:同一个面板也会显示本地目录,摆出一个只能对远端工作的控件会谎报能力。
已知差异:远端的变更监视优先走 inotifywait 推送(远端装了该工具时即时上报,不受元数据精度限制);没有该工具时回退到轮询式快照比对——基础约 2 秒一次,空闲目录自适应退避到 5–10 秒、变更后立即回到 2 秒,文件树 / 预览会自动刷新。轮询路径下 SFTP 元数据无亚秒时间戳,同一秒内等长的连续改写可能不触发刷新(增、删、改名总能感知),此时重新打开或重新读取即可看到最新内容。
- hosts.md — 主机与凭据在面板中的管理语义
- protocol.md — 展示拼写与路径规范
- troubleshooting.md — 界面报错对应的排查