refactor(terminal): fold the owned-leaf rule into its only consumer

The leaf-claims module had two functions for two readers; with the hydration heal
gone, one has no reader and the other has exactly one. Inline it, keeping the
empty-binding filter and the reason for it, and drop the module.

Also rewords the comments that explained an unbound leaf by the heal. A pane keeps
its leaf after its PTY exits, which is the reason that outlives this change.
This commit is contained in:
Jinwoo-H
2026-09-21 00:23:23 -04:00
parent 29b5bb432a
commit 5cd43f83e5
4 changed files with 26 additions and 51 deletions
@@ -1,46 +0,0 @@
import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types'
import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids'
export type TerminalLayoutLeafClaims = {
root: TerminalPaneLayoutNode | null
activeLeafId?: string | null
ptyIdsByLeafId?: Record<string, string>
}
/**
* Leaf ids a rootless layout actually binds. The persisted map types its values as a plain
* string, so an empty one survives the schema and is not a binding: counting it would let a
* layout look like it holds a leaf no session is attached to.
*/
function boundLeafIds(layout: TerminalLayoutLeafClaims): string[] {
return Object.entries(layout.ptyIdsByLeafId ?? {})
.filter(([, ptyId]) => Boolean(ptyId))
.map(([leafId]) => leafId)
}
/**
* Leaf ids this layout holds, read generously: its tree, or — for a rootless layout, which binds
* its sole pane off-tree — every leaf it binds. Use this to ask "does some pane already hold
* this?", where over-counting only costs a reveal that adopts instead of minting.
*
* A binding whose leaf has left a rooted tree reattaches nothing, so it is excluded either way.
*/
export function collectOwnedLeafIds(layout: TerminalLayoutLeafClaims): Set<string> {
return new Set(layout.root ? collectLeafIdsInOrder(layout.root) : boundLeafIds(layout))
}
/**
* Leaf ids this layout may take from another tab, read narrowly. Same as the owned set for a
* rooted layout, but a rootless one proves only its sole off-tree pane, or the one its
* `activeLeafId` names: a never-pruned map holds more than it owns, and claiming those evicts
* the live row that really owns them (#13098). Mirrors the `owned`/`claimable` split in
* `terminal-session-row-hydration.ts`, which is the guard that caught #13060.
*/
export function collectClaimableLeafIds(layout: TerminalLayoutLeafClaims): Set<string> {
if (layout.root) {
return new Set(collectLeafIdsInOrder(layout.root))
}
const leafIds = boundLeafIds(layout)
const provenLeafId = leafIds.length === 1 ? leafIds[0] : layout.activeLeafId
return new Set(leafIds.filter((leafId) => leafId === provenLeafId))
}
@@ -1,4 +1,5 @@
import { collectOwnedLeafIds } from '@/components/terminal-pane/terminal-layout-leaf-claims'
import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids'
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
import type { AppState } from '@/store/types'
/** No `tabsByWorktree`: ownership is tab-keyed, so no worktree key participates. */
@@ -22,6 +23,26 @@ export type TerminalPtyPaneOwnerOptions = {
preferTabId?: string
}
/**
* Leaf ids this layout holds: its tree, or — for a rootless layout, which binds its sole pane
* off-tree — every leaf it actually binds. A binding whose leaf has left a rooted tree reattaches
* nothing, so it must not outrank a live pane (#13098).
*
* Why the truthiness check: the persisted map types its values as a plain string, so an empty one
* survives the schema and is not a binding. Counting it would name a leaf no session is attached
* to, and a reveal that adopted that tab would show nothing.
*/
function collectOwnedLeafIds(layout: TerminalLayoutSnapshot): Set<string> {
if (layout.root) {
return new Set(collectLeafIdsInOrder(layout.root))
}
return new Set(
Object.entries(layout.ptyIdsByLeafId ?? {})
.filter(([, ptyId]) => Boolean(ptyId))
.map(([leafId]) => leafId)
)
}
/** The leaf a tab's layout binds to `ptyId`, or null when no leaf it owns holds that binding. */
function findLayoutBoundLeafId(
state: TerminalPtyPaneOwnerState,
@@ -1,6 +1,6 @@
// Which tab owns a leaf id decides whether a reveal adopts a pane or mints a second one
// (STA-7961). Two layouts can name the same leaf: one that still mounts it, and one left
// holding the id by a detach or by the hydration self-heal.
// holding the id by a detach.
import { describe, expect, it } from 'vitest'
import { findTerminalTabIdBindingLeafId } from './terminal-reveal-tab-adoption'
import type { AppState } from '@/store/types'
@@ -47,7 +47,7 @@ describe('findTerminalTabIdBindingLeafId', () => {
})
it('prefers the tab that binds the leaf over one that only carries it unbound', () => {
// The hydration self-heal unbinds the losing single-leaf tab but leaves the id in its tree.
// A pane keeps its leaf in the tree after its PTY exits, so the id outlives the binding.
const bound = layout(leaf(SHARED_LEAF_ID), { [SHARED_LEAF_ID]: 'pty-a' })
const unbound = layout(leaf(SHARED_LEAF_ID))
@@ -15,8 +15,8 @@ export type TerminalRevealTabAdoption =
| { kind: 'mint' }
/**
* The tab whose layout owns a leaf id. Bound-and-in-tree beats in-tree-unbound, because the
* hydration self-heal leaves a losing single-leaf tab carrying its leaf with no session to adopt.
* The tab whose layout owns a leaf id. Bound-and-in-tree beats in-tree-unbound, because a pane
* keeps its leaf after its PTY exits or is cleared, and such a tab has no session to adopt.
* Every layout is scanned, including ones whose row is gone: a leaf id is a pane identity for its
* lifetime, and re-minting one an orphan layout still holds is how the STA-7961 pair was created.
*/