Swift · Flutter · Shellby

命令片段库:把「点按即执行」改成「先查看再执行」

Shellby 复盘:终端顶部做了个命令工具条,点一下就发。上线后判定这个交互不安全,整体重构成「先查看再执行」的片段库。以及跨六端做这个功能踩的坑——移动端终端没有菜单注入口子、macOS 自绘右键菜单被系统置灰、全屏程序里不能乱注入。

Shellby 里高频的长命令重复手输很烦,让 AI 生成又太重。于是做了个命令片段库。第一版(v1)是终端顶部一条「命令工具条」,点一下就把命令发出去。上线后我把它整个下线重做了——因为「点按即执行」对运维命令是个危险交互。这篇讲这次重构的判断,和跨六端做这个功能踩的一串坑。

v1 的问题:点按即执行,且入口错了

v1 工具条有两个问题。一是安全:运维命令一键盲发,手一抖 systemctl restart 就出去了,没有任何「看一眼再确认」的机会。二是心智:入口藏在联机会话里,可片段库这种「库」类功能的价值根本不依赖当前连没连着——你想管理片段,不该先连上一台服务器。

重构做了两件事:把工具条换成**「先查看再执行」的片段库,把入口提成和主机列表平级的一等入口**。

「先查看再执行」是核心安全设计,不是 UI 偏好:片段列表收起时没有任何动作按钮,你得先点开、看到命令全文,「执行 / 填入 / 复制」才出现。防误触,同时把「复制到别处用」变成一等路径。数据模型 DATA-SNIPPET-01 也刻意做简单——全局库,{id, name, command, sortOrder, ...},砍掉了原规划的「片段绑定主机分组」关联(省掉 CloudKit 关系迁移和跨栈引用的复杂度),属性全带默认值、无唯一约束、无关系,对同步友好。

顺带和「快捷键条」分了工:快捷键条发按键(esc / ctrl / 方向键),片段库发整条命令——两条输入通道,各管各的。

坑一:全屏程序里不能乱注入

执行和填入都会往终端注入字节。但如果终端此刻正跑一个全屏程序(vim / less / htop 的 alternate screen),往里注入一整条命令必然花屏。

所以执行 / 填入前先判 alt-screen,是的话不注入任何字节、只给个轻提示

private func execute(_ snippet: Snippet) {
    guard !session.isInFullScreenApp else { showBlockedHint(); return }
    session.sendText(cmd.hasSuffix("\n") ? cmd : cmd + "\n")   // 执行:无换行补 \n
}
private func fill(_ snippet: Snippet) {
    guard !session.isInFullScreenApp else { showBlockedHint(); return }
    session.sendText(snippet.command)                          // 填入:不补换行,你自己回车
}

isInFullScreenApp 代理到终端的 isAlternateScreen。有意思的是,这同一个 alt-buffer 判定既守片段执行,也守 AI Agent 的输出回显——「别往全屏程序里乱塞东西」是个通用护栏,一处判定两处复用。

坑二:移动端终端没有菜单注入的口子

片段可以「剪藏」——从终端选区、AI Agent 命令卡、或剪贴板存成片段。桌面端好办:终端选区右键「存为片段」。但移动端撞了个硬墙:iOS 上 SwiftTerm 的 UIMenuController 菜单项是硬编码为空的,没有让你插入自定义菜单项的口子,移动端长按工具条同样没有。

结论是移动端的剪藏只能走剪贴板兜底:复制之后,用「从剪贴板新建片段」。这不是偷懒,是那个终端组件在移动端根本没给注入点,只能绕。还有个隐私细节:「从剪贴板新建」只在点击按钮那一刻读剪贴板,而不是提前读——避免频繁触发系统的剪贴板访问提示(iOS 会弹「某某读取了你复制的内容」)。

坑三:macOS 自绘右键菜单被系统置灰

桌面端的「选区右键存为片段」也不顺。SwiftTerm 父类的 menu(for:) 返回 nil,得自己覆写建一个 NSMenu。建出来一看,自定义菜单项全灰的

两个坑叠一起:

menu.autoenablesItems = false          // 否则 SwiftTerm 的 validateUserInterfaceItem
                                       // 不认识自定义 selector,一律置灰
// 还要关掉系统追加的 AutoFill / 写作工具插件项:
view.allowsContextMenuPlugIns = false

autoenablesItems 默认开着时,AppKit 会调 validateUserInterfaceItem 问每个菜单项该不该启用,而 SwiftTerm 的实现不认识我们的自定义 selector,于是全判成禁用。关掉自动启用才行。回调经 TerminalSession.onSaveSelection 管道回传,保持 TerminalKit 对上层 App 零依赖。

坑四:跨栈同步别动已冻结的载荷

片段要跨设备同步。同步载荷原本有 5 个基础集合,字节序被冻结成了确定性测试向量。加片段(还有 AI 配置、对话)这些扩展集合时,规则是尾追在基础键之后、非字母序,且只序列化实际存在的集合——这样一个不含片段的载荷字节完全不变,不破坏已冻结的向量。合并端遍历两端集合键的并集,扩展集合自动纳入记录级 LWW,加新类型不用改合并代码。片段不含敏感值,所以不受「仅配置」同步开关约束,任何时候都同步。

双栈入口,两套导航体系

同一个功能在两栈的落地形态不一样,因为导航体系不同:Apple 端是侧栏「命令片段」一等行(和主机列表平级,带计数),iPad/macOS 点击从左滑出一块全高的半屏浮板,iPhone 直接 push 整页;Flutter 端是和主机 / 会话 / 设置平级的顶层 tab,紧凑布局走底部导航独立页,大屏用 NavigationRail + 双栏(左片段库、右常驻会话)。

功能一致、心智一致(一等入口、先查看再执行),但不强求两端 UI 长得一样——各自用平台原生的导航习惯落地。

复盘

  • 「点按即执行」对高危动作是坏默认。片段库把动作按钮藏在「点开看全文」之后,多一步换来「看一眼再发」,还顺手把「复制到别处」变成一等路径——安全常常就是刻意多设一道展开;
  • 库类功能的入口不该依赖上下文。管理片段不该先连服务器;把它提成一等入口,是让功能的价值和它的心智对齐;
  • 跨端的能力缺口要认,然后绕。移动端终端没有菜单注入口子,就走剪贴板兜底而不是硬凿;这不是妥协,是尊重平台组件的真实边界;
  • 一个护栏能守多处。alt-screen 判定既守片段注入、又守 AI 回显——通用的安全判定值得抽出来复用;
  • 加扩展数据别动已冻结的序列化。扩展集合尾追、只序列化存在的键、合并遍历并集——新功能接入是加数据,不是改协议,老载荷字节纹丝不动。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。