mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 08:02:09 +00:00
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as a re-export barrel so the import sites did not have to change. This removes the barrel: every consumer now imports from the module that actually declares the type, and `src/shared/types.ts` is deleted. Barrels hide where a type lives, make every consumer look like it depends on the whole domain, and let an unrelated edit invalidate a module that ~2,000 files transitively import. 2,323 import declarations across 2,321 files. Rewritten mechanically: each specifier was resolved to an absolute path via the TypeScript AST and recomputed, rather than string-substituted, so alias forms (`@/../../shared/ types`) and per-specifier `type` modifiers survive. Four cases the mechanical pass had to handle, each found by a gate rather than by reading the diff: - Modules inside `src/shared` import the barrel as `./types`, not `shared/types`. A pre-filter on the latter string skipped 176 of them and left imports dangling at a deleted file, which surfaced as confusing `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>` errors rather than "module not found". - The barrel RENAMED one type on the way through (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name in the owning module has to be re-aliased at each consumer. - Three test files put `;(globalThis as ...)` on the line after the import. TypeScript parses that `;` as the import statement's terminator, so replacing through `statement.getEnd()` deletes it and breaks ASI. The rewrite now stops at the module specifier. - A file that already imported directly from a module got a SECOND import from it, because the barrel re-exported those same names — which trips `import/no-duplicates` under `--deny-warnings`. A post-pass merges declarations sharing a specifier and type-only-ness; the `import type` plus `import` pair from one module is left alone, since that form is allowed. Splitting one barrel import into several genuinely adds lines, which pushed `terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character import must wrap, and neither local type collapses onto one line (101 and 116 characters). Rather than contort a type declaration to fit a line budget, `collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` — they are pure structural operations on the layout tree and independent of PTY ownership. `visible-worktrees.ts` similarly loses its own mini-barrel re-export of `isDefaultBranchWorkspace`, with the four real consumers repointed at the declaring module. No `max-lines` bypass added. Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted first — these projects are `composite: true` and reuse stale caches); the full `pnpm lint` green, not just bare oxlint — the narrower local check is what let the duplicate imports reach CI; max-lines ratchet OK at 344.
218 lines
7.2 KiB
TypeScript
218 lines
7.2 KiB
TypeScript
import type { RuntimeMobileSessionTabGroup } from '../../shared/runtime-types'
|
|
import type { TabGroupLayoutNode } from '../../shared/tab-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
|
|
}
|
|
}
|