Skip to content

Latest commit

 

History

History
214 lines (142 loc) · 18.3 KB

ui.md

File metadata and controls

214 lines (142 loc) · 18.3 KB

界面指南

定位:插件在 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——对话框开着时用户不会去看页面角落。

新建远程工作区对话框

一次完成"选主机 → 选目录 → 命名 → 挂载":

  1. 拉取主机列表,默认选中第一台;主机列表为空或出错时给出对应空态。
  2. 目录浏览:GET /api/dsh-ssh-workspace/ls?alias=…&path=… 逐级浏览远端目录,只列目录、按名称排序;提供「返回上级」、加载中 / 空目录 / 无法加载三种状态。
  3. 可写探测:/ls 同时回报 writable。目录不可写时显示红色警告("当前 SSH 账号对这个目录没有写入权限……")并禁止创建——写入能力是 Agent 工作的前提。
  4. 创建: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 workspaces store 的 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 元数据无亚秒时间戳,同一秒内等长的连续改写可能不触发刷新(增、删、改名总能感知),此时重新打开或重新读取即可看到最新内容。

相关