跳转至

05 · Workspace、Tab 与 BSP 布局

状态/运行时分开后,视图侧还要解决另一个问题:用户看到的是 workspace、tab 和 pane,终端 runtime 却不应该依赖它在屏幕上的坐标。Herdr 用分层容器加一棵二叉分割树表达组织结构,用稳定公开编号承载 CLI/API 契约。

5.1 层级关系

flowchart TB
    App["AppState"] --> W1["Workspace A"]
    App --> W2["Workspace B"]
    W1 --> T1["Tab 1"]
    W1 --> T2["Tab 2"]
    T1 --> L["TileLayout BSP tree"]
    L --> P1["PaneState → TerminalId α"]
    L --> P2["PaneState → TerminalId β"]
    L --> P3["PaneState → TerminalId γ"]
    P1 -.lookup.-> R["TerminalRuntimeRegistry"]
    P2 -.lookup.-> R
    P3 -.lookup.-> R

Workspace 管理一组 tab、工作目录/Git 身份和公开计数器;Tab 管理一棵布局树与 pane map。

5.2 Workspace 不是文件夹别名

Workspace 虽然有 cwd,却还承载:

  • 稳定 public workspace id;
  • 用户自定义名称;
  • identity cwd 与显示 cwd;
  • Git repository/worktree 识别缓存;
  • worktree membership 元数据;
  • tabs 和当前 active tab;
  • 下一个 public tab/pane 编号;
  • sidebar、状态展示需要的摘要。

“identity cwd”值得单独强调。cwd 可能因目录重命名、symlink、worktree 或子目录导航而变化;用户脚本引用的 workspace identity 不应该只是每帧重新 canonicalize 的路径字符串。

5.3 Tab 是布局与焦点边界

一个 Tab 保存:

  • 自定义名称和公开 tab 编号;
  • root/active pane;
  • TileLayout;
  • PaneId -> PaneState map;
  • zoomed pane;
  • 与该 tab 相关的通道/运行协调信息。

把 tab 作为布局边界有两个好处:

  1. 切 tab 不需要销毁后台 pane 的 PTY;
  2. 每棵树的几何、focus history 和 zoom 可以独立计算。

5.4 BSP 树的最小模型

src/layout.rs 的核心节点只有两种形状:

// 形状化简,字段名以源码为准
enum Node {
    Pane(PaneId),
    Split {
        direction: Direction,
        ratio: f32,
        first: Box<Node>,
        second: Box<Node>,
    },
}

例如下列树:

flowchart TB
    R["Horizontal · 0.60"] --> A["Pane A"]
    R --> V["Vertical · 0.50"]
    V --> B["Pane B"]
    V --> C["Pane C"]

会把区域先按宽度切成 A 与右侧,再把右侧按高度切成 B/C。每个内部节点只保存方向和比例,因此:

  • 序列化结构紧凑;
  • resize 时递归重算 rect;
  • 交换/move pane 只改叶子;
  • 相邻导航可从几何和树路径推导;
  • border 由 split 节点自然产生。

5.5 几何计算

TileLayout 从 root rect 递归分配:

layout(node, area):
  Pane(id) -> rect[id] = area
  Split(dir, ratio, a, b):
    (first_rect, second_rect) = split_rect(area, dir, ratio)
    layout(a, first_rect)
    layout(b, second_rect)

split_rect 在整数 cell 网格中 round。小窗口和多层分割会产生舍入误差,所以不能假设每个叶子严格得到浮点比例;最终约束是所有 rect 不越界且覆盖可用区域。

比例通过 valid_split_ratio 夹在 0.1..=0.9,非有限值回落到安全值。这个约束既保护 UI 可操作性,也防止 snapshot/API 输入把 pane 压成不可恢复的零尺寸。

5.6 Split 不是只改树

用户请求分屏时,至少有三层变化:

flowchart LR
    Action["split action"] --> Tree["BSP 插入新 PaneId"]
    Action --> State["创建 PaneState + TerminalState"]
    Action --> Runtime["spawn PTY + 注册 runtime"]
    Runtime --> Resize["按新 rect resize 所有受影响 PTY"]
    Tree --> Focus["更新 focus/history/public id"]
    State --> Persist["安排 snapshot"]

TileLayout::split_pane 本身只是纯树变换;业务 action 必须把它放进更大的补偿事务。若 PTY spawn 失败,不能留下没有 runtime 的空 pane,也不能消耗一个用户以为成功的公开编号后静默显示坏状态。

5.7 Focus 与选择历史

布局需要区分:

  • 当前 focused pane;
  • 上一个 focus,用于关闭后回退;
  • 通过方向键寻找的几何邻居;
  • zoom 前的布局 focus;
  • tab/workspace 切换时各自记住的 active 项。

树结构提供父子关系,却不自动提供“向左走应该选谁”。源码会计算 pane rect 与 split border,依据方向、重叠范围和距离选择候选。这样在不规则嵌套布局里,方向导航更接近视觉直觉,而不是简单 DFS 前后项。

5.8 Resize 操作

resize 的语义是寻找 focused pane 某个方向上的相关 split,并调整其 ratio。需要注意 focused pane 可能位于 split 的 first 或 second child:

向右扩展 focused pane:
  若 pane 在 vertical split 的 first 子树 -> ratio 增大
  若 pane 在 second 子树              -> ratio 减小

Herdr 通过 split path 和 pane 几何判断该调整哪个祖先节点,再调用 set_ratio_at。比例夹紧确保连续 resize 不会把另一侧彻底挤没。

测试覆盖:

  • first/second child 的增减方向;
  • missing target 不改变树;
  • 自定义 split ratio;
  • 多层嵌套下选中正确 border;
  • snapshot/restore 后比例保持。

5.9 Zoom 是视图投影,不是破坏布局

zoom 某 pane 时,理想语义是“暂时只把它投影到整个内容区域”,而不是把其他叶子从树中删除。Tab 保存 zoomed pane,原 TileLayout 继续存在:

  • 取消 zoom 可无损恢复;
  • 后台 pane 的 PTY 继续运行;
  • snapshot 能表达 zoom 状态;
  • focus 和公开编号不变化。

这与 CSS 隐藏类似:隐藏视图不等于销毁领域对象。

5.10 公开编号为何不复用

内部 PaneId 只需要当前进程唯一;CLI/API 的 public id 是用户契约。若关闭 pane 3 后立即把新 pane 编成 3,一个迟到的脚本或 pane.wait 可能操作错误对象。

Workspace 保存单调递增的公开计数器,编号在其作用域内稳定且不轻易复用。解析层还支持当前 workspace/tab 等别名,但最终要解析成明确层级和 TerminalId,再执行动作。

这种设计牺牲了编号连续美观,换来异步自动化安全。对控制平面而言,这是正确取舍。

5.11 移动、关闭与空容器

移动 pane

移动应尽量保持 TerminalId 和 runtime 不变,只改变:

  • 源/目标 TileLayout;
  • PaneState 所属 map;
  • 公开位置解析;
  • focus、active tab 和可能的空容器清理。

关闭 pane

树删除一个叶子后,其 sibling 替换父 split。若 tab 最后一个 pane 关闭,需要决定关闭 tab、创建替代 shell 还是关闭 workspace;这些是 app action 层策略,不属于纯 TileLayout。

关闭 tab/workspace

必须遍历所有附着 terminal,而不是只关闭当前可见 pane。若某些 runtime 被移动、共享或正处于 handoff,还要以 registry/ownership 为准,避免 double shutdown。

5.12 Workspace 与 Git 缓存

Workspace UI 经常显示 branch、repo、dirty state。直接在 render 中执行 git 会把 pane 数量乘以刷新频率。Herdr 把 Git identity/status 放入缓存,由后台发现和事件更新;render 只读准备好的摘要。

这也是 Workspace 字段看起来超出“tabs 容器”的原因:Git/worktree 是 workspace 身份的一部分,但探测必须被移出热路径。第 12 章会沿异步 worktree 操作完整展开。

5.13 快照形状

持久化不直接 serde 内部树,而是用 LayoutSnapshot 镜像 BSP 结构:pane 叶子保存可恢复引用,split 保存 direction/ratio/children。恢复时还要验证:

  • 每个 layout leaf 都有 pane snapshot;
  • active/focused/zoomed ID 存在;
  • ratio 有限且合法;
  • 旧版本无 tab 的 workspace 可迁移;
  • 无法恢复的 pane 不破坏整棵 workspace。

5.14 可迁移经验

  1. 视图位置和资源身份分开,移动 UI 节点不应重建进程。
  2. BSP 树是分屏的稳定最小模型,比存绝对坐标更适合 resize 和持久化。
  3. 公开 ID 应优化异步安全,不应优化连续美观。
  4. 跨 state/runtime 的 UI 动作要按补偿事务设计。
  5. zoom、hide、background 是投影状态,不应暗含资源销毁。

下一章穿过 TerminalId 进入活跃 runtime:一串不可打印的 PTY 字节如何驱动 Ghostty VT、终端回包、标题/剪贴板回调、检测文本和最终画面。