diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte new file mode 100644 index 0000000000..386007317c --- /dev/null +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -0,0 +1,399 @@ + + +
+
+ deploymentStatus[item.key]?.status !== 'deployed'} + onToggleItem={toggleItem} + onSelectAll={selectAll} + onDeselectAll={deselectAll} + emptyMessage={draftsLoading ? 'Loading drafts…' : 'No drafts in this workspace'} + > + {#snippet header()} + {#if isFork} +
+ onModeSelected?.(v)} + /> + +
+ + deploy: + + draft + + + + into: + + {currentWorkspaceId} + +
+
+ {/if} + {/snippet} + + {#snippet itemSummary(item)} + {@const draftItem = item as unknown as Row} + {@const editUrl = draftEditUrl(draftItem)} + {@const cache = summaryCache[draftItem.key]} + {@const oldSummary = cache?.deployed ?? draftItem.summary} + {@const newSummary = cache?.draft ?? draftItem.summary} + + {/snippet} + + {#snippet itemActions(item)} + {@const draftItem = item as unknown as Row} + {#if draftItem.draft_only} + New + {/if} + {#if deploymentStatus[draftItem.key]?.status !== 'deployed'} + + + {/if} + {/snippet} + + {#snippet footer()} +
+ +
+ {/snippet} +
+
+ + +
+ + (discardTarget = undefined)} +> + {#if discardTarget?.draft_only} +

+ {discardTarget?.path} exists only as a + draft. Discarding it will permanently delete the item. This cannot be undone. +

+ {:else} +

+ Discard the draft of + {discardTarget?.path}? The deployed + version is unaffected. +

+ {/if} +
diff --git a/frontend/src/lib/components/CompareModeToggle.svelte b/frontend/src/lib/components/CompareModeToggle.svelte new file mode 100644 index 0000000000..c7728ba090 --- /dev/null +++ b/frontend/src/lib/components/CompareModeToggle.svelte @@ -0,0 +1,61 @@ + + + + + onSelected(v as CompareMode)} noWFull> + {#snippet children({ item })} + {#if isFork} + + + {/if} + + {/snippet} + diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 95d36c4551..06d787095e 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -1,10 +1,8 @@ + + e.stopPropagation()} + class="group inline-flex items-center gap-1 max-w-full hover:underline {klass}" +> + {@render children()} + + diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index 7ce36b8e11..56418c1988 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -6,6 +6,7 @@ import { AlertTriangle, GitFork, CircleCheck, CircleX, Loader2 } from 'lucide-svelte' import { goto } from '$app/navigation' import { onMount, untrack } from 'svelte' + import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let loading = $state(false) let comparison: WorkspaceComparison | undefined = $state(undefined) @@ -16,6 +17,23 @@ let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id) let parentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId)) + // Drafts in this fork. When the fork is otherwise in sync with its parent, a + // user with only pending drafts should still get the draft CTA (mirrors the + // non-fork WorkspaceDraftsBanner). Pass undefined when not a fork so it doesn't + // fetch. + const drafts = useWorkspaceDrafts(() => (isFork ? ($workspaceStore ?? undefined) : undefined)) + const draftCount = $derived(drafts.count) + + // Fork is fully in sync with its parent (comparison ran, no ahead/behind diffs). + // Typed helper avoids the $state `never`-inference quirk on `comparison` in $derived. + function isUpToDate(c: WorkspaceComparison | undefined): boolean { + return !!c && !c.skipped_comparison && c.summary.total_diffs === 0 + } + let upToDate = $derived(isUpToDate(comparison)) + // Up to date with the parent but local drafts are pending — show the draft + // state (same text + CTA as the draft banner) instead of "Everything is up to date". + let showDraftsOnly = $derived(upToDate && draftCount > 0) + $effect(() => { ;[$workspaceStore, parentWorkspaceId] untrack(() => { @@ -68,6 +86,14 @@ } } + function openDraftCompare() { + if ($workspaceStore) { + goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore) + '&mode=draft', { + replaceState: true + }) + } + } + let ciTestPassing = $state(0) let ciTestFailing = $state(0) let ciTestRunning = $state(0) @@ -270,6 +296,10 @@ This fork was created before the addition of certain windmill features, and therefore the changes with its parent workspace cannot be displayed. + {:else if showDraftsOnly} + + This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} + {:else} Everything is up to date {/if} @@ -278,8 +308,14 @@
- +
+ + +{/if} diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 939519522f..b90fb385d8 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -14,6 +14,10 @@ disabled?: boolean canFavorite?: boolean isSelectable?: boolean + /** When true, clicking anywhere on the row card (except interactive + * children — checkbox, buttons, links) toggles selection. Opt-in so + * existing tables that don't want it are unaffected. */ + selectOnRowClick?: boolean alignWithSelectable?: boolean errorHandlerMuted?: boolean aiId?: string | undefined @@ -62,6 +66,7 @@ disabled = false, canFavorite = true, isSelectable = false, + selectOnRowClick = false, alignWithSelectable = false, errorHandlerMuted = false, aiId = undefined, @@ -92,6 +97,32 @@ rowEl?.scrollIntoView({ block: 'nearest' }) } }) + + const clickToSelect = $derived(selectOnRowClick && isSelectable && !disabled) + + // Interactive children that handle their own activation — selecting the row on + // top of them would double-fire (mouse) or hijack their keyboard activation. + function fromInteractiveChild(e: Event): boolean { + return !!(e.target as HTMLElement | null)?.closest('a, button, input, [data-row-actions]') + } + + function handleRowClick(e: MouseEvent) { + if (!clickToSelect) return + // Don't double-toggle when the click originated from an interactive child + // (the checkbox itself, action buttons, or the title link). + if (fromInteractiveChild(e)) return + onSelect?.(e as unknown as Event & { currentTarget: EventTarget & HTMLInputElement }) + } + + function handleRowKeydown(e: KeyboardEvent) { + if (!clickToSelect) return + if (e.key !== 'Enter' && e.key !== ' ') return + // Same guard as the click path: activating a child (checkbox / action button + // / title link) via Enter/Space must not also toggle the row's selection. + if (fromInteractiveChild(e)) return + e.preventDefault() + onSelect?.(e as unknown as Event & { currentTarget: EventTarget & HTMLInputElement }) + } {#if href} @@ -112,9 +143,14 @@ 'w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0', depth > 0 ? '!rounded-none' : '', disabled ? 'opacity-25' : 'hover:bg-surface-hover', + clickToSelect ? 'cursor-pointer select-none' : '', selected ? 'bg-surface-accent-selected' : keyboardSelected ? 'bg-gray-200 dark:bg-gray-700' : '' )} style={depth > 0 ? `padding-left: ${depth * 32}px;` : ''} + role={clickToSelect ? 'button' : undefined} + tabindex={clickToSelect ? 0 : undefined} + onclick={handleRowClick} + onkeydown={clickToSelect ? handleRowKeydown : undefined} > {#if isSelectable} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8121ca8b70..63f7a42453 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -69,6 +69,9 @@ onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** Fired after a successful deploy; the session preview reloads on it. */ onDeploy?: (e: { path: string }) => void + /** Fired after a successful server-draft save; the session refreshes its + * draft-bar count on it (parity with the script/flow editors). */ + onSaveDraft?: (e: { path: string }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -101,6 +104,7 @@ diffDrawer = undefined, onNavigate, onDeploy = undefined, + onSaveDraft = undefined, defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', liveEditorDraftStoragePath = undefined, @@ -1371,6 +1375,7 @@ {getBundle} {onNavigate} {onDeploy} + {onSaveDraft} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1637,8 +1642,9 @@ title="Build failed" class="relative before:absolute before:inset-0 before:-z-10 before:rounded-md before:bg-surface before:content-['']" > -
{buildError}
+
{buildError}
{/if} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index dedb7b5d11..46dec152f7 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -136,6 +136,9 @@ liveEditorDraftStoragePath?: string // Fired after a successful deploy; lets the session preview reload. onDeploy?: (e: { path: string }) => void + // Fired after a successful server-draft save; lets the session refresh the + // draft-bar count (the script/flow editors do the same on save-draft). + onSaveDraft?: (e: { path: string }) => void } let { @@ -162,7 +165,8 @@ onToggleSidebar = undefined, onNavigate = undefined, liveEditorDraftStoragePath = undefined, - onDeploy = undefined + onDeploy = undefined, + onSaveDraft = undefined }: Props = $props() let newEditedPath = $state( @@ -517,6 +521,8 @@ // a future "+ App" click opens on a clean slate. if (!inSessionPane) UserDraft.remove('raw_app', appPath) dispatch('savedNewAppPath', newEditedPath) + sendUserToast('Draft saved') + onSaveDraft?.({ path: newEditedPath }) } catch (e) { sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true) } @@ -529,8 +535,15 @@ return } if (newApp) { - // initial draft - draftDrawerOpen = true + if (appPath === '') { + // Standalone "+ App" with no path chosen yet — pick one via the drawer. + draftDrawerOpen = true + return + } + // Path already known (e.g. an AI-created raw app in the session preview). + // The path-picker drawer is gated on `appPath == ''`, so opening it here + // renders nothing — save the initial draft directly instead. + await saveInitialDraft() return } if (!savedApp) { @@ -621,6 +634,7 @@ if (newApp || savedApp.draft_only) { dispatch('savedNewAppPath', newEditedPath || path) } + onSaveDraft?.({ path: newEditedPath || path }) } catch (e) { loading.saveDraft = false throw e diff --git a/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte new file mode 100644 index 0000000000..9fa83be7fc --- /dev/null +++ b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte @@ -0,0 +1,82 @@ + + + buildEditUrl(d as unknown as WorkspaceItemDiff, workspaceId)} +> + {#snippet titleExtra()} +
+ + {ws?.name ?? workspaceId} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index dfa928f08c..9613b4c25a 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -5,6 +5,7 @@ import type { SessionRuntime } from './sessionRuntime.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' + import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let { runtime, @@ -65,12 +66,18 @@ {diffDrawer} {onNavigate} customUi={{ topBar: { aiBuilder: false } }} - onSaveDraft={() => runtime.scheduleForkComparisonRefresh()} + onSaveDraft={() => { + runtime.scheduleForkComparisonRefresh() + // Saving a draft adds/keeps a pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) + }} onDeploy={() => { // FlowBuilder has no deploy toast and the session stays put, so toast // here, then sync the preview to deployed (pulls the new locks + version_id). sendUserToast('Deployed') runtime.syncPreviewWithDeployed(workspaceId, 'flow', path) + // Deploying clears the item's pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) }} /> {/snippet} diff --git a/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte b/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte index acfac1f03f..e03fc5a49c 100644 --- a/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte @@ -1,84 +1,71 @@ - searchableText(d)} -/> - -{#snippet renderTreeNode(node: TreeNode, depth: number)} - {#if node.type === 'folder'} - {@const isUserScope = node.isScope && node.name.startsWith('u/')} - {@const fkey = folderKey(node)} - {@const open = isFolderOpen(fkey)} - {@const isHl = fkey === highlightedKey} -
(folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)} - class="select-none" - > - setHoverHighlight(fkey)} - class="flex items-center gap-1.5 px-3 py-1 cursor-pointer text-xs font-medium font-mono text-emphasis hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl - ? 'bg-surface-hover' - : ''}" - style="padding-left: {depth * 12 + 8}px" - > - - - {#if isUserScope} - - {:else} - - {/if} - {node.name} - -
- {#each node.children as child} - {@render renderTreeNode(child, depth + 1)} - {/each} -
-
- {:else} - {@const status = statusOf(node.diff)} - {@const key = itemKey(node.diff)} - { - highlightedKey = key - scrollToDiff(node.diff) - }} - onmouseenter={() => setHoverHighlight(key)} - > - {#snippet extras()} - - {/snippet} - - {/if} -{/snippet} - - - drawer?.closeDrawer()} - documentationLink={undefined} - noPadding - overflow_y={false} - > - {#snippet titleExtra()} -
- - {forkWs?.name ?? forkWorkspaceId} - - {parentWs?.name ?? parentWorkspaceId} - {#if comparison} - - {comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''} + buildEditUrl(d as unknown as WorkspaceItemDiff, forkWorkspaceId)} +> + {#snippet titleExtra()} +
+ + {forkWs?.name ?? forkWorkspaceId} + + {parentWs?.name ?? parentWorkspaceId} + {#if comparison} + + {comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''} + + {#if comparison.summary.conflicts > 0} + + + {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''} - {#if comparison.summary.conflicts > 0} - - - {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''} - - {/if} {/if} -
- {/snippet} - {#snippet actions()} - - {#snippet children({ item })} - - - {/snippet} - - - {/snippet} -
- {#if comparison && comparison.diffs.length > 0} - {/if} -
-
- {#if loading && !comparison} -
- - Loading comparison... -
- {:else if error} -
{error}
- {:else if comparison?.skipped_comparison} -
- This fork was created before change tracking was added — diffs are not available. -
- {:else if comparison && comparison.diffs.length === 0} -
No changes between this fork and its parent.
- {:else if comparison && filteredDiffs.length === 0} -
No files match "{searchQuery}".
- {:else if comparison} -
- {#each filteredDiffs as d (itemKey(d))} - {@const key = itemKey(d)} - {@const status = statusOf(d)} - {@const StatusIcon = statusIcons[status]} - {@const loaded = loadedDiffs[key]} - {@const editUrl = editUrlFor(d)} -
onDetailsToggle(d, e)} - > - - - - -
- {#if d.ahead > 0} - {d.ahead} ahead - {/if} - {#if d.behind > 0} - {d.behind} behind - {/if} - - - {status} - -
-
-
- {#if !loaded || loaded.state === 'loading'} -
- - Loading diff… -
- {:else if loaded.state === 'error'} -
{loaded.error}
- {:else if loaded.state === 'ready'} - - {/if} -
-
- {/each} -
- {/if} -
- - - - +
+ {/snippet} + diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 9ed75f9702..683d32a31f 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -4,6 +4,7 @@ import type { WorkspaceItem } from '$lib/components/workspacePicker' import type { SessionRuntime } from './sessionRuntime.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' + import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let { runtime, @@ -62,6 +63,13 @@ onDeploy={(e) => { // Sync the preview to deployed (raw apps deploy only from this editor). runtime.syncPreviewWithDeployed(workspaceId, 'raw_app', e.path) + // Deploying clears the item's pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) + }} + onSaveDraft={() => { + // Saving a server draft adds/updates a draft — refresh the Draft Count so + // the session draft bar appears/updates immediately (parity with script/flow). + invalidateWorkspaceDrafts(workspaceId) }} defaultSidebarCollapsed sidebarStorageKey="raw-app-sidebar-collapsed-preview" diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index bebd6d3cce..2e6fc4210f 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -7,6 +7,7 @@ import { UserDraft } from '$lib/userDraft.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' + import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' let { runtime, @@ -45,6 +46,9 @@ try { await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path }) saved.draft = undefined + // Server draft gone — refresh the session draft-bar count immediately + // instead of waiting for an AI turn-end / tab-refocus signal. + invalidateWorkspaceDrafts(workspaceId) } catch (e: any) { sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true) return @@ -103,6 +107,8 @@ {initialTestPanelCollapsed} onSaveDraft={async (e) => { runtime.scheduleForkComparisonRefresh() + // Saving a draft adds/keeps a pending draft — refresh the Draft Count. + invalidateWorkspaceDrafts(workspaceId) // Re-pin parent_hash to the latest version so the next Deploy's conflict // check (which runs before deploy, while the session stays mounted) // doesn't misfire. @@ -123,6 +129,9 @@ // preview to the deployed version. sendUserToast('Deployed') runtime.syncPreviewWithDeployed(workspaceId, 'script', e.path) + // Deploying clears the item's pending draft — refresh the workspace + // Draft Count so the session bar / compare page drop it immediately. + invalidateWorkspaceDrafts(workspaceId) }} /> {/if} diff --git a/frontend/src/lib/components/sessions/SessionDiffButton.svelte b/frontend/src/lib/components/sessions/SessionDiffButton.svelte new file mode 100644 index 0000000000..77dd16d156 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionDiffButton.svelte @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/lib/components/sessions/SessionDraftBar.svelte b/frontend/src/lib/components/sessions/SessionDraftBar.svelte new file mode 100644 index 0000000000..dcc5640f2c --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionDraftBar.svelte @@ -0,0 +1,70 @@ + + +{#if committedId && count > 0} +
+
+ + {count} draft{count === 1 ? '' : 's'} +
+
+ drawer?.open()} /> + +
+
+ + +{/if} diff --git a/frontend/src/lib/components/sessions/SessionForkBar.svelte b/frontend/src/lib/components/sessions/SessionForkBar.svelte index 22b41a7a00..b1e74b43eb 100644 --- a/frontend/src/lib/components/sessions/SessionForkBar.svelte +++ b/frontend/src/lib/components/sessions/SessionForkBar.svelte @@ -2,10 +2,8 @@ import { Archive, ArrowRight, - Diff, GitCompareArrows, GitFork, - GitMerge, GitPullRequestArrow, GitPullRequestClosed, MoveRight, @@ -20,6 +18,7 @@ import { deriveForkStatus, sessionState, type Session } from './sessionState.svelte' import { getRuntime } from './sessionRuntime.svelte' import ForkDiffDrawer from './ForkDiffDrawer.svelte' + import SessionDiffButton from './SessionDiffButton.svelte' let { session, @@ -189,24 +188,12 @@
- - + /> +
diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 81b6f06aa0..830cf1d2bd 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -29,6 +29,7 @@ import RawAppEditorView from './RawAppEditorView.svelte' import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' import SessionForkBar from './SessionForkBar.svelte' + import SessionDraftBar from './SessionDraftBar.svelte' import { createSession, getEffectiveWorkspaceId, @@ -274,13 +275,20 @@ {#if !hasFirstUserMessage} {/if} - moveAndActivate(workspaceId)} - onCreateForkAndMove={(fork) => createForkAndMove(fork)} - onArchive={() => archiveAndReset()} - onDelete={() => (deleteConfirmOpen = true)} - /> + +
+ moveAndActivate(workspaceId)} + onCreateForkAndMove={(fork) => createForkAndMove(fork)} + onArchive={() => archiveAndReset()} + onDelete={() => (deleteConfirmOpen = true)} + /> + +
{/snippet} + + +
+ {#if tree.children.length > 0} + {#each tree.children as child} + {@render renderTreeNode(child, 0)} + {/each} + {:else} +
No matches
+ {/if} +
+ + {/if} +
+
+ {#if loading && diffs.length === 0} +
+ + Loading comparison... +
+ {:else if error} +
{error}
+ {:else if notice} +
{notice}
+ {:else if diffs.length === 0} +
{emptyMessage}
+ {:else if filteredDiffs.length === 0} +
No files match "{searchQuery}".
+ {:else} +
+ {#each filteredDiffs as d (itemKey(d))} + {@const key = itemKey(d)} + {@const status = d.status} + {@const StatusIcon = statusIcons[status]} + {@const loaded = loadedDiffs[key]} + {@const editUrl = editUrlFor?.(d)} +
onDetailsToggle(d, e)} + > + + + +
+ {#if editUrl} + + {d.path} + + {:else} +
+ {d.path} +
+ {/if} +
+
+ {#if d.ahead && d.ahead > 0} + {d.ahead} ahead + {/if} + {#if d.behind && d.behind > 0} + {d.behind} behind + {/if} + + + {status} + +
+
+
+ {#if !loaded || loaded.state === 'loading'} +
+ + Loading diff… +
+ {:else if loaded.state === 'error'} +
{loaded.error}
+ {:else if loaded.state === 'ready'} + + {/if} +
+
+ {/each} +
+ {/if} +
+ +
+
+ + diff --git a/frontend/src/lib/rawAppDeploy.ts b/frontend/src/lib/rawAppDeploy.ts new file mode 100644 index 0000000000..0ed87a1b09 --- /dev/null +++ b/frontend/src/lib/rawAppDeploy.ts @@ -0,0 +1,122 @@ +/** + * Deploy a raw app (code-based app) from its server-side draft. Raw apps can't + * be deployed through the normal AppService.updateApp/createApp path: their + * source `files` must be bundled to js/css and saved via the raw-app endpoints. + * + * This mirrors how the global AI chat deploys raw apps + * (`copilot/chat/global/core.ts` → deployDraft, case 'app'): read the item with + * its draft, normalise to an AppDraftValue, recompute the policy, bundle the + * files, then createAppRaw/updateAppRaw. The two pure transforms + * (appSourceToDraftValue / normalizeRawAppData) are re-implemented here to avoid + * importing the heavy chat module. + */ +import { get } from 'svelte/store' +import { AppService } from '$lib/gen' +import type { Policy } from '$lib/gen' +import { userStore } from '$lib/stores' +import { bundleRawAppDraft } from '$lib/components/copilot/chat/global/rawAppBundlerBridge' +import type { AppDraftValue } from '$lib/components/copilot/chat/global/workspaceItems' +import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' +import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' + +function normalizeRawAppData(value: Record): AppDraftValue['data'] { + if (value.data?.creation) { + return { + tables: value.data.tables ?? [], + datatable: value.data.creation.datatable, + schema: value.data.creation.schema + } + } + if (value.data) return value.data + if (value.datatables) return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables } + if (value.dataTableRefs) return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs } + return { ...DEFAULT_RAW_APP_DATA } +} + +function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue { + const value = (app.value ?? {}) as Record + return { + summary: app.summary ?? '', + files: { ...(value.files ?? {}) }, + runnables: { ...(value.runnables ?? {}) }, + data: normalizeRawAppData(value), + policy: app.policy ?? fallback?.policy, + custom_path: app.custom_path ?? fallback?.custom_path + } +} + +/** + * Promote a raw app's draft to deployed. Throws on failure (caller wraps into a + * DeployResult). The matching draft row is deleted server-side by the raw-app + * create/update handler, like the other deploy paths. + */ +export async function deployRawAppDraft( + workspace: string, + path: string, + deploymentMessage?: string +): Promise { + const app = await AppService.getAppByPathWithDraft({ workspace, path }) + const draft = (app as any).draft + // Honor a renamed draft path; the URL `path` below stays the existing item key. + const targetPath = draft?.path ?? path + const value = appSourceToDraftValue(draft ?? app, app) + + const policy = (await updateRawAppPolicy( + value.runnables as any, + value.policy as any + )) as NonNullable & Policy + if (!policy.execution_mode) { + policy.execution_mode = 'publisher' + } + + const bundle = await bundleRawAppDraft({ workspace, files: value.files }) + + const rawAppValue = { + files: value.files, + runnables: value.runnables, + data: value.data ?? { ...DEFAULT_RAW_APP_DATA } + } + const summary = value.summary ?? '' + + if (await AppService.existsApp({ workspace, path })) { + // custom_path changes require admin. Mirror RawAppEditorHeader's update path: + // admins send the draft's value (`''` to clear), non-admins send undefined so + // the backend ignores it and preserves the existing route — otherwise a + // non-admin deploying a draft for an app that has a custom route would hit + // RequireAdmin (the deployed custom_path is sent via the appSourceToDraftValue + // fallback even when unchanged). + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + await AppService.updateAppRaw({ + workspace, + path, + formData: { + app: { + path: targetPath, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage, + custom_path: isAdmin ? (value.custom_path ?? '') : undefined + }, + js: bundle.js, + css: bundle.css + } + }) + } else { + await AppService.createAppRaw({ + workspace, + formData: { + app: { + path: targetPath, + value: rawAppValue, + summary, + policy, + deployment_message: deploymentMessage, + custom_path: value.custom_path + }, + js: bundle.js, + css: bundle.css + } + }) + } +} diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts new file mode 100644 index 0000000000..8ce4624fc1 --- /dev/null +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -0,0 +1,219 @@ +/** + * Draft deploy/discard orchestration for the compare page's "draft" mode. + * + * Drafts only exist for scripts, flows and apps (the `draft_type` enum). A draft + * is the editor's serialized state stored in the `draft` table; deploying it is + * the same create/update call the editor makes on "Deploy", which auto-deletes + * the matching draft server-side (unless `skip_draft_deletion`) — so we never + * call `deleteDraft` after a successful deploy. The lock/dependency job runs + * async, exactly as in the editor. + * + * Discarding branches on `draft_only`: a `draft_only` item exists only as a + * draft, so discarding deletes the whole item (mirrors `common/table/*Row.svelte`); + * a draft on an already-deployed item just deletes the draft row. + */ +import { get, writable } from 'svelte/store' +import { ScriptService, FlowService, AppService, DraftService } from '$lib/gen' +import type { DeployResult } from '$lib/utils_workspace_deploy' +import { deployRawAppDraft } from '$lib/rawAppDeploy' +import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' +import { userStore } from '$lib/stores' +import { deployTriggers, type Trigger } from '$lib/components/triggers/utils' + +export type DraftKind = 'script' | 'flow' | 'app' + +export interface DraftDiffValues { + deployed: unknown + draft: unknown +} + +// Empty-but-valid "deployed" shapes for draft_only items (which have never been +// deployed). Using a fully-empty `{}` breaks the flow graph diff (it needs +// `value.modules`) and leaves the drawer spinning — so each kind gets a minimal +// valid shape, making the whole draft show as "all new". +const EMPTY_DEPLOYED: Record unknown> = { + script: (draft) => ({ content: '', language: draft?.language, schema: {} }), + flow: () => ({ summary: '', value: { modules: [] }, schema: {} }), + app: () => ({ summary: '', value: {}, policy: {} }) +} + +/** + * Fetch the deployed value and the draft value for an item, for the DiffDrawer + * (`mode: 'simple'`, original = deployed, current = draft). For a `draft_only` + * item there is no real deployed value, so the deployed side is a minimal + * empty-but-valid shape and the draft shows as entirely new. DiffDrawer cleans + * both sides via `cleanValueProperties`, so raw objects are fine here. + */ +export async function getDraftDiffValues( + kind: DraftKind, + path: string, + workspace: string, + draftOnly = false +): Promise { + // A `draft_only` item can keep its content in the row itself with no separate + // draft-table row (e.g. a flow created via createFlow(draft_only: true), like + // `u/admin/new`). There `draft` is null, so the draft side must fall back to + // the row's own value — otherwise the diff "after" is empty and nothing shows. + if (kind === 'script') { + const r = (await ScriptService.getScriptByPathWithDraft({ workspace, path })) as any + const { draft, draft_created_at: _c, hash: _h, ...deployed } = r + const draftValue = draft ?? deployed + return { deployed: draftOnly ? EMPTY_DEPLOYED.script(draftValue) : deployed, draft: draftValue } + } else if (kind === 'flow') { + const r = (await FlowService.getFlowByPathWithDraft({ workspace, path })) as any + const { draft, draft_created_at: _c, ...deployed } = r + const draftValue = draft ?? deployed + return { deployed: draftOnly ? EMPTY_DEPLOYED.flow(draftValue) : deployed, draft: draftValue } + } else { + const r = (await AppService.getAppByPathWithDraft({ workspace, path })) as any + const deployed = { + summary: r.summary, + value: r.value, + policy: r.policy, + path: r.path, + custom_path: r.custom_path + } + const draftValue = r.draft ?? deployed + return { deployed: draftOnly ? EMPTY_DEPLOYED.app(draftValue) : deployed, draft: draftValue } + } +} + +/** + * Deploy a script/flow draft's trigger changes the same way the editors do. + * Scripts and flows can carry `draft_triggers`; the create/update call below + * deletes the draft row, so without this the saved trigger edits would be + * silently lost. Uses the shared `deployTriggers` (a throwaway `usedTriggerKinds` + * store is fine — it only tracks kinds for the editor UI). `isNew` forces each + * trigger's `script_path` to the deployed path (matches the editors' new path). + */ +async function deployDraftTriggers( + draftTriggers: Trigger[] | undefined, + workspace: string, + path: string, + isNew: boolean +): Promise { + const triggers = (draftTriggers ?? []).filter((t) => t?.draftConfig) + if (triggers.length === 0) return + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + await deployTriggers(triggers, workspace, isAdmin, writable([]), path, isNew) +} + +/** + * Promote a draft to deployed by replaying the editor's create/update call with + * the stored draft value. The matching draft row is deleted server-side by the + * create/update handler. Returns the same `{ success, error? }` shape as the + * fork-merge `deployItem`, so callers can reuse the `deploymentStatus` pattern. + */ +export async function deployDraft( + kind: DraftKind, + path: string, + workspace: string, + draftOnly = false, + rawApp = false +): Promise { + try { + if (kind === 'app' && rawApp) { + // Raw apps bundle their source files to js/css and deploy via the + // raw-app endpoints — same as the global AI chat's deploy. + await deployRawAppDraft(workspace, path) + } else if (kind === 'script') { + const r = (await ScriptService.getScriptByPathWithDraft({ workspace, path })) as any + const d = r.draft ?? r + // Drop editor-only / server-managed keys; deploy as a real (non-draft) version. + const { draft_triggers: draftTriggers, draft_only: _o, ...rest } = d + const scriptPath = d.path ?? path + // Deploy at the draft's path so a rename in the draft is honored (same as + // the editor: createScript at the new path with parent_hash links lineage). + await ScriptService.createScript({ + workspace, + requestBody: { ...rest, path: scriptPath, parent_hash: r.hash } + }) + // Then deploy any draft trigger edits, so they aren't dropped with the draft. + await deployDraftTriggers(draftTriggers, workspace, scriptPath, true) + } else if (kind === 'flow') { + const r = (await FlowService.getFlowByPathWithDraft({ workspace, path })) as any + const d = r.draft ?? r + const requestBody = { + // Honor a renamed draft path; the URL `path` stays the existing item key. + path: d.path ?? path, + summary: d.summary ?? '', + description: d.description ?? '', + value: d.value, + schema: d.schema, + tag: d.tag, + dedicated_worker: d.dedicated_worker, + ws_error_handler_muted: d.ws_error_handler_muted, + visible_to_runner_only: d.visible_to_runner_only, + on_behalf_of_email: d.on_behalf_of_email, + labels: d.labels + } + // A draft (draft_only or on a deployed flow) always has a flow row, so + // updateFlow is correct in both cases — it promotes a draft_only flow to + // a real deployed version (clearing the flag). createFlow would 400 + // "Flow already exists". + await FlowService.updateFlow({ workspace, path, requestBody }) + // Then deploy any draft trigger edits, so they aren't dropped with the draft. + await deployDraftTriggers(d.draft_triggers, workspace, d.path ?? path, draftOnly) + } else { + const r = (await AppService.getAppByPathWithDraft({ workspace, path })) as any + const d = r.draft ?? { + value: r.value, + summary: r.summary, + policy: r.policy, + path: r.path, + custom_path: r.custom_path + } + // custom_path requires admin on app update. Non-admins send undefined so + // the backend preserves the existing route (no RequireAdmin 403). For + // admins, fall back to the *deployed* route (`r.custom_path`) when the + // draft doesn't carry one — the visual-app draft value usually omits + // custom_path, and sending `''` would clear the existing route. An + // explicit '' in the draft still clears (`'' ?? x === ''`). + const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) + const requestBody = { + value: d.value, + summary: d.summary ?? '', + policy: d.policy, + path: d.path ?? path, + custom_path: isAdmin ? (d.custom_path ?? r.custom_path) : undefined + } + // Same as flows: a draft always has an app row, so updateApp promotes a + // draft_only app (clearing the flag); createApp would 400 "already exists". + await AppService.updateApp({ workspace, path, requestBody }) + } + // Mutated the workspace's Server Drafts — refresh every mounted reader. + invalidateWorkspaceDrafts(workspace) + return { success: true } + } catch (e: any) { + return { success: false, error: e?.body ?? e?.message ?? String(e) } + } +} + +/** + * Discard a draft. For `draft_only` items the item exists only as a draft, so + * delete the whole item; otherwise delete just the draft row. + */ +export async function discardDraft( + kind: DraftKind, + path: string, + workspace: string, + draftOnly = false +): Promise { + try { + if (draftOnly) { + if (kind === 'script') { + await ScriptService.deleteScriptByPath({ workspace, path }) + } else if (kind === 'flow') { + await FlowService.deleteFlowByPath({ workspace, path }) + } else { + await AppService.deleteApp({ workspace, path }) + } + } else { + await DraftService.deleteDraft({ workspace, path, kind }) + } + invalidateWorkspaceDrafts(workspace) + return { success: true } + } catch (e: any) { + return { success: false, error: e?.body ?? e?.message ?? String(e) } + } +} diff --git a/frontend/src/lib/workspaceDrafts.svelte.ts b/frontend/src/lib/workspaceDrafts.svelte.ts new file mode 100644 index 0000000000..475fbb9894 --- /dev/null +++ b/frontend/src/lib/workspaceDrafts.svelte.ts @@ -0,0 +1,143 @@ +/** + * Workspace Drafts — the single source of truth for "which Server Drafts exist + * in a workspace". Lists the deployable Draft Items once; the Draft Count is + * simply that list's length — never a separate query. This is what makes the + * count reliable: count ≡ list, by construction. + * + * Behind this seam the list is currently assembled from the three version-aware + * list endpoints (scripts/flows/apps with `include_draft_only`). A single + * `GET /w/{ws}/drafts/items` endpoint can replace `getDraftItems` later without + * touching any consumer. + * + * Reactivity: `useWorkspaceDrafts(() => ws)` is a component-scoped `runed` + * resource — it fetches on mount and when `ws` changes, and is disposed on + * unmount, so a re-opened view always shows a fresh count (no persistent cache + * to go stale). `invalidateWorkspaceDrafts(ws)` bumps a per-workspace version so + * every *mounted* consumer re-fetches after a Server-Draft mutation. + */ +import { resource } from 'runed' +import { ScriptService, FlowService, AppService } from '$lib/gen' + +export type DraftKind = 'script' | 'flow' | 'app' + +export interface DraftItem { + kind: DraftKind + path: string + summary?: string + /** Never deployed — exists only as a draft. */ + draft_only: boolean + /** App is a raw app (deploys via the raw-app endpoints). Always false for non-apps. */ + raw_app: boolean +} + +/** The one place the "is this a deployable Draft Item?" rule lives on the + * frontend: a pending draft on a deployed item (`has_draft`) OR a never-deployed + * `draft_only` item. Mirrors the backend `count_drafts` predicate. */ +/** The list-endpoint fields this module reads. Kept as a narrow local interface + * (rather than `any`) so the count predicate isn't typed against `any`. NOTE: + * `openapi.yaml`'s `ListableApp` still omits `has_draft`/`draft_only` (the backend + * struct returns them) — the proper fix is to add them to the spec and regenerate + * the client; until then this interface documents the contract relied on. */ +interface DraftListEntry { + path: string + summary?: string + has_draft?: boolean + draft_only?: boolean + raw_app?: boolean +} + +// The list endpoints are paginated; without paging, drafts past the first page +// would be silently missing from the count/list (and "Deploy all"). Page through +// with a generous page size until a short page signals the end. +const DRAFT_LIST_PER_PAGE = 100 + +async function listAllPages( + fetchPage: (page: number, perPage: number) => Promise +): Promise { + const all: DraftListEntry[] = [] + for (let page = 1; ; page++) { + const batch = await fetchPage(page, DRAFT_LIST_PER_PAGE) + all.push(...batch) + if (batch.length < DRAFT_LIST_PER_PAGE) break + } + return all +} + +export async function getDraftItems(workspace: string): Promise { + const [scripts, flows, apps] = await Promise.all([ + listAllPages((page, perPage) => + ScriptService.listScripts({ workspace, includeDraftOnly: true, page, perPage }) + ), + listAllPages((page, perPage) => + FlowService.listFlows({ workspace, includeDraftOnly: true, page, perPage }) + ), + listAllPages((page, perPage) => + AppService.listApps({ workspace, includeDraftOnly: true, page, perPage }) + ) + ]) + const items: DraftItem[] = [] + const push = (kind: DraftKind, list: DraftListEntry[]) => { + for (const it of list) { + if (it.has_draft || it.draft_only) { + items.push({ + kind, + path: it.path, + summary: it.summary, + draft_only: !!it.draft_only, + raw_app: !!it.raw_app + }) + } + } + } + push('script', scripts) + push('flow', flows) + push('app', apps) + items.sort((a, b) => a.path.localeCompare(b.path)) + return items +} + +// Per-workspace invalidation version. Bumping it changes the resource key for +// that workspace, so mounted consumers re-fetch. Plain $state record. +const versions: Record = $state({}) + +export function invalidateWorkspaceDrafts(workspace: string | undefined): void { + if (!workspace) return + versions[workspace] = (versions[workspace] ?? 0) + 1 +} + +export interface WorkspaceDraftsHandle { + readonly items: DraftItem[] + readonly count: number + readonly loading: boolean + /** Imperative re-fetch (e.g. right after a mutation in the same component). */ + refresh: () => void +} + +/** + * Reactive Workspace Drafts for the given workspace. Call at component init. + * Re-fetches on mount, when `workspace` changes, and when + * `invalidateWorkspaceDrafts(workspace)` is called while mounted. + */ +export function useWorkspaceDrafts(workspace: () => string | undefined): WorkspaceDraftsHandle { + const res = resource( + () => { + const ws = workspace() + return { ws, v: ws ? (versions[ws] ?? 0) : 0 } + }, + async ({ ws }) => (ws ? getDraftItems(ws) : []) + ) + return { + get items() { + return res.current ?? [] + }, + get count() { + return (res.current ?? []).length + }, + get loading() { + return res.loading + }, + refresh() { + void res.refetch() + } + } +} diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index e15719694a..b070bd5fef 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -37,6 +37,7 @@ import { page } from '$app/state' import { goto, replaceState } from '$app/navigation' import ForkWorkspaceBanner from '$lib/components/ForkWorkspaceBanner.svelte' + import WorkspaceDraftsBanner from '$lib/components/WorkspaceDraftsBanner.svelte' import WorkspaceTutorials from '$lib/components/WorkspaceTutorials.svelte' import { onMount, setContext } from 'svelte' import { tutorialsToDo } from '$lib/stores' @@ -278,6 +279,7 @@ style="scrollbar-gutter: stable both-edges;" > +
{#if $workspaceStore == 'admins'}
diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.js b/frontend/src/routes/(root)/(logged)/forks/compare/+page.js index 712d677d68..2e9ca0e335 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.js +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.js @@ -1,5 +1,5 @@ export function load() { return { - stuff: { title: 'Compare / Deploy to main workspace' } + stuff: { title: 'Compare & Deploy' } } } diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte index a6be7b1044..7c32b6a6af 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -1,9 +1,11 @@ - - {#if isFork} -
+ +
+ + {#if isFork} -
- {/if} + {/if} +
- {#if currentWorkspaceId && parentWorkspaceId} - - {/if} {#if !currentWorkspaceId} No workspace selected - {:else if !parentWorkspaceId} + {:else if mode === 'draft'} + + {:else if parentWorkspaceId} + + {:else} workspace {currentWorkspaceId} has no parent workspace {/if}