Files
orca/src/shared/project-groups.ts
T
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#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.
2026-08-13 22:48:24 -07:00

181 lines
5.8 KiB
TypeScript

import { normalizeExecutionHostId } from './execution-host'
import type { ProjectGroup, ProjectGroupCreatedFrom } from './project-group-types'
import type { Repo } from './repo-types'
export const UNGROUPED_PROJECT_GROUP_KEY = 'project-group:ungrouped'
function createProjectGroupId(): string {
const randomUUID = globalThis.crypto?.randomUUID
if (randomUUID) {
return randomUUID.call(globalThis.crypto)
}
return `project-group-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export function normalizeProjectGroupName(name: string, fallback = 'Untitled group'): string {
const trimmed = name.trim()
return trimmed.length > 0 ? trimmed : fallback
}
export function createProjectGroup(input: {
name: string
parentPath?: string | null
connectionId?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroupCreatedFrom
tabOrder: number
now?: number
}): ProjectGroup {
const now = input.now ?? Date.now()
return {
id: createProjectGroupId(),
name: normalizeProjectGroupName(input.name),
parentPath: input.parentPath ?? null,
connectionId: input.connectionId ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: input.tabOrder,
isCollapsed: false,
color: null,
createdAt: now,
updatedAt: now
}
}
export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
if (!Array.isArray(value)) {
return []
}
const groups: ProjectGroup[] = []
const seen = new Set<string>()
for (const candidate of value) {
if (!candidate || typeof candidate !== 'object') {
continue
}
const raw = candidate as Partial<ProjectGroup>
if (typeof raw.id !== 'string' || seen.has(raw.id)) {
continue
}
seen.add(raw.id)
const now = Date.now()
const executionHostId = normalizeExecutionHostId(raw.executionHostId)
groups.push({
id: raw.id,
name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''),
parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null,
connectionId:
typeof raw.connectionId === 'string'
? raw.connectionId
: raw.connectionId === null
? null
: null,
parentGroupId: typeof raw.parentGroupId === 'string' ? raw.parentGroupId : null,
createdFrom:
raw.createdFrom === 'manual' ||
raw.createdFrom === 'folder-scan' ||
raw.createdFrom === 'migration'
? raw.createdFrom
: 'manual',
tabOrder:
typeof raw.tabOrder === 'number' && Number.isFinite(raw.tabOrder) ? raw.tabOrder : 0,
isCollapsed: raw.isCollapsed === true,
color: typeof raw.color === 'string' ? raw.color : null,
createdAt:
typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now,
updatedAt:
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now,
// Why: runtime-owned groups otherwise look local after persistence reload.
...(executionHostId ? { executionHostId } : {})
})
}
groups.sort(
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
)
const groupIds = new Set(groups.map((group) => group.id))
for (const group of groups) {
if (group.parentGroupId === group.id || !groupIds.has(group.parentGroupId ?? '')) {
group.parentGroupId = null
}
}
return groups
}
export function clearMissingProjectGroupMemberships(repos: Repo[], groups: ProjectGroup[]): Repo[] {
const groupIds = new Set(groups.map((group) => group.id))
return repos.map((repo) =>
repo.projectGroupId && !groupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
)
}
export function getProjectGroupSubtreeIds(
groups: readonly Pick<ProjectGroup, 'id' | 'parentGroupId'>[],
rootGroupId: string
): Set<string> {
const childGroupsByParentId = new Map<string, string[]>()
for (const group of groups) {
if (!group.parentGroupId) {
continue
}
const children = childGroupsByParentId.get(group.parentGroupId) ?? []
children.push(group.id)
childGroupsByParentId.set(group.parentGroupId, children)
}
const subtreeIds = new Set<string>()
const pending = [rootGroupId]
while (pending.length > 0) {
const groupId = pending.pop()!
if (subtreeIds.has(groupId)) {
continue
}
subtreeIds.add(groupId)
// Why: imported project-group trees can be very wide; `push(...children)`
// can exceed V8's argument limit while collecting descendants.
for (const childGroupId of childGroupsByParentId.get(groupId) ?? []) {
pending.push(childGroupId)
}
}
return subtreeIds
}
/** Manual rank for a project inside a group bucket. Explicit
* `projectGroupOrder` wins; otherwise fall back to global repo order so drag
* midpoint math and sidebar sorting stay aligned. */
export function getEffectiveProjectGroupManualRank(
repo: Pick<Repo, 'id' | 'projectGroupOrder'> | undefined,
repoOrderRankById?: ReadonlyMap<string, number>,
siblingFallbackIndex?: number
): number {
if (!repo) {
return Number.POSITIVE_INFINITY
}
const order = repo.projectGroupOrder
if (typeof order === 'number' && Number.isFinite(order)) {
return order
}
const repoRank = repoOrderRankById?.get(repo.id)
if (repoRank !== undefined) {
return repoRank * 1000
}
if (siblingFallbackIndex !== undefined) {
return siblingFallbackIndex * 1000
}
return Number.POSITIVE_INFINITY
}
export function getNextProjectGroupOrder(repos: readonly Repo[], groupId: string | null): number {
let max = -1
for (const repo of repos) {
if ((repo.projectGroupId ?? null) !== groupId) {
continue
}
const order = repo.projectGroupOrder
if (typeof order === 'number' && Number.isFinite(order)) {
max = Math.max(max, order)
}
}
return max + 1
}