Files
simstudioai--sim/apps/sim/ee/workspace-forking/components/forks.tsx
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

581 lines
22 KiB
TypeScript

'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<string, string> {
const names = new Map<string, string>()
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 (
<div className='flex items-center justify-between gap-3'>
<FloatingOverflowText
label={name}
className='block min-w-0 truncate text-[var(--text-body)] text-sm'
/>
<div className='flex flex-shrink-0 items-center gap-1'>
<RowActionsMenu label='Fork actions' actions={actions} />
</div>
</div>
)
}
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 (
<>
<SettingsPanel
back={{
text: 'Workspace Forks',
icon: ArrowLeft,
onSelect: () =>
guard.guardBack(() => {
void setDirection(null)
onBack()
}),
}}
title={title}
actions={panelActions}
>
<ForkSyncView
controller={controller}
onDirectionChange={(next) => void setDirection(next)}
/>
</SettingsPanel>
<UnsavedChangesModal
open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal}
onDiscard={guard.confirmDiscard}
/>
<ChipConfirmModal
open={confirmSyncOpen}
onOpenChange={setConfirmSyncOpen}
srTitle='Sync workspace'
title='Overwrite target workspace'
text={[
'The target may have been modified since the last sync. Syncing will ',
{ text: 'overwrite any changes', bold: true },
' there. Continue?',
]}
confirm={{
label: 'Sync',
onClick: () => {
setConfirmSyncOpen(false)
void controller.sync()
},
pending: controller.submitting,
pendingLabel: 'Syncing...',
}}
>
{controller.archivedWorkflowNames.length > 0 ? (
<div className='flex flex-col gap-1 px-2'>
<p className='break-words text-[var(--text-primary)] text-sm'>
Will be archived in <span className='font-medium'>{targetWorkspaceName}</span>{' '}
(deleted in the source):
</p>
{controller.archivedWorkflowNames
.slice(0, ARCHIVED_PREVIEW_LIMIT)
.map((name, index) => (
<div
key={`${name}:${index}`}
className='min-w-0 truncate text-[var(--text-muted)] text-small'
>
{name}
</div>
))}
{controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? (
<div className='text-[var(--text-muted)] text-small'>
and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more
</div>
) : null}
</div>
) : null}
</ChipConfirmModal>
</>
)
}
interface ForkActivityDetailViewProps {
workspaceId: string
/** Lineage partner names by id, for phrasing rows recorded on the other side of an edge. */
workspaceNames: ReadonlyMap<string, string>
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 (
<SettingsPanel
back={{ text: 'Workspace Forks', icon: ArrowLeft, onSelect: onBack }}
title='Activity'
actions={actions}
>
<ForkActivityPanel workspaceId={workspaceId} workspaceNames={workspaceNames} />
</SettingsPanel>
)
}
/**
* 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 <SettingsPanel />
}
if (!canUseForking) {
return (
<SettingsPanel>
<SettingsEmptyState>
{canAdmin
? 'Forking is not available for this workspace.'
: 'Only workspace admins can manage forks.'}
</SettingsEmptyState>
</SettingsPanel>
)
}
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 ? (
<ForkSyncDetailView
key={parent.id}
title={parent.name}
workspaceId={workspaceId}
otherWorkspaceId={parent.id}
otherWorkspaceName={parent.name}
onBack={() => setSelectedForkId(null)}
actions={parentHeaderActions}
/>
) : forkView === 'activity' ? (
<ForkActivityDetailView
workspaceId={workspaceId}
workspaceNames={lineagePartnerNames(parent, forks)}
onBack={() => 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
}
/>
) : (
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search forks...',
}}
actions={[
{ text: 'See activity', onSelect: () => void setForkView('activity') },
{
text: 'Create fork',
icon: Plus,
variant: 'primary',
onSelect: () => setIsForkModalOpen(true),
},
]}
>
{lineage.isError ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-sm leading-tight'>
{getErrorMessage(lineage.error, 'Failed to load forks')}
</p>
</div>
) : lineage.isLoading ? null : !hasRows ? (
<SettingsEmptyState>Click "Create fork" above to get started</SettingsEmptyState>
) : (
<div className='flex flex-col gap-7'>
{parentVisible && parent !== null && (
<SettingsSection label='Parent'>
<ForkListRow
name={parent.name}
actions={[
{
label: 'Edit mappings',
onSelect: () => 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 }),
},
]}
/>
</SettingsSection>
)}
<SettingsSection label='Forks'>
{filteredForks.length > 0 ? (
<div className='flex flex-col gap-2'>
{filteredForks.map((fork) => (
<ForkListRow
key={fork.id}
name={fork.name}
actions={[
{
label: 'Open workspace',
onSelect: () => 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 }),
},
]}
/>
))}
</div>
) : (
<SettingsEmptyState variant='inline'>
{searchTerm.trim()
? `No forks found matching "${searchTerm}"`
: 'No forks yet — click "Create fork" above to get started'}
</SettingsEmptyState>
)}
</SettingsSection>
</div>
)}
</SettingsPanel>
)}
<ForkWorkspaceModal
open={isForkModalOpen}
onOpenChange={setIsForkModalOpen}
sourceWorkspaceId={workspaceId}
sourceWorkspaceName={workspaceName || 'Workspace'}
canFork={canFork}
onUpgrade={() => {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
}}
/>
<ChipConfirmModal
open={confirmUnlink !== null}
onOpenChange={(open) => {
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...',
}}
>
<div className='flex items-start gap-1.5 px-2 text-[var(--text-secondary)] text-caption'>
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
<span>
This cannot be undone the saved mappings and sync history for this pair are deleted,
and forking again creates a brand-new workspace.
</span>
</div>
</ChipConfirmModal>
<ChipConfirmModal
open={confirmRollbackOpen}
onOpenChange={setConfirmRollbackOpen}
srTitle='Undo last sync'
title='Undo last sync'
text={[
'This restores each affected workflow to its ',
{ text: 'prior deployed version', bold: true },
' and removes workflows the sync created. Continue?',
]}
confirm={{
label: 'Rollback',
onClick: () => void runRollback(),
pending: rollback.isPending,
pendingLabel: 'Rolling back...',
}}
>
<div className='flex items-start gap-1.5 px-2 text-[var(--text-secondary)] text-caption'>
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
<span>
Resources copied into this workspace during syncs may remain afterward rollback
restores workflows to their prior versions but does not remove copied resources.
</span>
</div>
</ChipConfirmModal>
</>
)
}