Files
orca/src/shared/project-groups.ts
T
2026-06-11 02:34:01 -07:00

151 lines
4.7 KiB
TypeScript

import type { Repo, ProjectGroup, ProjectGroupCreatedFrom } from './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()
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
})
}
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
}
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
}