'use client' import { useState } from 'react' import { ChipConfirmModal, toast } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { AlertTriangle, Plus } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { forkIdParam, forkIdUrlKeys, forkSyncDirectionParam, forkSyncDirectionUrlKeys, forkViewParam, forkViewUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { type RowAction, RowActionsMenu, } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel' import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' import { ARCHIVED_PREVIEW_LIMIT, useForkSync, } from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal' import { useForkingAvailability } from '@/ee/workspace-forking/hooks/use-forking-available' import { useForkLineage, useRollbackFork, useUnlinkFork, } from '@/ee/workspace-forking/hooks/workspace-fork' import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' /** Explains a disabled lineage action whose target workspace the viewer cannot open. */ const NO_ACCESS_TOOLTIP = "You don't have access to this workspace" /** Lineage partner names by id (the parent + this workspace's forks), for the Activity view. */ function lineagePartnerNames( parent: ForkLineageNodeApi | null, forks: ForkLineageChildApi[] ): ReadonlyMap { const names = new Map() if (parent) names.set(parent.id, parent.name) for (const fork of forks) names.set(fork.id, fork.name) return names } interface ForkListRowProps { name: string /** Entries for the row's `...` menu (Edit mappings / Open workspace / Disconnect). */ actions: RowAction[] } function ForkListRow({ name, actions }: ForkListRowProps) { return (
) } interface ForkSyncDetailViewProps { title: string workspaceId: string /** The other side of the edge being synced (this workspace's parent). */ otherWorkspaceId: string otherWorkspaceName: string onBack: () => void /** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */ actions: SettingsAction[] } /** * The parent edge's sync page (reached from the parent row): direction, deployed-workflow * changes, per-kind mappings (each an expandable row whose status badge is the summary), * copy resources, and blocking references, all as page sections. * The header's Sync chip is gated until zero blockers + required mappings + reconfigure are * complete, and always confirms the overwrite first — that confirm is the flow's one modal. * While the mapping has unsaved edits the header swaps to Discard/Save and leaving is guarded; * Sync itself persists the effective mapping as part of the run. */ function ForkSyncDetailView({ title, workspaceId, otherWorkspaceId, otherWorkspaceName, onBack, actions, }: ForkSyncDetailViewProps) { // Sync direction is shareable view state: a copied link opens the same side of the sync. const [direction, setDirection] = useQueryState(forkSyncDirectionParam.key, { ...forkSyncDirectionParam.parser, ...forkSyncDirectionUrlKeys, }) const controller = useForkSync({ workspaceId, otherWorkspaceId, otherWorkspaceName, direction, enabled: true, }) // Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed // the shared settings dirty store so a sidebar section switch confirms too. const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty }) const [confirmSyncOpen, setConfirmSyncOpen] = useState(false) // Sync is the edge's primary action, so it's the rightmost/black chip; the caller's // Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster // for Discard/Save until they're saved or discarded. const panelActions: SettingsAction[] = controller.dirty ? saveDiscardActions({ dirty: controller.dirty, saving: controller.saving, onSave: controller.save, onDiscard: controller.discard, }) : [ ...actions, { text: controller.submitting ? 'Working...' : 'Sync', variant: 'primary' as const, onSelect: () => setConfirmSyncOpen(true), disabled: controller.syncDisabled, tooltip: controller.syncDisabled ? controller.syncDisabledReason : `Push to or pull from ${otherWorkspaceName}`, }, ] const targetWorkspaceName = direction === 'push' ? otherWorkspaceName : 'this workspace' return ( <> guard.guardBack(() => { void setDirection(null) onBack() }), }} title={title} actions={panelActions} > void setDirection(next)} /> { setConfirmSyncOpen(false) void controller.sync() }, pending: controller.submitting, pendingLabel: 'Syncing...', }} > {controller.archivedWorkflowNames.length > 0 ? (

Will be archived in {targetWorkspaceName}{' '} (deleted in the source):

{controller.archivedWorkflowNames .slice(0, ARCHIVED_PREVIEW_LIMIT) .map((name, index) => (
{name}
))} {controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? (
and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more
) : null}
) : null}
) } interface ForkActivityDetailViewProps { workspaceId: string /** Lineage partner names by id, for phrasing rows recorded on the other side of an edge. */ workspaceNames: ReadonlyMap onBack: () => void /** Header actions (e.g. the destructive Rollback chip while the last sync is undoable). */ actions?: SettingsAction[] } /** * Workspace-scoped activity: every fork, sync, and rollback involving this workspace * (both sides of each edge), reached from the page header's "See activity" action. */ function ForkActivityDetailView({ workspaceId, workspaceNames, onBack, actions, }: ForkActivityDetailViewProps) { return ( ) } /** * Forks settings page. The workspace's single parent (if it's a fork) sits in its own * "Parent" section, above the "Forks" list of child forks. The parent row's `...` menu * has Edit mappings (the child owns its edge's re-picks), Open workspace, and * Disconnect; fork rows offer Open workspace and Disconnect only. Activity is * workspace-scoped and lives behind the header's "See activity" action (including * Rollback when the last sync into this workspace is undoable). Sync lives on the * parent's sync detail page. * Forking and sync rewrite workflow state and deployments en masse, so the page is * workspace-admin only and gated on the workspace's fork entitlement - every fork route * re-checks both; the server remains the boundary. */ export function Forks() { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string const { canAdmin, isLoading: permissionsLoading } = useUserPermissionsContext() const { available: forkingAvailable, isLoading: availabilityLoading } = useForkingAvailability(workspaceId) const canUseForking = forkingAvailable && canAdmin const { data: workspaces } = useWorkspacesQuery() const { data: creationPolicy } = useWorkspaceCreationPolicy() const { navigateToSettings } = useSettingsNavigation() const lineage = useForkLineage(workspaceId, canUseForking) const rollback = useRollbackFork() const unlink = useUnlinkFork() const [searchTerm, setSearchTerm] = useState('') const [isForkModalOpen, setIsForkModalOpen] = useState(false) const [confirmRollbackOpen, setConfirmRollbackOpen] = useState(false) const [confirmUnlink, setConfirmUnlink] = useState<{ id: string; name: string } | null>(null) const [selectedForkId, setSelectedForkId] = useQueryState(forkIdParam.key, { ...forkIdParam.parser, ...forkIdUrlKeys, }) const [forkView, setForkView] = useQueryState(forkViewParam.key, { ...forkViewParam.parser, ...forkViewUrlKeys, }) const workspaceName = workspaces?.find((workspace) => workspace.id === workspaceId)?.name const canFork = creationPolicy?.canCreate ?? true const parent = lineage.data?.parent ?? null const forks = lineage.data?.children ?? [] const undoableRun = lineage.data?.undoableRun ?? null const gateLoading = availabilityLoading || permissionsLoading // Rollback undoes the last sync INTO this workspace, restoring each affected // workflow to its prior deployed version. const runRollback = async () => { if (!undoableRun) return try { await rollback.mutateAsync({ workspaceId, body: { otherWorkspaceId: undoableRun.otherWorkspaceId }, }) toast.success(`Undid sync from "${undoableRun.otherName}"`) setConfirmRollbackOpen(false) } catch (err) { toast.error(getErrorMessage(err, 'Undo failed')) } } const openForkWorkspace = (forkId: string) => { router.push(`/workspace/${forkId}/w`) } const openForkMappings = (forkId: string) => { void setSelectedForkId(forkId) } /** Permanently dissolve the edge with the confirmed workspace; both workspaces remain. */ const runUnlink = async () => { if (!confirmUnlink) return try { await unlink.mutateAsync({ workspaceId, body: { otherWorkspaceId: confirmUnlink.id }, }) toast.success(`Disconnected "${confirmUnlink.name}"`) setConfirmUnlink(null) } catch (err) { toast.error(getErrorMessage(err, 'Disconnect failed')) } } if (gateLoading) { return } if (!canUseForking) { return ( {canAdmin ? 'Forking is not available for this workspace.' : 'Only workspace admins can manage forks.'} ) } const searchLower = searchTerm.trim().toLowerCase() const parentVisible = parent !== null && (!searchLower || parent.name.toLowerCase().includes(searchLower)) const filteredForks = forks.filter((fork) => fork.name.toLowerCase().includes(searchLower)) const hasRows = parent !== null || forks.length > 0 // The sync detail exists only for the PARENT edge: sync (and the mapping re-picks it // persists) belongs to the child workspace configuring how it maps its parent's // resources, so a parent browsing its forks gets no detail for them (a stale fork-id // deep link falls back to the list). Fork rows offer Open workspace / Disconnect only. const showParentDetail = Boolean(selectedForkId && parent && parent.id === selectedForkId) // Open workspace sits left of the detail view's primary Sync chip, which the sync // page owns (it carries the gating). Rollback lives on the Activity view only. const parentHeaderActions: SettingsAction[] = parent ? [ { text: 'Open workspace', onSelect: () => openForkWorkspace(parent.id), disabled: !parent.viewerAccessible, tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP, }, ] : [] return ( <> {showParentDetail && parent ? ( setSelectedForkId(null)} actions={parentHeaderActions} /> ) : forkView === 'activity' ? ( setForkView(null)} actions={ undoableRun ? [ { text: 'Rollback', variant: 'destructive', onSelect: () => setConfirmRollbackOpen(true), disabled: rollback.isPending, tooltip: `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.`, }, ] : undefined } /> ) : ( void setForkView('activity') }, { text: 'Create fork', icon: Plus, variant: 'primary', onSelect: () => setIsForkModalOpen(true), }, ]} > {lineage.isError ? (

{getErrorMessage(lineage.error, 'Failed to load forks')}

) : lineage.isLoading ? null : !hasRows ? ( Click "Create fork" above to get started ) : (
{parentVisible && parent !== null && ( openForkMappings(parent.id), disabled: !parent.viewerAccessible, tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP, }, { label: 'Open workspace', onSelect: () => openForkWorkspace(parent.id), disabled: !parent.viewerAccessible, tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP, }, // Disconnect stays enabled regardless of access: severing the edge is a // current-workspace operation (admin on the acting side only), and must // remain reachable exactly when the other side is inaccessible. { label: 'Disconnect', destructive: true, onSelect: () => setConfirmUnlink({ id: parent.id, name: parent.name }), }, ]} /> )} {filteredForks.length > 0 ? (
{filteredForks.map((fork) => ( openForkWorkspace(fork.id), disabled: !fork.viewerAccessible, tooltip: fork.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP, }, { label: 'Disconnect', destructive: true, onSelect: () => setConfirmUnlink({ id: fork.id, name: fork.name }), }, ]} /> ))}
) : ( {searchTerm.trim() ? `No forks found matching "${searchTerm}"` : 'No forks yet — click "Create fork" above to get started'} )}
)}
)} { if (isBillingEnabled) navigateToSettings({ section: 'billing' }) }} /> { if (!open) setConfirmUnlink(null) }} srTitle='Disconnect fork' title='Disconnect fork' text={[ 'This permanently removes the fork relationship with ', { text: confirmUnlink?.name ?? '', bold: true }, ". Both workspaces stay exactly as they are, but they will no longer appear in each other's fork lists, and syncing between them stops.", ]} confirm={{ label: 'Disconnect', onClick: () => void runUnlink(), pending: unlink.isPending, pendingLabel: 'Disconnecting...', }} >
This cannot be undone — the saved mappings and sync history for this pair are deleted, and forking again creates a brand-new workspace.
void runRollback(), pending: rollback.isPending, pendingLabel: 'Rolling back...', }} >
Resources copied into this workspace during syncs may remain afterward — rollback restores workflows to their prior versions but does not remove copied resources.
) }