fix: explain in the fork dropdowns why a fork can't be created

The create-fork entry disappeared whenever forking was unavailable, leaving
no trace of why. Keep it visible, disabled, and carrying the reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq84eSViYocWFKfqvzdpR
This commit is contained in:
Guilhem Lemouel
2026-09-02 19:59:34 +02:00
co-authored by Claude Opus 5
parent 337154b830
commit 80164a9177
3 changed files with 86 additions and 33 deletions
@@ -17,7 +17,7 @@
buildWorkspaceHierarchy
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import { canCreateFork } from '$lib/utils/editInFork'
import { forkBlockedReason, type ForkBlockedReason } from '$lib/utils/editInFork'
import { forkAccentStyle } from '$lib/utils/forkColor'
import { getUserExt } from '$lib/user'
import { WorkspaceService } from '$lib/gen'
@@ -27,7 +27,6 @@
canUserBypassRuleKindInRulesets
} from '$lib/workspaceProtectionRules.svelte'
import { resource } from 'runed'
import { isCloudHosted } from '$lib/cloud'
import { random_adj } from '$lib/components/random_positive_adjetive'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import InputError from '$lib/components/InputError.svelte'
@@ -169,19 +168,20 @@
rootRulesetsResource.loading || rootUserInfoResource.loading || !canDeployRoot
)
// Structural gate: hidden in the admins workspace, or when the user can't fork; on cloud, forking
// is premium-only (backend caps it per paid seat). DisableWorkspaceForking on the active workspace
// (a locked prod) doesn't apply when there's a dev to fork from instead — the dev isn't locked, and
// devOfRoot only resolves when the user is a member of it.
const forksGateOpen = $derived(
(!isCloudHosted() || $maybePremium) &&
$workspaceStore !== 'admins' &&
(canCreateFork($userStore) || !!devOfRoot)
// Why forking is unavailable here (admins workspace, non-premium cloud, or a protection rule), or
// undefined when it is available. DisableWorkspaceForking on the active workspace (a locked prod)
// doesn't apply when there's a dev to fork from instead — the dev isn't locked, and devOfRoot only
// resolves when the user is a member of it.
const forkGateReason = $derived(
forkBlockedReason($userStore, $workspaceStore, {
premium: $maybePremium,
hasDevWorkspace: !!devOfRoot
})
)
// A fork is a new workspace, so it's subject to the community-edition cap on
// the number of non-'admins' workspaces (backend _check_nb_of_workspaces,
// run only on community builds). An enterprise license lifts the cap. We
// mirror the backend count with the client-side workspace list to hide the
// mirror the backend count with the client-side workspace list to disable the
// affordance once the cap is reached; the server still enforces the real
// (instance-wide) check on commit, so this is purely UX.
const CE_MAX_NON_ADMIN_WORKSPACES = 2
@@ -189,20 +189,24 @@
const ceWorkspaceCapReached = $derived(
!$enterpriseLicense && nonAdminWorkspaceCount >= CE_MAX_NON_ADMIN_WORKSPACES
)
// The interactive create-fork row is shown unless the cap is reached;
// otherwise (structural gate open but cap hit) we surface a disabled row
// explaining the limit — never stage a fork the backend would reject.
// Structural: the consumer wired up a create path at all, and there's a family to fork from. The
// row is absent only in that case — every other blocker keeps it visible and names itself.
const forkAffordanceOpen = $derived(
allowCreateFork && forksGateOpen && (!!onCreateFork || !!onRequestCreateFork) && !!root
allowCreateFork && (!!onCreateFork || !!onRequestCreateFork) && !!root
)
// The upsell (CE workspace cap) only applies to in-place inline creation;
// onRequestCreateFork delegates to a flow that enforces its own limits.
const showCreateFork = $derived(
forkAffordanceOpen && (!ceWorkspaceCapReached || !!onRequestCreateFork)
)
const showForkUpsell = $derived(
forkAffordanceOpen && ceWorkspaceCapReached && !onRequestCreateFork
// Blocker for the create-fork row: the gate above, else the CE workspace cap. The cap only applies
// to in-place inline creation; onRequestCreateFork delegates to a flow that enforces its own
// limits. Never stage a fork the backend would reject.
const createForkBlocked = $derived<ForkBlockedReason | undefined>(
forkGateReason ??
(ceWorkspaceCapReached && !onRequestCreateFork
? {
note: 'Workspace limit reached',
title: `Community edition is limited to ${CE_MAX_NON_ADMIN_WORKSPACES + 1} workspaces. Archive a workspace or upgrade to an enterprise license to create more forks.`
}
: undefined)
)
const showCreateFork = $derived(forkAffordanceOpen && !createForkBlocked)
let dropdownOpen = $state(false)
let creatingFork = $state(false)
@@ -559,17 +563,16 @@
<span>{createForkLabel}</span>
</button>
{/if}
{:else if showForkUpsell}
{:else if forkAffordanceOpen && createForkBlocked}
<div class="my-1 border-t border-border-light shrink-0"></div>
<div
class={`${rowBase} opacity-60 cursor-not-allowed`}
aria-disabled="true"
title="Community edition is limited to {CE_MAX_NON_ADMIN_WORKSPACES +
1} workspaces. Archive a workspace or upgrade to an enterprise license to create more forks."
title={createForkBlocked.title}
>
<Plus size={14} class="shrink-0 text-tertiary" />
<span>{createForkLabel}</span>
<span class="ml-auto shrink-0 text-2xs text-tertiary"> Workspace limit reached </span>
<span class="ml-auto shrink-0 text-2xs text-tertiary">{createForkBlocked.note}</span>
</div>
{/if}
{#if settingsHref}
@@ -37,7 +37,7 @@
findWorkspaceRoot,
isForkOwner
} from '$lib/utils/workspaceHierarchy'
import { canCreateFork } from '$lib/utils/editInFork'
import { forkBlockedReason } from '$lib/utils/editInFork'
import { getContrastTextColor } from '$lib/utils'
import { workspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
@@ -113,10 +113,10 @@
return withForks
})
// Gate for the "Workspace fork" entry pinned below the list (the global fork
// modal carries its own base-workspace picker). Hidden on non-premium cloud,
// in the admins workspace, or when forking is disabled.
const canForkHere = $derived(
(!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore)
// modal carries its own base-workspace picker). When forking is unavailable the
// entry stays, disabled and carrying the reason, rather than disappearing.
const forkBlocked = $derived(
forkBlockedReason($userStore, $workspaceStore, { premium: $maybePremium })
)
const familyWorkspaces = $derived.by(() => {
if (strictWorkspaceSelect) return hierarchy
@@ -375,7 +375,7 @@
</div>
{/each}
</div>
{#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect}
{#if !strictWorkspaceSelect}
<div class="py-1" role="none">
{#if isCloudHosted() || $superadmin}
<MenuItem href="{base}/user/create_workspace" class={itemClass} {item}>
@@ -383,7 +383,21 @@
Workspace
</MenuItem>
{/if}
{#if canForkHere}
{#if forkBlocked}
<!-- Kept visible so the reason forking is unavailable is readable here, rather than
leaving the entry to silently vanish. -->
<div
class="text-primary font-normal w-full flex flex-row gap-2 items-center px-4 py-2 text-xs opacity-60 cursor-not-allowed"
role="menuitem"
tabindex="-1"
aria-disabled="true"
title={forkBlocked.title}
>
<Plus size={16} />
Workspace fork
<span class="ml-auto shrink-0 text-2xs text-tertiary">{forkBlocked.note}</span>
</div>
{:else}
<MenuItem
class={itemClass}
onClick={() => (globalForkModal.val = { opened: true })}
+36
View File
@@ -13,6 +13,7 @@ import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import { checkItemExists } from '$lib/utils_workspace_deploy'
import { updateDevWorkspaceModal } from '$lib/utils/editInForkModal.svelte'
import { isCloudHosted } from '$lib/cloud'
export type ItemType = 'script' | 'flow' | 'app' | 'raw_app'
@@ -55,6 +56,41 @@ export function canCreateFork(user: UserExt | undefined): boolean {
)
}
/** Why a create-fork entry is disabled: a short right-hand note, and the tooltip that explains it. */
export type ForkBlockedReason = { note: string; title: string }
/**
* The blocker on creating a fork of the given workspace, or undefined when there is none. Callers
* render the create-fork entry disabled and carrying this reason rather than dropping it, so a user
* who can't fork reads why instead of hunting for a missing entry. `premium` comes from
* `maybePremium` (forking is metered per paid seat on cloud); `hasDevWorkspace` relaxes the forking
* rule, since a locked prod's dev workspace is itself forkable.
*/
export function forkBlockedReason(
user: UserExt | undefined,
workspaceId: string | undefined,
{ premium, hasDevWorkspace = false }: { premium: boolean; hasDevWorkspace?: boolean }
): ForkBlockedReason | undefined {
if (workspaceId === 'admins')
return {
note: 'Not forkable',
title: 'The admins workspace cannot be forked. Switch to another workspace to create a fork.'
}
if (isCloudHosted() && !premium)
return {
note: 'Paid plans only',
title:
'Forking a workspace is available on paid plans. Upgrade this workspace to create forks.'
}
if (!canCreateFork(user) && !hasDevWorkspace)
return {
note: 'Disabled',
title:
'A protection rule disables forking of this workspace. A workspace admin can change it in the workspace settings.'
}
return undefined
}
function editPathFor(itemType: ItemType, itemPath: string): string {
switch (itemType) {
case 'script':