数据截至 (上游 commit 4ec6bbf5884e)
领域模型与状态骨架 — Project / Worktree / Tab 怎么被表达和存住
30 秒导读: Orca 是给"一群并行跑的编码 agent"用的 IDE。要让十几个 agent 各占一个工作区同时干活,第一件事是把"工作区是什么、怎么认出它、状态存在哪"定死。这一章只讲数据结构和身份规则:五个核心名词、三套 id、git worktree 与普通文件夹的双形态、tab 的分屏树,以及"主进程管落盘、渲染进程管内存"的分工。
这章不讲工作区怎么被创建和销毁(见 一条 worktree 的一生),也不讲终端和 PTY(见 终端底座)、RPC 与远程执行(见 runtime 与 RPC)。
1. 这章要解决的问题(零基础也能懂)
场景
你在一个仓库里同时让 5 个 agent 干 5 件事。最朴素的做法是开 5 个终端,但它们共享同一份工作副本——A 改的文件 B 看得见,git 状态互相打架。
Git 自带的解法叫 worktree(工作树:同一个 .git 仓库,签出到多个互不干扰的目录)。Orca 把这个原语包装成产品级概念:一个 worktree = 一个工作区 = 侧边栏里的一行 = 一组终端标签页 = 一个 agent 的地盘。
于是要回答三个问题
| 问题 | 这章的答案 |
|---|---|
| 怎么唯一地"指着"一个工作区? | ${repoId}::${worktreePath},路径就是身份 |
| 不是 git 仓库的普通文件夹怎么办? | 造一个"假 worktree"投影进同一套类型 |
| 状态存哪?谁说了算? | 磁盘上的 orca-data.json 是权威,渲染进程持一份可重建的镜像 |
一句话直觉
把 Orca 的领域模型想成"文件系统 + 便签":git 和磁盘提供硬事实(这个目录存在、在哪个分支),Orca 只在旁边贴便签(显示名、关联的 issue、置顶、未读)。便签用路径当图钉钉在硬事实上——这就是本章所有取舍的源头。
2. 五个名词,先认人
Orca 的核心类型曾经集中在一个巨型 src/shared/types.ts 里;上游已把它按领域拆分成 src/shared/*-types.ts 一族(repo-types.ts、tab-types.ts、persisted-state-types.ts……)加一个 src/shared/worktree/ 子目录(types.ts/meta-types.ts/lineage-types.ts 等),主进程和渲染进程都从这里 import。下表给出拆分后的定义处。
| 名词 | 是什么 | 身份 | 定义处 |
|---|---|---|---|
Repo | 一个被导入的本地/远程仓库或文件夹 | randomUUID() | src/shared/repo-types.ts:42 |
Project | 跨多个 Repo 的持久项目身份(一个项目可能在 Mac 和 SSH 各有一份签出) | randomUUID(),持 sourceRepoIds[] | src/shared/project-types.ts:16 |
ProjectGroup | 侧边栏的分组(纯组织,不影响执行) | randomUUID() | src/shared/project-group-types.ts:3 |
Worktree | 工作区——一次 git worktree 签出,或它的文件夹等价物 | ${repoId}::${path} | src/shared/worktree/types.ts:60 |
Tab | 工作区里的一个内容页(终端 / 编辑器 / diff / 浏览器…) | UUID 或文件路径 | src/shared/tab-types.ts:40 |
再加一个横跨所有名词的维度:
ExecutionHostId—— 这份东西在哪台机器上执行。三种取值:'local'、ssh:${targetId}、runtime:${environmentId}(src/shared/execution-host.ts:9)。
层级长这样
ProjectGroup ── 侧边栏分组,可嵌套(parentGroupId)
│
├── Repo ──────────── 一个签出根,id = UUID
│ │ path / connectionId / executionHostId
│ │
│ ├── Worktree ── 工作区,id = `${repoId}::${path}`
│ │ │
│ │ ├── TabGroup ── 一个"标签条+内容区",可被分屏树摆放
│ │ │ └── Tab ── terminal / editor / diff / browser / …
│ │ └── TabGroupLayoutNode ── 分屏树(leaf=groupId, split=first/second)
│ │
│ └── Worktree …(并行的其它 agent 地盘)
│
└── FolderWorkspace ── 非 git 的工作区,挂在 group 上而不是 repo 上
Project 不在这棵树上——它是横向的持久身份,靠 sourceRepoIds 把散落在不同宿主上的 Repo 串成一个项目(src/shared/project-types.ts:16,Worktree.projectId 是它在工作区上的回指)。
3. 顶层全景:状态从磁盘到屏幕
先给"怎么读这张图":从左到右是启动时的水化方向,从右到左是用户操作后的回写方向。
磁盘 主进程 (Electron main) 渲染进程 (React)
┌──────────┐ ┌───────────────────────┐ ┌────────────────────┐
│ orca- │ load │ Store 类 │ IPC │ zustand useAppStore│
│ data.json│ ─────► │ 持 PersistedState │ ───► │ 40 个 slice 组合 │
│ │ ◄───── │ ① 归一化 ② 防抖落盘 │ ◄─── │ 内存镜像 + 纯瞬态 │
└──────────┘ save └───────────────────────┘ set └────────────────────┘
▲ ▲ ▲
唯一权威 ①改 id ②GC ③迁移 水化时丢弃不认识的行
三个部件各自的职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
orca-data.json | 唯一的持久权威;PersistedState 的序列化 | <userData>/orca-data.json(src/main/persistence/loading-store/user-data-path.ts:24) |
Store 类 | 读/写/防抖/归一化/GC/身份迁移 | src/main/persistence/loading-store/store.ts:511 |
useAppStore | 渲染进程的单一 zustand store,40 个 slice 拼成 AppState | src/renderer/src/store/index.ts:61 |
主线走一遍(不进代码):
- 启动 —
Store构造函数load()读 JSON,跑一遍 pane 身份归一化,缺字段的用默认值补齐(src/main/persistence/loading-store/store.ts:566构造调load()于:807,pane 身份归一化经normalizeWorkspaceSessionPaneIdentities于:2912)。 - 列工作区 — 主进程跑
git worktree list,把硬事实(路径/HEAD/分支)和便签(WorktreeMeta)合并成完整Worktree发给渲染进程。 - 水化 UI — 渲染进程用
WorkspaceSessionState重建标签页和分屏树,凡是引用了不存在工作区的行一律丢弃。 - 回写 — 用户开关标签页 → 渲染进程整包
session:set给主进程 → 主进程归一化后防抖 1 秒落盘。
4. 身份规则:三套 id,一个取舍
这是全章最重要的一节。Orca 里有三层身份,各解决一个问题。
4.1 repoId — 一次性发的 UUID
导入一个仓库时直接 randomUUID(),之后再不变(src/main/ipc/repos.ts:351)。所以仓库整体挪位置不会丢 身份。
4.2 worktreeId — 路径即身份
export type Worktree = {
id: string // `${repoId}::${path}`
...
—— src/shared/worktree/types.ts:60-61,注释就写在类型上。
分隔符 :: 定义在一个共享常量里,明确说明"曾经因为三处各写一遍 parser 而踩过 bug,所以集中到这里"(WORKTREE_ID_SEPARATOR,src/shared/pty-session-id-format.ts:15)。解析走 splitWorktreeId(src/shared/worktree/id.ts:20),主进程侧包一层会抛错的 parseWorktreeId(src/main/ipc/worktree-logic.ts:264)。
这么设计换来了什么:
- 不需要注册表。
git worktree list吐出路径,直接就能拼出 id、去worktreeMeta里查便签。用户在 Orca 之外手动git worktree add出来的目录,下次刷新自动被认领。 - id 自带定位信息。 拿到 id 就知道 cwd 该设成哪、属于哪个 repo,不用回表。
- 删除天然幂等。 目录没了,id 自然再也不出现。
代价是什么: 目录一改名,身份就断了。
4.3 改名迁移:priorWorktreeIds 和那 35 张表
改名不是罕见操作——Orca 有个功能是"agent 说的第一句话决定工作区叫什么",它会把分支和显示名改掉,然后顺手 git worktree move 把文件夹也对齐(renameWorktreeFolderOnFirstWork,src/main/agent-hooks/first-work-folder-rename.ts:37)。文件夹一动,${repoId}::${path} 就换了一个。
处理顺序被注释钉死:"先 move(不可回头点),再同步重打身份,不让任何东西插进来"(同文件 :68-71):
await deps.moveWorktree(repo.path, plan.oldPath, plan.newPath)
deps.migrateWorktreeIdentity(worktreeId, plan.newWorktreeId)
deps.notifyWorktreeRenamed(repo.id, worktreeId, plan.newWorktreeId)
主进程侧 Store.migrateWorktreeIdentity(src/main/persistence/loading-store/store.ts:2592)做三件事:
- 搬 key ——
worktreeMeta、worktreeLineageById、workspaceLineageByChildKey、各 host 分区的 session…… 逐个record[new] = record[old]; delete record[old]。 - 记旧账 —— 把旧 id 追加进新 meta 的
priorWorktreeIds(src/main/persistence/tracking-repos/worktree-identity-migration.ts:148-154)。 - 修反向引用 —— 别的工作区的
lineage.parentWorktreeId指着旧 id 的,一并改掉。
第 2 步为什么必须有?因为 PTY 会话是在旧 id 下铸出来的。守护进程的会话 GC 看到一个"归属于不存在的工作区"的会话就会回收它——不留旧账,改个名字等于把用户正在跑的 agent 杀了。类型注释把这句话写得很清楚:
让守护进程的 session GC 和注册表水化认得出用旧 id 铸出来的会话,而不是把它们当孤儿收掉。 ——
WorktreeMeta.priorWorktreeIds,src/shared/worktree/meta-types.ts:81-85
渲染进程有一份对 称的实现:buildWorktreeRenameState(src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts:55)遍历一张常量表 WORKTREE_ID_KEYED_MAP_KEYS——35 个以 worktreeId 为 key 的 map(src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts:11-53),从 tabsByWorktree 到 gitStatusByWorktree 到 expandedDirs,一个都不能漏。漏一个,用户改完名就会发现某个面板空了。
还有更细的坑:终端"重开上次关掉的标签页"快照里存的是绝对 startupCwd,不重映射的话 Cmd+Shift+T 会 spawn 到一个已经不存在的目录去(remapClosedTerminalTabSnapshotCwds,worktree-identity-rename-state.ts:105-112;函数本体在 slices/recently-closed-tabs.ts:78)。
一句话总结这个取舍:
| 路径即身份 | 独立 UUID + 路径字段 | |
|---|---|---|
| 自动发现外部 worktree | 免费 | 需要注册/对账 |
| 改名 | 要在两个进程重打 30+ 张表 | 免费 |
| id 可读性 | 高(自带路径) | 低 |
| Orca 的选择 | ✅ | — |
4.4 instanceId — 不随路径漂的那一半
正因为 id 会变,Orca 又补了一个不变的 instanceId:
每个工作区实例不可变的 ID,用来在路径被复用后拒绝过期的 lineage。 ——
WorktreeMeta.instanceId,src/shared/worktree/meta-types.ts:18-19
它专门服务于血缘(lineage)。WorktreeLineage(src/shared/worktree/lineage-types.ts:19)同时记 parentWorktreeId(路径派生,会漂)和 parentWorktreeInstanceId(不漂)。场景:一个协调者 agent 在 A 工作区里派生出 B 工作区,A 后来被删、同一个路径被新建的 C 占用——只比路径的话 B 会误认 C 当父亲,比 instanceId 就不会。
4.5 WorkspaceKey — 给"工作区"这个概念一个统一 key
worktree 之外还有 folder workspace,两者要能塞进同一个 Record。于是有了带前缀的 key:
export type WorkspaceScope =
| { type: 'worktree'; worktreeId: string }
| { type: 'folder'; folderWorkspaceId: string }
export type WorkspaceKey = `worktree:${string}` | `folder:${string}`
—— src/shared/folder-workspace-types.ts:11,构造/解析在 src/shared/workspace-scope.ts:3(worktreeWorkspaceKey)、:7(folderWorkspaceKey)、:11(parseWorkspaceKey)。
新代码用 WorkspaceKey,老代码继续读裸 activeWorktreeId,getActiveSidebarWorkspaceId(src/shared/workspace-scope.ts:29)负责在两套之间兜底。这是一次没做完的迁移,WorkspaceSessionState 的注释直说了:"key 可能是老式裸 worktree ID,也可能是规范的 WorkspaceKey"(src/shared/workspace-session-state-types.ts:39-40)。
4.6 ExecutionHostId — 身份还要带宿主
同一个 repoId::path 在两台服务器上可能都存在。所以宿主 id 是身份不可分割的一部分:
'local' 本机(Mac / Windows / Linux)
'ssh:<encodeURIComponent(targetId)>' SSH 目标
'runtime:<encodeURIComponent(envId)>' 远程 Orca runtime(含临时 VM)
—— src/shared/execution-host.ts:9,解析器 parseExecutionHostId(:69)连 decodeURIComponent 失败都当无效处理。
宿主的优先级是一段值得抄的代码(getWorktreeExecutionHostId,src/shared/execution-host.ts:161):工作区自己的 hostId 优先,其次从 repo 推(executionHostId > connectionId > 'local')。注释解释了为什么统一成一个函数:runtime 和 SSH 快照能给出比 repo 兜底更精确的归属,"侧边栏的每一次宿主判断都必须走同一套优先级"。
Repo 上为什么既有 connectionId 又有 executionHostId?类型注释给了答案:runtime 宿主上的 repo connectionId: null,看起来和本地 repo 一模一样,必须有显式字段区分(src/shared/repo-types.ts:61-66)。
5. 双形态:git worktree 与 folder workspace
5.1 为什么要有第二种形态
不是所有人都想用 git worktree。有人只想让 agent 在一个普通目录里干活(写文档、跑脚本、非 git 项目)。Orca 的处理不是新开一套 UI,而是把文件夹伪装成 worktree,让侧边栏、标签页、终端全部复用同一条代码路径。
代价是"folder workspace"在代码里有两条不同的实现路线,读代码时最容易在这里迷路:
| 路线 | 记录类型 | 挂在谁下面 | id 形状 | 投影函数 |
|---|---|---|---|---|
| A. 独立 folder 工作区 | FolderWorkspace | projectGroupId | folder:<uuid> | folderWorkspaceToWorktree(src/shared/folder-workspace-worktree.ts:7) |
| B. folder 型 repo 的会话实例 | WorktreeMeta | repoId | ${repoId}::${repoPath}::workspace:<uuid> | mergeFolderWorkspace(src/main/ipc/worktrees.ts:953) |
路线 B 的分隔符是另一个常量 FOLDER_WORKSPACE_INSTANCE_SEPARATOR = '::workspace:'(src/shared/worktree/id.ts:10)。它的存在是因为:一个文件夹项目可以开多个并行会话,但它们共享同一个物理目录。所以有了一对"孪生"解析器:
splitWorktreeId(id) // 拿身份 → 保留 ::workspace:<uuid> 后缀
splitWorktreeIdForFilesystem(id) // 拿路径 → 剥掉后缀,得到真实目录
—— src/shared/worktree/id.ts:20 与 :31,后者的注释直说:"文件系统调用方要的是真实文件夹路径当 cwd/root"。
踩坑提示: 用错了 splitter 会导致 cwd 指向一个不存在的 .../foo::workspace:abc-123 目录。gcStaleWorktreeMeta 里也专门跳过带这个分隔符的 key,因为"folder 项目的工作区实例本身就是工作区记录,不是一行签出"(src/main/persistence/tracking-repos/worktree-metadata-normalization.ts:35)。
5.2 三个 merge 函数,一个 Worktree 类型
不管哪条路线,最后都收敛成同一个 Worktree:
git worktree list ──┐
(GitWorktreeInfo)│ mergeWorktree(repoId, git, meta) ← 硬事实 + 便签
├──► Worktree ◄── mergeFolderWorkspace(repo, id, meta) ← 路径 B
WorktreeMeta ────┘ └─ folderWorkspaceToWorktree(fw) ← 路径 A
mergeWorktree(src/main/ipc/worktree-metadata-merge.ts:11)是最典型的一个,显示名的兜底链很能说明设计意图:
displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path)
用户改的名 → 分支名 → repo 名 → 目录名。folder 路线因为没有 git 事实,head/branch 直接填空串、isBare: false(src/shared/folder-workspace-worktree.ts:46-49)——类型上是 Worktree,语义上有一半字段是假的,这是这个投影方案的固有代价。
Worktree 的类型定义把这层结构写得很直白:它是 {...应用层字段} & GitWorktreeInfo(src/shared/worktree/types.ts:60 与 meta-types.ts:72-78 的创建标记字段),交叉类型的右半边就是 git 那部分硬事实(src/shared/worktree/types.ts:21)。
5.3 归属:这个 worktree 是谁造的
自动发现带来一个新问题:git worktree list 里既有 Orca 造的,也有用户手工造的,还有 agent 自己偷偷造的临时目录。于是有 WorktreeOwnership 四态(src/shared/worktree/types.ts:198):
| 取值 | 含义 | 判据 |
|---|---|---|
orca-managed | Orca 亲手造的 | meta 里有"强证据"字段 |
agent-scratch | 子 agent 的临时工作树(如 .claude/worktrees) | 路径匹配 |
external | 用户在 Orca 之外造的 | 不在 Orca 的 workspace 根下 |
unknown-legacy | 说不准 | 兜底 |
判定顺序在 classifyWorktreeOwnership(src/shared/worktree/ownership.ts:111),"强证据"的定义是一组只有 Orca 会写的字段:orcaCreatedAt、orcaCreationWorkspaceLayout、createdWithAgent、pushTarget、sparsePresetId……(hasStrongOrcaMetadata,src/shared/worktree/ownership.ts:227)。
为什么不能只看路径? 代码里有一句关键注释:普通的 git worktree add 完全可以把目 标定在 Orca 的嵌套 workspace 目录里——"只有 metadata 能证明是 Orca 造的"(src/shared/worktree/ownership.ts:152-154)。
带上归属的 Worktree 叫 DetectedWorktree(src/shared/worktree/types.ts:202),多三个字段:ownership、selectedCheckout、visible。
5.4 路径是怎么算出来的
既然路径就是身份,那算路径的函数就是身份的铸造厂。computeWorktreePath(src/main/ipc/worktree-logic.ts:101):
workspaceRoot = repo.worktreeBasePath ?? settings.workspaceDir
└─ WSL 特例:镜像到 \\wsl.localhost\<distro>\home\<u>\orca\workspaces
nestWorkspaces = true → <root>/<repoName>/<sanitizedName>
nestWorkspaces = false → <root>/<sanitizedName>
两个安全 守卫值得单独看:
sanitizeWorktreeName(src/main/ipc/worktree-logic.ts:25)保留 Unicode 字母数字(让人能用中文命名工作区),但把连续的点折叠成一个——注释说明原因:git check-ref-format拒绝含..的 ref,一个像../../foo的提示词 slug 化后会变成..-..-foo,git 会直接拒建分支。ensurePathWithinWorkspace(src/main/ipc/worktree-logic.ts:79)用path.relative判断结果是否逃出了 workspace 根,防目录穿越。
6. 分屏树:tab → tabGroup → leaf
6.1 要解决的小问题
一个工作区里用户会同时要:一个跑 agent 的终端、一个看代码的编辑器、一个看 diff 的面板,还想把它们左右分屏。分屏是递归的(左边再上下分),所以只能用树。
6.2 三层结构
worktreeId
│
├── layoutByWorktree[worktreeId] : TabGroupLayoutNode ← 分屏树
│ split(direction, ratio)
│ ├── leaf(groupId: "g1")
│ └── split
│ ├── leaf(groupId: "g2")
│ └── leaf(groupId: "g3")
│
├── groupsByWorktree[worktreeId] : TabGroup[] ← 每个 group 一条标签条
│ { id: "g1", activeTabId, tabOrder[], recentTabIds[] }
│
└── unifiedTabsByWorktree[worktreeId] : Tab[] ← 扁平的标签页清单
{ id, entityId, groupId, contentType, label, ... }
三层各自 的分工(src/renderer/src/store/slices/tabs.ts:63-69):
| 层 | 类型 | 存什么 | 定义处 |
|---|---|---|---|
| 布局 | TabGroupLayoutNode | 只有 leaf(groupId) 和 split(first, second, ratio) | src/shared/tab-types.ts:7 |
| 分组 | TabGroup | 标签顺序 tabOrder[] + 活跃页 + MRU 栈 recentTabIds[] | src/shared/tab-types.ts:68 |
| 页 | Tab | 内容类型、标签文字、entityId | src/shared/tab-types.ts:40 |
id 和 entityId 为什么要分开? Tab 是"UI 上的一格",真正的内容活在别的 slice 里:终端 tab 的 entityId 指向 TerminalTab 记录,编辑器的指向文件路径,浏览器的指向 BrowserWorkspace id(src/shared/tab-types.ts:42)。这样"关闭标签页"和"销毁内容"可以解耦。
contentType 有 7 种:terminal | editor | diff | conflict-review | check-details | browser | simulator(src/shared/tab-types.ts:19-27)。其中只有 4 种算"工作区可见标签"(WorkspaceVisibleTabType,src/shared/tab-types.ts:28)——diff 之类是瞬态的,重启不恢复。
recentTabIds 是个小而妙的设计:每个 group 独立维护 MRU 栈,关掉当前页时回退到上一个用过的页,而不是跳到视觉上的邻居;作用域限定在 group 内,所以分屏的两半各有各的历史(src/shared/tab-types.ts:72-78)。
6.3 还有第二棵树:终端窗格
一个终端 tab 内部还能再分屏。这是另一棵独立的树 TerminalPaneLayoutNode,叶子不是 groupId 而是 leafId(src/shared/terminal-tab-types.ts:60)。
两棵树的关系:
TabGroupLayoutNode (工作区级)leaf = groupId → 一条标签条
└── Tab (terminal)
└── TerminalPaneLayoutNode (tab 级)leaf = leafId → 一个 xterm 窗格
leafId 必须是 UUID,由一个正则守着(UUID_RE + isTerminalLeafId,src/shared/stable-pane-id.ts:4/:18)。窗格的全局身份是 paneKey:
export function makePaneKey(tabId: string, stableLeafId: string): PaneKey {
if (!tabId || tabId.includes(':')) throw new Error('tabId must be non-empty and must not contain ":"')
if (!isTerminalLeafId(stableLeafId)) throw new Error('stableLeafId must be a UUID')
return `${tabId}:${stableLeafId}` as PaneKey
}
—— src/shared/stable-pane-id.ts:22。
文件顶部的注释解释了为什么非要用持久 UUID 而不是渲染进程本地的数字窗格 id:paneKey 要跨渲染进程重载、跨 PTY 环境变量、跨 hook IPC、跨保留的 UI 行(src/shared/stable-pane-id.ts:1-3)。agent 状态就是按 paneKey 索引的(agentStatusByPaneKey,src/renderer/src/store/slices/agent-status.ts:177)——详见 认得出 agent。
历史包袱也留着:parseLegacyNumericPaneKey(src/shared/stable-pane-id.ts:47)还能认出老的 tabId:0 形式,用于建别名。
7. 主进程持久化 vs 渲染进程内存态
7.1 分工原则
主进程 Store | 渲染进程 useAppStore | |
|---|---|---|
| 权威性 | 唯一权威,落 orca-data.json | 镜像 + 瞬态,随时可从主进程重建 |
| 内容 | 用户意图(便签、设置、会话布局) | 上面全部 + 实时状态(agent 状态、git status、PTY 绑定) |
| 生命周期 | 跨重启 | 随窗口消失 |
| 写入方式 | 防抖 1s / 强制 5s | 同步 set() |
判断某个东西该放哪,有一条清晰的线:能从磁盘/git/进程重新算出来的,不存。TerminalLayoutSnapshot.ptyIdsByLeafId 的注释是最好的例子——"用于会话内的重挂载(比如 tab 移组);不用于应用重启,因为 PTY 是瞬态进程"(src/shared/terminal-tab-types.ts:78-80)。
7.2 落盘的东西:PersistedState
PersistedState(src/shared/persisted-state-types.ts:50)是 orca-data.json 的形状,默认值在 getDefaultPersistedState(src/shared/constants.ts:425),SCHEMA_VERSION = 1(src/shared/constants.ts:46)。
跟本章相关的字段:
| 字段 | 内容 |
|---|---|
repos / projects / projectGroups / folderWorkspaces | 四张实体表 |
worktreeMeta: Record<worktreeId, WorktreeMeta> | 便签表——路径为 key 的那张 |
worktreeLineageById / workspaceLineageByChildKey | 血缘(谁派生了谁) |
workspaceSession | 'local' 宿主的会话布局 |
workspaceSessionsByHostId | 其它宿主的会话分区 |
githubCache | 单独走 sidecar 文件 |
注意两处刻意的向后兼容设计:
workspaceSession保留成裸字段而不是并进workspaceSessionsByHostId['local'],注释写明理由:"遗留的单 blob 会话,作为规范的 'local' 分区保留,这样应用降级后仍能读到自己的工作区"(src/shared/persisted-state-types.ts:88)。githubCache拆成 sidecar:它每轮轮询都刷新,写在主文件里会导致每个周期重写整个数 MB 的 JSON(src/main/persistence/loading-store/user-data-path.ts:29)。
WorktreeMeta(src/shared/worktree/meta-types.ts:17)和 Worktree 高度重合但不含 git 事实——没有 head/branch/path。这正是"便签 vs 硬事实"的边界:便签落盘,硬事实每次问 git。
7.3 会话状态:一个巨型可选字段包
WorkspaceSessionState(src/shared/workspace-session-state-types.ts:32)是重启 后重建 UI 所需的一切:tabsByWorktree、unifiedTabs、tabGroups、tabGroupLayouts、openFilesByWorktree、browserTabsByWorktree、terminalLayoutsByTabId……几乎每个字段都是可选的,因为老版本写下的 session 必须能被新版本读。
最典型的双读路径写在类型注释里:
统一 tab 模型 —— 由包含 TabsSlice 的构建版本写入时才存在。读路径先查它,缺失则回退到 legacy 字段。 ——
WorkspaceSessionState.unifiedTabs,src/shared/workspace-session-state-types.ts:68-69
对应的实现就是 buildHydratedTabState(src/renderer/src/store/slices/tabs-hydration.ts:314):
if (session.unifiedTabs && session.tabGroups) {
return hydrateUnifiedFormat(session, validWorktreeIds)
}
return hydrateLegacyFormat(session, validWorktreeIds)
legacy 路径会把老的 tabsByWorktree + openFilesByWorktree 现场合成一个 group(:189 起)。
水化时的自我保护:所有 hydrate 函数都收一个 validWorktreeIds: Set<string>,key 不在集合里就整条跳过(:56-58)。分屏树则走 pruneTabGroupLayoutForGroups(:24)——递归剪掉指向已消失 group 的叶子,只剩一边就把另一边提上来,两边都在且没变就返回原节点(保持引用相等,省一次 React 重渲染)。