Files
orca/src/main/runtime/headless-tab-group-split-layout.ts
T
Jinjing 954fa0a65e Prevent auto-reloading diff views when status area shifts (#5951)
* Avoid auto-reloading single-file diff tabs if their status row has
  moved to a different area (e.g. from unstaged to staged).
* Prevent auto-reloading combined uncommitted diffs when they are
  backed by an uncommitted entries snapshot.
* This preserves the open tab's original diff context as staging and
  commit status changes, preventing useful context from being lost.
2026-06-20 18:52:04 -07:00

218 lines
7.2 KiB
TypeScript

import type { RuntimeMobileSessionTabGroup } from '../../shared/runtime-types'
import type { TabGroupLayoutNode } from '../../shared/types'
/**
* Headless ("Orca server") tab-GROUP split operations (distinct from terminal
* PANE splits inside one tab). The headless host historically coalesced every
* tab into a single group, so a client drag-to-split-group was lost on the next
* snapshot. These pure helpers let the host model + persist a real multi-group
* layout, mirroring the renderer's buildSplitNode/replaceLeaf semantics so host
* and client agree on the tree.
*/
type SplitDirection = 'left' | 'right' | 'up' | 'down'
function buildSplitNode(
existingGroupId: string,
newGroupId: string,
direction: 'horizontal' | 'vertical',
position: 'first' | 'second'
): TabGroupLayoutNode {
const existingLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: existingGroupId }
const newLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: newGroupId }
return {
type: 'split',
direction,
first: position === 'first' ? newLeaf : existingLeaf,
second: position === 'second' ? newLeaf : existingLeaf,
ratio: 0.5
}
}
function replaceLeaf(
root: TabGroupLayoutNode,
targetGroupId: string,
replacement: TabGroupLayoutNode
): TabGroupLayoutNode {
if (root.type === 'leaf') {
return root.groupId === targetGroupId ? replacement : root
}
return {
...root,
first: replaceLeaf(root.first, targetGroupId, replacement),
second: replaceLeaf(root.second, targetGroupId, replacement)
}
}
/** Collect every groupId referenced by a layout tree. */
export function collectTabGroupLayoutGroupIds(
node: TabGroupLayoutNode | null | undefined,
groupIds = new Set<string>()
): Set<string> {
if (!node) {
return groupIds
}
if (node.type === 'leaf') {
groupIds.add(node.groupId)
return groupIds
}
collectTabGroupLayoutGroupIds(node.first, groupIds)
collectTabGroupLayoutGroupIds(node.second, groupIds)
return groupIds
}
/** Remove a leaf from the tree, collapsing the parent split into the sibling. */
export function removeTabGroupLayoutLeaf(
root: TabGroupLayoutNode | null | undefined,
groupId: string
): TabGroupLayoutNode | null {
if (!root) {
return null
}
if (root.type === 'leaf') {
return root.groupId === groupId ? null : root
}
const first = removeTabGroupLayoutLeaf(root.first, groupId)
const second = removeTabGroupLayoutLeaf(root.second, groupId)
if (!first) {
return second
}
if (!second) {
return first
}
return { ...root, first, second }
}
export type HeadlessTabGroupMoveResult = {
groups: RuntimeMobileSessionTabGroup[]
layout: TabGroupLayoutNode | null
}
/**
* Move `tabId` into an EXISTING `targetGroupId` (a non-split drop), mirroring the
* renderer's move-to-group path. Inserts at `index` (clamped), removes the tab
* from its source group, and drops a source group emptied by the move (collapsing
* it out of the layout). Returns null when the tab/target is missing or it's a
* same-group no-op.
*/
export function buildHeadlessTabGroupMove(args: {
groups: readonly RuntimeMobileSessionTabGroup[]
layout: TabGroupLayoutNode | null | undefined
tabId: string
targetGroupId: string
index?: number
}): HeadlessTabGroupMoveResult | null {
const sourceGroup = args.groups.find((group) => group.tabOrder.includes(args.tabId))
const targetGroup = args.groups.find((group) => group.id === args.targetGroupId)
if (!sourceGroup || !targetGroup) {
return null
}
if (sourceGroup.id === args.targetGroupId) {
return null
}
let groups: RuntimeMobileSessionTabGroup[] = args.groups.map((group) => {
if (group.id === sourceGroup.id) {
const tabOrder = group.tabOrder.filter((id) => id !== args.tabId)
return {
...group,
tabOrder,
activeTabId: group.activeTabId === args.tabId ? (tabOrder[0] ?? null) : group.activeTabId
}
}
if (group.id === args.targetGroupId) {
const tabOrder = group.tabOrder.filter((id) => id !== args.tabId)
const at = Math.max(0, Math.min(args.index ?? tabOrder.length, tabOrder.length))
tabOrder.splice(at, 0, args.tabId)
return { ...group, tabOrder, activeTabId: args.tabId }
}
return group
})
groups = groups.filter((group) => group.tabOrder.length > 0)
const liveGroupIds = new Set(groups.map((group) => group.id))
let layout: TabGroupLayoutNode | null = args.layout ?? null
for (const groupId of collectTabGroupLayoutGroupIds(args.layout)) {
if (!liveGroupIds.has(groupId)) {
layout = removeTabGroupLayoutLeaf(layout, groupId)
}
}
return { groups, layout }
}
export type HeadlessTabGroupSplitResult = {
groups: RuntimeMobileSessionTabGroup[]
layout: TabGroupLayoutNode
newGroupId: string
}
/**
* Move `tabId` out of its current group into a NEW group split off from
* `targetGroupId` in `splitDirection`, mirroring the renderer's dropUnifiedTab
* split path. Returns the next groups + group layout tree.
*
* Returns null when the move can't apply (tab/group missing, or it would split
* the only tab off its own group — a renderer-side no-op).
*/
export function buildHeadlessTabGroupSplit(args: {
groups: readonly RuntimeMobileSessionTabGroup[]
layout: TabGroupLayoutNode | null | undefined
tabId: string
targetGroupId: string
splitDirection: SplitDirection
newGroupId: string
}): HeadlessTabGroupSplitResult | null {
const sourceGroup = args.groups.find((group) => group.tabOrder.includes(args.tabId))
if (!sourceGroup) {
return null
}
// Splitting the last tab off its own group would create a sibling only to
// immediately collapse the empty source — a no-op the renderer skips too.
if (sourceGroup.id === args.targetGroupId && sourceGroup.tabOrder.length <= 1) {
return null
}
const direction =
args.splitDirection === 'left' || args.splitDirection === 'right' ? 'horizontal' : 'vertical'
const position =
args.splitDirection === 'left' || args.splitDirection === 'up' ? 'first' : 'second'
const baseLayout: TabGroupLayoutNode = args.layout ?? {
type: 'leaf',
groupId: args.targetGroupId
}
const layout = replaceLeaf(
baseLayout,
args.targetGroupId,
buildSplitNode(args.targetGroupId, args.newGroupId, direction, position)
)
const sourceOrder = sourceGroup.tabOrder.filter((id) => id !== args.tabId)
let groups: RuntimeMobileSessionTabGroup[] = args.groups.map((group) => {
if (group.id === sourceGroup.id) {
return {
...group,
tabOrder: sourceOrder,
activeTabId: group.activeTabId === args.tabId ? (sourceOrder[0] ?? null) : group.activeTabId
}
}
return group
})
groups.push({ id: args.newGroupId, activeTabId: args.tabId, tabOrder: [args.tabId] })
// Drop any group emptied by the move and collapse it out of the layout.
groups = groups.filter((group) => group.tabOrder.length > 0)
const liveGroupIds = new Set(groups.map((group) => group.id))
let prunedLayout: TabGroupLayoutNode | null = layout
for (const groupId of collectTabGroupLayoutGroupIds(layout)) {
if (!liveGroupIds.has(groupId)) {
prunedLayout = removeTabGroupLayoutLeaf(prunedLayout, groupId)
}
}
return {
groups,
layout: prunedLayout ?? { type: 'leaf', groupId: args.newGroupId },
newGroupId: args.newGroupId
}
}