From 1a5eaa9ca1c9b71cbf1467e88029636d3abd98d7 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 9 Jun 2026 19:36:39 +0200 Subject: [PATCH] ui(drafts): per-user View JSON / Fork actions in DraftBadge popover Hover popover used to be a plain text list of usernames. Now each row gets a colored circle icon + name + "(you)" for the authed user, and every OTHER user's row carries View JSON / Fork buttons mirroring the OtherUsersDraftsModal. For draft-only entries owned solely by the authed user, the popover ends with "Only you can see this {kind}" so the row's privacy is obvious. ScriptRow / FlowRow / AppRow thread workspace + itemKind + path + editPathFor through; AppRow switches between app / raw_app on app.raw_app. Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/components/DraftBadge.svelte | 215 ++++++++++++++++-- .../lib/components/common/table/AppRow.svelte | 5 + .../components/common/table/FlowRow.svelte | 4 + .../components/common/table/ScriptRow.svelte | 4 + 4 files changed, 215 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 503d757e4c..fd3b708780 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -5,6 +5,16 @@ * at this path. Up to 3 circles render inline; with 4+ users we show * the first 2 + a `+N` overflow circle so the badge stays compact. * + * Popover (hover): one row per draft owner with a circle icon. When + * the row knows the item kind (workspace + itemKind + path + + * editPathFor passed through) each OTHER user's row also gets + * "View JSON" / "Fork" buttons — same actions as the in-editor + * OtherUsersDraftsModal, surfaced inline here so users don't need to + * open the editor first. The authed user's own row never has those + * actions (forking yourself is meaningless); when the entry is + * draft-only AND it's the authed user's own draft, the popover ends + * with "Only you can see this {kind}" so the row's privacy is clear. + * * Variants: * draft_only=true → "Draft only" (no deployed row exists) * draft_only=false → "Draft" (deployed and at least one user @@ -16,6 +26,13 @@ */ import Popover from './Popover.svelte' import { Badge } from './common' + import Button from './common/button/Button.svelte' + import Modal2 from './common/modal/Modal2.svelte' + import { Braces, GitFork } from 'lucide-svelte' + import { DraftService, type UserDraftItemKind } from '$lib/gen' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' + import { sendUserToast } from '$lib/toast' + import { goto } from '$lib/navigation' type DraftUser = { username?: string | null } @@ -29,13 +46,28 @@ * popover entry with `(you)`. Pass `$userStore?.username` * from the row. */ currentUsername?: string | null + /** Optional context needed to render the per-user View JSON / + * Fork actions. When any of these is missing the popover falls + * back to the legacy text-only list (used by trigger rows that + * don't have a per-user draft surface). */ + workspace?: string + itemKind?: UserDraftItemKind + path?: string + /** Build the per-editor edit URL for a forked draft path. Same + * shape as the OtherUsersDraftsModal's `editPathFor` — different + * editors live under different roots. */ + editPathFor?: (forkedPath: string) => string } let { is_draft = false, draft_only = false, draft_users = [], - currentUsername = undefined + currentUsername = undefined, + workspace = undefined, + itemKind = undefined, + path = undefined, + editPathFor = undefined }: Props = $props() // Authed user always lands FIRST in the circle row when they have a @@ -102,23 +134,155 @@ // endpoint sets this even for paths the user has a draft on but no // one else does). const showBadge = $derived(is_draft || draft_users.length > 0) + + // The popover renders inline actions only when the parent supplied + // the full context (we need workspace + itemKind + path + editPathFor + // to actually fetch and fork drafts). + const actionsEnabled = $derived( + !!workspace && !!itemKind && !!path && !!editPathFor && draft_users.length > 0 + ) + + const kindLabel = $derived( + itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script' + ) + + // "Only you can see this {kind}" — true when the authed user owns the + // sole draft on a never-deployed item. + const onlyOwnDraft = $derived( + draft_only && + draft_users.length === 1 && + !!currentUsername && + draft_users[0]?.username === currentUsername + ) + + let busyFor = $state(null) + let jsonOpen = $state(false) + let jsonOwnerLabel = $state('') + let jsonValue = $state(undefined) + + function ownerKey(owner: DraftUser): string { + return owner.username ?? '__legacy__' + } + + function forkPath(owner: DraftUser): string { + // `currentUsername` is required upstream for the badge to be + // meaningful, but defensively fall back to 'me' so the action + // still produces a well-formed path. + const leaf = (path ?? '').split('/').pop() ?? path ?? 'draft' + const ownerSuffix = owner.username ?? 'legacy' + return `u/${currentUsername ?? 'me'}/${leaf}_${ownerSuffix}_fork` + } + + async function fetchDraft(owner: DraftUser): Promise { + if (!workspace || !itemKind || !path) { + throw new Error('Missing context for draft fetch') + } + return ( + await DraftService.getDraftForUser({ + workspace, + kind: itemKind, + path, + username: owner.username ?? undefined + }) + ).value + } + + async function viewJson(owner: DraftUser) { + busyFor = ownerKey(owner) + try { + jsonValue = await fetchDraft(owner) + jsonOwnerLabel = fullLabel(owner) + jsonOpen = true + } catch (e: any) { + sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true) + } finally { + busyFor = null + } + } + + async function fork(owner: DraftUser) { + if (!workspace || !itemKind || !editPathFor) return + busyFor = ownerKey(owner) + try { + const value = await fetchDraft(owner) + const target = forkPath(owner) + // Bypass the autosave debouncer so the fork lands BEFORE we + // navigate. The destination editor loads via `getDraft=true` + // and 404s if no draft yet exists at the fork path. + await UserDraftDbSyncer.save({ + workspace, + itemKind, + path: target, + value, + immediate: true + }) + goto(editPathFor(target)) + } catch (e: any) { + sendUserToast(`Could not fork draft: ${e.body ?? e.message}`, true) + } finally { + busyFor = null + } + } {#if showBadge} {#snippet text()} - {#if draft_users.length > 0} - {draft_only ? 'Never deployed — only a draft exists.' : 'Deployed with drafts pending.'} -
- {#each draft_users as u} - • {fullLabel(u)}{u.username === currentUsername ? ' (you)' : ''} - {/each} -
- {:else if draft_only} - Never deployed and is only a draft - {:else} - Is deployed and has a draft - {/if} +
+

+ {#if draft_users.length > 0} + {draft_only ? 'Never deployed — only a draft exists.' : 'Deployed with drafts pending.'} + {:else if draft_only} + Never deployed and is only a draft + {:else} + Is deployed and has a draft + {/if} +

+ {#if draft_users.length > 0} +
    + {#each orderedUsers as u (u.username ?? '__legacy__')} + {@const isSelf = !!currentUsername && u.username === currentUsername} +
  • + + {initials(u)} + + + {fullLabel(u)}{isSelf ? ' (you)' : ''} + + {#if actionsEnabled && !isSelf} + + + {/if} +
  • + {/each} +
+ {/if} + {#if onlyOwnDraft} +

Only you can see this {kindLabel}.

+ {/if} +
{/snippet} {#if orderedUsers.length > 0} @@ -153,3 +317,28 @@
{/if} + + + {#snippet headerRight()} + + {/snippet} +
+
{JSON.stringify(jsonValue ?? {}, null, 2)}
+
+
diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index e9ff38acd9..efa9fe35e7 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -124,6 +124,11 @@ draft_only={app.draft_only} draft_users={app.draft_users} currentUsername={$userStore?.username} + workspace={$workspaceStore ?? undefined} + itemKind={app.raw_app ? 'raw_app' : 'app'} + path={app.path} + editPathFor={(forkedPath) => + app.raw_app ? `/apps_raw/edit/${forkedPath}` : `/apps/edit/${forkedPath}`} /> {#if app.labels?.length}
diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index 5b03fb421b..2b2fa80da1 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -146,6 +146,10 @@ draft_only={flow.draft_only} draft_users={flow.draft_users} currentUsername={$userStore?.username} + workspace={$workspaceStore ?? undefined} + itemKind="flow" + path={flow.path} + editPathFor={(forkedPath) => `/flows/edit/${forkedPath}`} /> {#if flow.labels?.length}
diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index f4ff55016b..a59fc519a3 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -195,6 +195,10 @@ draft_only={script.draft_only} draft_users={script.draft_users} currentUsername={$userStore?.username} + workspace={$workspaceStore ?? undefined} + itemKind="script" + path={script.path} + editPathFor={(forkedPath) => `/scripts/edit/${forkedPath}`} /> {#if script.labels?.length}