mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(projects): a project move names the row it moves; a paired server's twin by its SSH target * fix(sidebar): pass the header's row without an assertion; e2e helpers and focus baseline follow the row-shaped move * fix(sidebar): a header drop re-reads the dragged row and drops nothing if its group changed mid-drag * fix(sidebar): read live repos through a stable store reader, not a ref written during render
281 lines
9.5 KiB
TypeScript
281 lines
9.5 KiB
TypeScript
import type { WorkspaceAttachmentMutation } from '../../shared/workspace-attachment-mutation'
|
|
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
|
import type { ProjectGroup } from '../../shared/project-group-types'
|
|
import type { Repo } from '../../shared/repo-types'
|
|
import type {
|
|
FolderWorkspacePathStatus,
|
|
FolderWorkspacePathStatusRequest
|
|
} from '../../shared/folder-workspace-path-status'
|
|
import {
|
|
assertFolderWorkspacePathUsable,
|
|
folderWorkspacePathRefusal,
|
|
getFolderWorkspacePathStatus,
|
|
getFolderWorkspacePathStatusForPath
|
|
} from '../project-groups/folder-workspace-path-status'
|
|
import { FolderWorkspaceCreateRefusedError } from '../project-groups/folder-workspace-create-refusal'
|
|
import type { RuntimeStore } from './runtime-store-contract'
|
|
import {
|
|
runtimeVisibleFolderWorkspaces,
|
|
runtimeVisibleProjectGroups
|
|
} from './runtime-visible-catalog'
|
|
import { folderWorkspaceKey } from '../../shared/workspace-scope'
|
|
import {
|
|
assertWslMoveProjectWritable,
|
|
assertWslMoveRepoWritable,
|
|
isLocalWslMoveRow,
|
|
shouldDropWslMoveProjectUpdate
|
|
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
|
|
|
type RuntimeProjectGroupDependencies = {
|
|
getStore: () => RuntimeStore | null
|
|
// `connectionId` narrows twins by their SSH target; undefined keeps the plain selector.
|
|
resolveRepo: (selector: string, connectionId?: string | null) => Promise<Repo>
|
|
notifyReposChanged: () => void
|
|
resolveFolderConnectionId: (workspace: FolderWorkspace) => string | null
|
|
teardownFolderWorkspacePtys: (worktreeId: string, connectionId: string | null) => Promise<void>
|
|
cleanupRemovedFolderWorkspaceState: (worktreeId: string) => void
|
|
}
|
|
|
|
type FolderWorkspaceUpdates = Partial<
|
|
Pick<
|
|
FolderWorkspace,
|
|
| 'name'
|
|
| 'folderPath'
|
|
| 'linkedTask'
|
|
| 'linkedItems'
|
|
| 'linkedTaskSourceContext'
|
|
| 'comment'
|
|
| 'isArchived'
|
|
| 'isUnread'
|
|
| 'isPinned'
|
|
| 'sortOrder'
|
|
| 'manualOrder'
|
|
| 'workspaceStatus'
|
|
| 'createdWithAgent'
|
|
| 'pendingFirstAgentMessageRename'
|
|
| 'firstAgentMessageRenameError'
|
|
| 'lastActivityAt'
|
|
| 'diffComments'
|
|
>
|
|
> &
|
|
WorkspaceAttachmentMutation
|
|
|
|
export class RuntimeProjectGroupController {
|
|
constructor(private readonly deps: RuntimeProjectGroupDependencies) {}
|
|
|
|
listGroups(): ProjectGroup[] {
|
|
return this.deps.getStore()?.getProjectGroups?.() ?? []
|
|
}
|
|
|
|
listFolderWorkspaces(): FolderWorkspace[] {
|
|
return this.deps.getStore()?.getFolderWorkspaces?.() ?? []
|
|
}
|
|
|
|
/** What a client may list; `listGroups`/`listFolderWorkspaces` keep the full store. */
|
|
listVisibleGroups(): ProjectGroup[] {
|
|
return runtimeVisibleProjectGroups(this.deps.getStore())
|
|
}
|
|
|
|
listVisibleFolderWorkspaces(): FolderWorkspace[] {
|
|
return runtimeVisibleFolderWorkspaces(this.deps.getStore())
|
|
}
|
|
|
|
async createGroup(input: {
|
|
name: string
|
|
parentPath?: string | null
|
|
connectionId?: string | null
|
|
parentGroupId?: string | null
|
|
createdFrom?: ProjectGroup['createdFrom']
|
|
}): Promise<ProjectGroup> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.createProjectGroup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const group = store.createProjectGroup({
|
|
name: input.name,
|
|
parentPath: input.parentPath ?? null,
|
|
connectionId: input.connectionId ?? null,
|
|
parentGroupId: input.parentGroupId ?? null,
|
|
createdFrom: input.createdFrom ?? 'manual'
|
|
})
|
|
this.deps.notifyReposChanged()
|
|
return group
|
|
}
|
|
|
|
async updateGroup(
|
|
groupId: string,
|
|
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
|
): Promise<ProjectGroup | null> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.updateProjectGroup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const updated = store.updateProjectGroup(groupId, updates)
|
|
if (updated) {
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
return updated
|
|
}
|
|
|
|
async deleteGroup(groupId: string): Promise<{ deleted: boolean }> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.deleteProjectGroup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const deleted = store.deleteProjectGroup(groupId)
|
|
if (deleted) {
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
return { deleted }
|
|
}
|
|
|
|
async moveProject(
|
|
repoSelector: string,
|
|
groupId: string | null,
|
|
order?: number,
|
|
connectionId?: string | null
|
|
): Promise<Repo> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.moveProjectToGroup) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const repo = await this.deps.resolveRepo(repoSelector, connectionId)
|
|
assertWslMoveRepoWritable(store, repo)
|
|
const moved = store.moveProjectToGroup(repo, groupId, order)
|
|
if (!moved) {
|
|
throw new Error('repo_not_found')
|
|
}
|
|
this.deps.notifyReposChanged()
|
|
return moved
|
|
}
|
|
|
|
async createFolderWorkspace(input: {
|
|
projectGroupId: string
|
|
name?: string
|
|
folderPath?: string | null
|
|
connectionId?: string | null
|
|
creatorProvenance?: FolderWorkspace['creatorProvenance']
|
|
linkedTask?: FolderWorkspace['linkedTask']
|
|
linkedItems?: FolderWorkspace['linkedItems']
|
|
linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext']
|
|
createdWithAgent?: FolderWorkspace['createdWithAgent']
|
|
pendingFirstAgentMessageRename?: boolean
|
|
}): Promise<FolderWorkspace> {
|
|
// Every refusal below comes before the store write, so it proves nothing was created.
|
|
const store = this.deps.getStore()
|
|
if (!store?.createFolderWorkspace) {
|
|
throw new FolderWorkspaceCreateRefusedError('runtime_unavailable')
|
|
}
|
|
const projectGroups = store.getProjectGroups?.() ?? []
|
|
const group = projectGroups.find((entry) => entry.id === input.projectGroupId)
|
|
const folderPath =
|
|
typeof input.folderPath === 'string' && input.folderPath.trim().length > 0
|
|
? input.folderPath
|
|
: group?.parentPath
|
|
if (!group || !folderPath) {
|
|
throw new FolderWorkspaceCreateRefusedError('folder_workspace_project_group_not_found')
|
|
}
|
|
const status = await getFolderWorkspacePathStatusForPath({
|
|
folderPath,
|
|
projectGroupId: group.id,
|
|
connectionId: input.connectionId ?? group.connectionId ?? null,
|
|
projectGroups,
|
|
repos: store.getRepos()
|
|
})
|
|
const pathRefusal = folderWorkspacePathRefusal(status)
|
|
if (pathRefusal) {
|
|
throw new FolderWorkspaceCreateRefusedError(pathRefusal)
|
|
}
|
|
const workspace = store.createFolderWorkspace({
|
|
...input,
|
|
creatorProvenance: input.creatorProvenance ?? { kind: 'host' }
|
|
})
|
|
this.deps.notifyReposChanged()
|
|
return workspace
|
|
}
|
|
|
|
async getFolderPathStatus(
|
|
request: FolderWorkspacePathStatusRequest
|
|
): Promise<FolderWorkspacePathStatus> {
|
|
const store = this.deps.getStore()
|
|
if (!store) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
return getFolderWorkspacePathStatus(store, request)
|
|
}
|
|
|
|
async updateFolderWorkspace(
|
|
folderWorkspaceId: string,
|
|
updates: FolderWorkspaceUpdates
|
|
): Promise<FolderWorkspace | null> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.updateFolderWorkspace) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const existing = store.getFolderWorkspaces?.().find((entry) => entry.id === folderWorkspaceId)
|
|
if (
|
|
existing &&
|
|
isLocalWslMoveRow(existing) &&
|
|
shouldDropWslMoveProjectUpdate(store, { folderWorkspaceId }, updates)
|
|
) {
|
|
return existing
|
|
}
|
|
if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) {
|
|
const workspace = store
|
|
.getFolderWorkspaces?.()
|
|
.find((entry) => entry.id === folderWorkspaceId)
|
|
if (!workspace) {
|
|
return null
|
|
}
|
|
const projectGroups = store.getProjectGroups?.() ?? []
|
|
const status = await getFolderWorkspacePathStatusForPath({
|
|
folderPath: updates.folderPath,
|
|
projectGroupId: workspace.projectGroupId,
|
|
connectionId:
|
|
workspace.connectionId ??
|
|
projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ??
|
|
null,
|
|
projectGroups,
|
|
repos: store.getRepos()
|
|
})
|
|
assertFolderWorkspacePathUsable(status)
|
|
}
|
|
const updated = store.updateFolderWorkspace(folderWorkspaceId, updates)
|
|
if (updated) {
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
return updated
|
|
}
|
|
|
|
async deleteFolderWorkspace(folderWorkspaceId: string): Promise<{ deleted: boolean }> {
|
|
const store = this.deps.getStore()
|
|
if (!store?.removeFolderWorkspace) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
const workspace = store.getFolderWorkspaces?.().find((entry) => entry.id === folderWorkspaceId)
|
|
if (workspace && isLocalWslMoveRow(workspace)) {
|
|
assertWslMoveProjectWritable(store, { folderWorkspaceId })
|
|
}
|
|
if (workspace) {
|
|
const worktreeId = folderWorkspaceKey(folderWorkspaceId)
|
|
// Why: a mixed-host group has no single PTY target; forgetting the
|
|
// workspace must still succeed, so skip the sweep instead of failing.
|
|
let connectionId: string | null | undefined
|
|
try {
|
|
connectionId = this.deps.resolveFolderConnectionId(workspace)
|
|
} catch (error) {
|
|
console.warn(`[folder-workspace] skipping PTY teardown for ${worktreeId}:`, error)
|
|
}
|
|
if (connectionId !== undefined) {
|
|
await this.deps.teardownFolderWorkspacePtys(worktreeId, connectionId)
|
|
}
|
|
this.deps.cleanupRemovedFolderWorkspaceState(worktreeId)
|
|
}
|
|
const deleted = store.removeFolderWorkspace(folderWorkspaceId)
|
|
if (deleted) {
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
return { deleted }
|
|
}
|
|
}
|