feat: deployed↔draft compare + AI-session draft bar (#9435)

* feat: deployed↔draft compare for current workspace + session draft bar

Add a "Deployed ↔ draft" comparison alongside the existing fork-vs-parent
compare flow, and surface drafts in the AI session UI.

- Merge the fork-direction toggle (Deploy to parent / Update current) and
  the new deployed↔draft mode into one 3-way CompareModeToggle, rendered
  inside the comparison card. Hidden in non-fork workspaces (draft only).
- CompareDrafts: list/deploy/discard server drafts (scripts, flows, apps
  incl. raw apps) via shared WorkspaceDeployLayout.
- Session draft bar (SessionDraftBar) mirrors the fork bar, only visible
  when drafts exist; its diff button opens the shared read-only diff
  drawer extracted as WorkspaceDiffDrawer (ForkDiffDrawer + DraftDiffDrawer
  are thin wrappers over it).
- WorkspaceDraftsBanner: home banner linking to draft review.
- Backend: GET /drafts/count endpoint for the draft-count badge.
- Raw app draft deploy (rawAppDeploy.ts) + vite /ui_builder proxy headers
  so the bundler iframe loads cross-origin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: refine draft/fork compare toggle UX

Follow-up polish on the merged deploy/draft compare control:

- Relabel the draft toggle to "Deploy draft (N)" and show per-direction
  counts on all three toggle buttons (deployable / updateable / drafts),
  suppressed when zero. Counts are computed page-side so they persist in
  draft mode too.
- Warn before deploying to the parent when the fork has undeployed drafts
  ("Only deployed versions in this fork can be sent to {parent} …") with a
  one-click link to the draft view; milder note in the update direction.
- Show an empty-state message per direction ("Nothing to update — this
  fork is up to date with {parent}") instead of a table of greyed,
  non-actionable rows; hide the deploy/update button in that case.
- Drop the standalone "Pending drafts" info alert from the draft list.
- Align the fork "Show diff" button to the non-deprecated Button API
  (unifiedSize, onClick, startIcon) so it matches the draft one; mark
  "Discard draft" destructive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: link compare row titles to the item editor

- Render each compare row title (fork and draft) as a link that opens the
  item in a new tab, scoped to the current workspace (raw apps route to
  /apps_raw/edit), matching the AI-session diff drawer: target=_blank, hover
  underline + ExternalLink icon, click stops row-selection propagation.
  Kinds without an editor stay plain; the fork rename markup is preserved.
- Drop the "Kind → name" prefix from draft rows — that arrow reads as the
  rename visual and the kind is already shown by the row icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: clickable rows + multi-select in deploy layout

- Make deploy-layout row cards selectable on click via an opt-in
  `selectOnRowClick` prop on the shared Row (default off, other tables
  unaffected); clicks on the checkbox, title link and action buttons are
  ignored. Adds role/tabindex + Enter/Space keyboard support.
- Support multi-select with modifier keys like classic list pickers:
  Shift+click selects the contiguous range from the anchor row; Cmd/Ctrl
  (and plain) click toggles a single row. select-none avoids text
  highlighting on shift-click.
- Turn the "Select all" text into a <label> associated with its checkbox
  so clicking the text toggles it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: select all drafts by default in draft compare

Drafts now load pre-selected (deploy-all is the common intent); guarded so
a reload after a deploy doesn't re-select the items left behind. Mirrors
CompareWorkspaces' default auto-selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: show diff for draft-only items stored without a draft row

A draft_only flow/script/app whose content lives in the entity row itself
(created via create*(draft_only: true), no separate draft-table row — like
u/admin/new) returns draft == null from get*ByPathWithDraft. getDraftDiffValues
passed that null through, so the diff "after" side was empty and nothing
rendered. Fall back to the row's own value as the draft content when draft is
null (deployDraft already did this), fixing both the compare-page DiffDrawer
and the session bars' WorkspaceDiffDrawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: shared diff button across session bars + bar spacing

- Extract SessionDiffButton (variant=default, ± DiffIcon, count, "Open diff"
  title) and use it for the diff-drawer trigger in both the fork bar and the
  draft bar, so they're identical. Drop the icons from both "Review" buttons.
- Add gap-1 (4px) between the fork bar and draft bar when both are visible
  (flex wrapper; single in-flow root per bar, drawer is portalled — no stray
  gap when only one shows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: deploying a new (draft-only) flow or app

A draft_only flow/app already has an entity row (created via
create*(draft_only: true)), so deployDraft's createFlow/createApp rejected it
with 400 "already exists". Use updateFlow/updateApp instead — a listed draft
always has a row, and update promotes a draft_only entity to a real deployed
version (clearing the flag), like the editor does. Scripts were unaffected
(createScript + parent_hash makes a new version).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: refresh fork comparison after deploying/discarding a draft

Deploying a draft promotes it to the workspace's deployed version, changing
the fork comparison (ahead/behind vs parent) — but the compare page only
re-fetched it on workspace change, so the deploy/update toggle counts and the
CompareWorkspaces tab went stale. CompareDrafts now fires onChanged after a
successful deploy/discard; the page rewires it to refresh the comparison and
draft count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: stop draft-count effect from freezing the AI session page

ensureDraftCount cleared its dedupe key on error; since the caller is a reactive $effect (SessionDraftBar), a persistently-failing countDrafts spun the effect into an infinite retry loop that flooded the console and froze the tab. Claim the key before awaiting and keep it set on failure; refresh*() still forces a re-fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: correct draft count and refresh compare counts after actions

count_drafts now counts deployable drafts (draft_only OR has-a-draft-row across script/flow/app), matching the CompareDrafts list, instead of raw draft-table rows which miss new draft-only items. CompareWorkspaces and CompareDrafts fire onChanged so the compare page re-fetches the comparison and draft count after deploy/update/discard, keeping the toggle badges in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: session draft bar shows a fresh count on every (re)open

The runtime persists across client-side navigation, so the deduped
ensureDraftCount() kept a stale count (e.g. a 0 cached before a draft was
created) when a session was re-opened — the bar stayed hidden even though
the server count was >0.

Force one fresh fetch per mount from a non-reactive onMount via
refreshDraftCount(workspace) (which now takes the workspace so it works
before the dedupe key is set). The reactive $effect keeps using
ensureDraftCount: refreshDraftCount reads loadingDraftCount ($state), so
calling it from an effect would track-and-mutate that state into an
infinite fetch loop — ensureDraftCount's plain-key early-return avoids it.
ensureDraftCount also now releases its key after a 5s backoff on failure so
a transient countDrafts error retries instead of leaving the bar stuck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: make the draft count a single deep Workspace Drafts module

The Draft Count was computed four ways (backend count_drafts SQL, the
CompareDrafts list filter, a bespoke sessionRuntime cache, and the compare
page state) that drifted — the root cause of the unreliable count, the
stale-on-reopen bug, and the effect-loop freeze.

Introduce one module (workspaceDrafts.svelte.ts):
- getDraftItems(ws) lists the deployable Draft Items once; count ≡ list length,
  never a separate query.
- useWorkspaceDrafts(() => ws) is a component-scoped runed resource (fetches on
  mount + ws change, no persistent cache → fresh on every (re)open).
- invalidateWorkspaceDrafts(ws) refreshes mounted consumers; deployDraft/
  discardDraft self-invalidate, so callers never reason about staleness.

Rewire every reader to it (SessionDraftBar, CompareDrafts, DraftDiffDrawer,
WorkspaceDraftsBanner, compare page) and delete the sessionRuntime draftCount
apparatus (key + loading flag + 4 methods + effects + backoff). With no caller
left, remove the count_drafts endpoint (handler, route, openapi, sqlx cache,
generated client) — drafts.rs/openapi return to their main state. Record the
draft vocabulary in CONTEXT.md.

A single GET /w/{ws}/drafts/items endpoint can later replace getDraftItems'
three list calls behind the unchanged seam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: warn before deploying a draft based on an outdated version

When a newer version is deployed while a draft exists (git-sync/CLI deploys preserve drafts via skip_draft_deletion), the compare/deploy-drafts page now flags the draft as Outdated and gates deploy behind an override confirmation with a diff — instead of silently clobbering the newer version. Staleness is read from the draft's base version: scripts already store parent_hash; flows/apps now record a draft_base_version sidecar on save.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: drop redundant /ui_builder proxyRes hack (superseded by #9433)

main's global configure-response-headers plugin now runs with enforce:'pre'
and sets COOP/COEP/CORP on dev responses (#9433), so the per-proxy proxyRes
override is no longer needed. Revert the /ui_builder block to main's headers
form — vite.config.js now matches main with no branch-specific change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(sessions): keep draft count reactive to preview/chat deploys

Invalidate the Workspace Drafts resource at every frontend deploy seam
(ScriptEditorView / FlowEditorView / RawAppEditorView onDeploy + onSaveDraft)
so user-driven deploys from the Preview panel update the count immediately,
and refresh SessionDraftBar on the same coarse signals SessionForkBar uses
(AI turn-end + tab refocus) to cover chat-driven deploys that happen
server-side and never surface as frontend calls.

Also: always show the draft toggle count including (0) on the compare page,
drop the header/content separator line in both compare cards, and derive the
"Deploy N drafts" footer count so it stays reactive after discard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: address branch review findings

- WorkspaceDraftsBanner: use the modern Button API (variant/unifiedSize/onclick)
  instead of the deprecated size/color/on:click triad; drop "pending" from the
  banner copy to match CONTEXT.md vocabulary.
- WorkspaceDeployLayout: make Cmd/Ctrl-click distinct from a plain click.
  Plain row click now selects only that row (classic file-picker), Cmd/Ctrl
  toggles, Shift extends the range, and the checkbox still plain-toggles.
  Adds an onSelectOnly callback wired in CompareDrafts/CompareWorkspaces.
- WorkspaceDiffDrawer: document why the file filter is a raw input (bespoke
  keyboard-nav integration the design-system inputs can't express).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* revert: drop draft version-gating (stale-draft warning)

Remove the "deploying an outdated draft would override a newer version"
guard. It's a rare edge case and will be handled properly by conflict
resolution in a follow-up PR.

- CompareDrafts: drop staleMap/computeStaleness, the TOCTOU pre-deploy
  re-check, the "Outdated" badge, the override-in-diff button, and the
  "Newer version deployed" confirmation modal; deploySelected is now the
  plain deploy.
- utils_draft_deploy: remove getDraftStaleness/DraftStaleness and the
  draft_base_version strip.
- FlowBuilder / AppEditorHeader / RawAppEditorHeader / AppJsonEditor: stop
  injecting draft_base_version into draft saves — these editor paths are
  back to matching main, shrinking the PR's blast radius.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: unify home banner CTAs on the modern Button API

Both the Workspace Drafts banner and the sibling Fork banner now use
variant="default" unifiedSize="sm" onclick, so the two CTAs on the home
page render identically and neither uses the deprecated size/color/on:click
props.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(compare): show draft deploy direction badge inside forks

Mirror the fork compare header's "from → into" badges on the Deploy-draft
tab: "deploy: draft → into: <fork>". Makes it explicit that deploying a
draft promotes it within the fork (deployed↔draft), not up to the parent.
Only rendered inside forks, where the parent could otherwise be confused
with the deploy target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(compare): address PR review — dedup drafts resource + shared link

- De-dupe the compare page Workspace Drafts fetch: the page owns the single
  resource and passes draftItems/draftsLoading into CompareDrafts (was mounting
  a second resource → 6 list calls; now 3).
- Prune transient deploymentStatus for items dropped from the list (no unbounded
  growth, no stale 'deployed' suppressing a re-drafted row).
- Type getDraftItems' list fields via a narrow DraftListEntry (drop Array<any>).
- Clear comparison catch-up timers on unmount (onDestroy).
- Extract shared ExternalEditLink.svelte; use it in CompareDrafts,
  CompareWorkspaces, WorkspaceDiffDrawer (was a near-verbatim <a> block x3).
- Note conflicts intentionally count in both toggle directions; drop a stray
  blank line in sessionRuntime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(compare): show draft summary renames via shared item-summary component

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(compare): address round-2 PR review

- Point the fork-compare edit link at the workspace the item actually lives
  in: a parent-only row (absent in the fork) would 404 if linked into the
  fork, so link it into the parent instead.
- Replace the bespoke raw <button class="underline">Deploy drafts</button> in
  the undeployed-drafts alert with a design-system Button (variant=subtle).
- Drop the stray Prettier reflow in sessionRuntime (restore to match main).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(compare): warn on fork items with a pending draft

In the fork compare list, items that are deployed *and* have a pending
draft (has_draft) now:
- show a yellow "+Draft" badge (AlertTriangle), rendered before the
  New/status badges, with a per-direction tooltip explaining that
  deploying/updating moves the deployed version, not the draft;
- are excluded from the default selection (still manually selectable);
- trigger a confirmation modal if explicitly selected and deployed/updated,
  listing the affected paths.

The signal comes from the page's existing fork drafts resource (a
kind:path Set passed down) — no new fetch, no backend change. Also rename
the undeployed-drafts alert CTA from "Deploy drafts" to "See drafts".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(compare): rename page to "Compare & Deploy"

Update both the page heading (PageHeader) and the browser-tab title.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(compare): multi-select rows by default (no modifier)

In the shared WorkspaceDeployLayout (fork + draft lists), a plain row
click now toggles the item in/out of the selection instead of replacing
the whole selection with it. Removed the modifier-based selection
entirely: the now-dead onSelectOnly path and its two call sites, plus
shift+click range selection (and its anchor/isPickable helpers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(table): don't toggle row selection on keyboard child activation

Row's onkeydown selection handler lacked the interactive-child guard that
handleRowClick already had, so pressing Enter/Space on a checkbox, action
button, or title link both activated the child and toggled the row's
selection. Extract a shared fromInteractiveChild() guard and apply it in
handleRowKeydown, mirroring the click path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(fork-banner): show draft CTA when fork is up to date

When a fork has no changes vs its parent ("Everything is up to date") but
has pending drafts, the banner now mirrors the non-fork drafts banner:
the status text becomes "This workspace has N draft(s)" and the button
becomes "Review & deploy drafts", linking to the compare page in draft
mode. When the fork has real ahead/behind diffs, the existing status and
buttons are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): honor renamed draft paths + raw-app draft fixes

Address the Codex review:
- Draft deploy now uses the draft payload's path for scripts, flows and raw
  apps (keeping the URL path as the existing item key), so a rename in a
  draft deploys to the new path instead of silently staying at the old one.
- DraftDiffDrawer maps raw apps to the `raw_app` kind so their row edit
  links open the raw-app editor, not the legacy app editor.
- ScriptEditorView.restoreDeployed invalidates the workspace drafts after
  deleting the draft, so the session draft-bar count drops immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): guard showDiff race, tree label, mode fallback

Address the cubic review:
- CompareDrafts.showDiff uses a monotonic request token so two quick "Show
  diff" clicks can't let a slow earlier fetch overwrite a faster later one.
- WorkspaceDiffDrawer.buildTree labels a 2-segment path with its leaf name
  (parts[1]) instead of the full scope key.
- The compare page only resolves ?mode=draft immediately; ?mode=fork (and
  an absent mode) defer to the isFork-aware effect, which falls back to
  draft for non-fork workspaces instead of stranding them on the fork UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: remove CONTEXT.md from the PR

Drop the root CONTEXT.md domain glossary and the lone comment pointer to
it in workspaceDrafts.svelte.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): send custom_path on raw-app draft deploy

A raw-app draft that changes or clears its custom route was silently
dropped on deploy from the compare page: updateAppRaw omitted custom_path,
so the backend preserved the old route. Send the draft's custom_path on
update — matching the fork deploy path (which spreads the full app,
custom_path included) and the createAppRaw branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sessions): refresh draft count on raw-app session save-draft

The script/flow session editors invalidate the workspace drafts on
save-draft, but the raw-app editor only did so on deploy. Thread an
onSaveDraft callback through RawAppEditor → RawAppEditorHeader and call
invalidateWorkspaceDrafts from RawAppEditorView, so saving a raw-app draft
in an AI session updates the SessionDraftBar count immediately (and the bar
appears when the count was zero).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): honor renamed paths, draft triggers & paginate inventory

Address the Codex review:
- Draft deploy honors the draft's renamed path for scripts/flows/raw apps
  (keeping the URL path as the existing item key).
- Script/flow draft deploy now deploys draft_triggers via the shared
  deployTriggers, instead of silently dropping them with the draft.
- rawAppDeploy sends custom_path admin-gated on update (admin: value/'' to
  clear; non-admin: undefined) so non-admins don't hit RequireAdmin.
- getDraftItems pages through listScripts/listFlows/listApps so drafts past
  the first page are included in the count, banners, drawer and deploy list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): admin-gate custom_path on visual-app draft deploy

The visual-app branch of deployDraft sent custom_path unconditionally on
updateApp, so a non-admin deploying an app draft for an app with a custom
route hit RequireAdmin. Mirror AppEditorHeader and the raw-app path: admins
send the draft's custom_path ('' clears), non-admins send undefined so the
backend preserves the existing route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(raw-app): save initial draft directly when path is known

In the AI-session preview, a never-deployed raw app has newApp=true but a
known path, so saveDraft opened the "Initial draft save" path-picker drawer
— which is gated on `appPath == ''` and therefore never rendered, making
Save draft silently do nothing. Branch the new-app case on appPath: pick a
path via the drawer only when none is chosen yet; otherwise call
saveInitialDraft() directly. saveInitialDraft now also toasts and fires
onSaveDraft so the session draft-bar count refreshes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(compare): preserve deployed custom_path on visual-app draft deploy

The visual-app draft value usually omits custom_path, so the admin branch's
`d.custom_path ?? ''` sent an empty string, which the backend treats as
"clear the route" — an admin deploying a content-only draft would wipe the
app's existing custom route. Fall back to the deployed route
(`r.custom_path`) when the draft omits it; an explicit '' still clears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-06-08 20:24:23 +02:00
committed by GitHub
parent a3740d571a
commit b0b330c786
27 changed files with 2476 additions and 825 deletions
@@ -0,0 +1,399 @@
<script lang="ts">
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
import DiffDrawer from './DiffDrawer.svelte'
import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte'
import { Badge } from './common'
import Button from './common/button/Button.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import { ArrowRight, DiffIcon, GitFork, Pencil, Undo2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte'
import { editUrlFor } from './sessions/forkEditUrl'
import { AppService, FlowService, ScriptService, type WorkspaceItemDiff } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { getDraftDiffValues, deployDraft, discardDraft } from '$lib/utils_draft_deploy'
import { type DraftItem } from '$lib/workspaceDrafts.svelte'
interface Props {
currentWorkspaceId: string
/** The Draft Items to review, owned by the page's Workspace Drafts resource
* and passed down so we don't mount a second resource (which would double
* the list fetches). Deploy/discard here invalidate that resource, so the
* list refetches upstream and the new `draftItems` flow back in. */
draftItems: DraftItem[]
/** True while the page's Workspace Drafts resource is loading. */
draftsLoading?: boolean
/** Fork context drives the merged toggle: only a fork offers the
* deploy_to/update directions, so the toggle is hidden otherwise. */
isFork?: boolean
parentWorkspaceId?: string
deployCount?: number
updateCount?: number
draftCount?: number
/** Selecting deploy_to/update asks the page to swap to CompareWorkspaces. */
onModeSelected?: (v: CompareMode) => void
/** Fired after a deploy/discard so the page can refresh the *fork*
* comparison (ahead/behind). The Draft Count refreshes itself — deploy/
* discard invalidate the Workspace Drafts resource. */
onChanged?: () => void
}
let {
currentWorkspaceId,
draftItems,
draftsLoading = false,
isFork = false,
parentWorkspaceId,
deployCount = 0,
updateCount = 0,
draftCount = 0,
onModeSelected,
onChanged
}: Props = $props()
type Row = {
kind: DraftItem['kind']
path: string
summary?: string
draft_only: boolean
raw_app: boolean
key: string
}
function getItemKey(kind: string, path: string): string {
return `${kind}:${path}`
}
// The list (and the Draft Count) come from the shared Workspace Drafts module,
// owned by the page and passed in via `draftItems`; deploy/discard invalidate
// that resource, so the list refetches and deployed items drop off without a
// manual reload here.
const items: Row[] = $derived(draftItems.map((d) => ({ ...d, key: getItemKey(d.kind, d.path) })))
// The Draft Items list only carries the *deployed* summary, so the draft's
// (new) display name isn't known yet. Fetch each item's draft blob once and
// cache both names — mirrors CompareWorkspaces' fetchSummaries (eager on load,
// keyed by row key) so the rename rendering is shared and consistent. Only
// non-`draft_only` items can show a rename: a `draft_only` item has no deployed
// side to diff the name against. Raw apps live on a separate route and aren't
// fetchable here, so they're skipped (no rename shown, same as before).
const summaryCache = $state<
Record<string, { deployed?: string; draft?: string; loading?: boolean }>
>({})
async function fetchDraftSummary(item: Row) {
if (summaryCache[item.key]) return
summaryCache[item.key] = { loading: true }
try {
const r = (await (item.kind === 'script'
? ScriptService.getScriptByPathWithDraft({ workspace: currentWorkspaceId, path: item.path })
: item.kind === 'flow'
? FlowService.getFlowByPathWithDraft({ workspace: currentWorkspaceId, path: item.path })
: AppService.getAppByPathWithDraft({
workspace: currentWorkspaceId,
path: item.path
}))) as any
summaryCache[item.key] = {
deployed: r.summary,
draft: (r.draft as any)?.summary,
loading: false
}
} catch (error) {
console.error(`Failed to fetch draft summary for ${item.kind}:${item.path}`, error)
summaryCache[item.key] = { loading: false }
}
}
$effect(() => {
const current = items
untrack(() => {
for (const item of current) {
if (!item.draft_only && !item.raw_app && !summaryCache[item.key]) {
void fetchDraftSummary(item)
}
}
})
})
let selectedItems = $state<string[]>([])
let deploying = $state(false)
// Select all on the first non-empty load (deploy-all is the common intent);
// only once, so a refetch after a deploy doesn't re-select the leftovers.
let hasAutoSelected = $state(false)
const deploymentStatus: Record<
string,
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
> = $state({})
// Prune transient deploy status for items no longer in the list (a deployed
// item drops off after the resource refetches). Keeps the map from growing
// unbounded and avoids a stale 'deployed' entry suppressing a row if the same
// kind:path is re-drafted within this mount.
$effect(() => {
const live = new Set(items.map((i) => i.key))
untrack(() => {
for (const key of Object.keys(deploymentStatus)) {
if (!live.has(key)) delete deploymentStatus[key]
}
})
})
$effect(() => {
if (!hasAutoSelected && items.length > 0) {
selectedItems = items
.filter((i) => deploymentStatus[i.key]?.status !== 'deployed')
.map((i) => i.key)
hasAutoSelected = true
}
})
// Selected items still in the live list and deployable. Derived (not a pruning
// effect) so the "Deploy N drafts" button stays reactive to the Workspace
// Drafts resource: deploy/discard drop items, and stale keys left in
// selectedItems are simply ignored here (and by deploySelected).
let selectedCount = $derived(
items.filter(
(i) => selectedItems.includes(i.key) && deploymentStatus[i.key]?.status !== 'deployed'
).length
)
let allSelected = $derived(
items.length > 0 &&
items
.filter((i) => deploymentStatus[i.key]?.status !== 'deployed')
.every((i) => selectedItems.includes(i.key))
)
function toggleItem(item: { key: string }) {
if (selectedItems.includes(item.key)) {
selectedItems = selectedItems.filter((k) => k !== item.key)
} else {
selectedItems = [...selectedItems, item.key]
}
}
function selectAll() {
selectedItems = items
.filter((i) => deploymentStatus[i.key]?.status !== 'deployed')
.map((i) => i.key)
}
function deselectAll() {
selectedItems = []
}
// --- Diff ---
let diffDrawer: DiffDrawer | undefined = $state(undefined)
let isFlow = $state(false)
// Monotonic token so that two quick "Show diff" clicks don't race: a slower
// earlier fetch must not overwrite a faster later one in the (single) drawer.
let diffRequestId = 0
async function showDiff(item: Row) {
if (!diffDrawer) return
const reqId = ++diffRequestId
isFlow = item.kind === 'flow'
diffDrawer.openDrawer()
const { deployed, draft } = await getDraftDiffValues(
item.kind,
item.path,
currentWorkspaceId,
item.draft_only
)
// A newer Show-diff click superseded this one — drop the stale result.
if (reqId !== diffRequestId) return
diffDrawer.setDiff({
mode: 'simple',
original: deployed as any,
current: draft as any,
title: 'Deployed → Draft'
})
}
// --- Deploy ---
async function deploySelected() {
deploying = true
// Snapshot the items to deploy: deployDraft invalidates the Workspace Drafts
// resource, so `items` can change mid-loop — iterate a stable copy.
const toDeploy = items.filter((i) => selectedItems.includes(i.key))
let deployedAny = false
for (const item of toDeploy) {
deploymentStatus[item.key] = { status: 'loading' }
const res = await deployDraft(
item.kind,
item.path,
currentWorkspaceId,
item.draft_only,
item.raw_app
)
if (res.success) {
deploymentStatus[item.key] = { status: 'deployed' }
deployedAny = true
} else {
deploymentStatus[item.key] = { status: 'failed', error: res.error }
sendUserToast(`Failed to deploy ${item.path}: ${res.error}`, true)
}
}
deploying = false
selectedItems = []
// The Draft list refetches itself (deployDraft invalidated it). Deploying
// also changes the fork comparison (ahead/behind) — ask the page to refresh
// that.
if (deployedAny) onChanged?.()
}
// --- Discard ---
let discardTarget = $state<Row | undefined>(undefined)
async function confirmDiscard() {
const item = discardTarget
discardTarget = undefined
if (!item) return
const res = await discardDraft(item.kind, item.path, currentWorkspaceId, item.draft_only)
if (res.success) {
sendUserToast(item.draft_only ? `Deleted ${item.path}` : `Discarded draft of ${item.path}`)
// discardDraft invalidated the Draft list; refresh the fork comparison.
onChanged?.()
} else {
sendUserToast(`Failed to discard ${item.path}: ${res.error}`, true)
}
}
// Editor URL for a draft item, scoped to the current workspace. Raw apps live
// under a different editor route, so map their kind accordingly.
function draftEditUrl(d: Row): string | undefined {
return editUrlFor(
{ kind: d.raw_app ? 'raw_app' : d.kind, path: d.path } as unknown as WorkspaceItemDiff,
currentWorkspaceId
)
}
</script>
<div class="flex flex-col gap-4">
<div class="bg-surface-tertiary p-4 rounded-md border">
<WorkspaceDeployLayout
{items}
{selectedItems}
{deploymentStatus}
{allSelected}
selectablePredicate={(item) => deploymentStatus[item.key]?.status !== 'deployed'}
onToggleItem={toggleItem}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
emptyMessage={draftsLoading ? 'Loading drafts…' : 'No drafts in this workspace'}
>
{#snippet header()}
{#if isFork}
<div class="flex flex-wrap gap-1 items-center bg-surface-tertiary pb-4">
<CompareModeToggle
selected="draft"
{isFork}
{parentWorkspaceId}
{deployCount}
{updateCount}
{draftCount}
disabled={deploying}
onSelected={(v) => onModeSelected?.(v)}
/>
<!-- Direction badge, mirroring the fork compare header: make it explicit
that deploying a draft promotes it *within this fork* (deployed↔draft),
not up to the parent. -->
<div class="flex-1 flex gap-1 items-center">
<Badge color="transparent" class="ml-5 font-semibold">
<span class="text-secondary">deploy:</span>
<Pencil size={14} />
<span class="text-emphasis">draft</span>
</Badge>
<ArrowRight size={16} />
<Badge color="transparent" class="font-semibold" title={currentWorkspaceId}>
<span class="text-secondary">into:</span>
<GitFork size={14} />
<span class="text-emphasis">{currentWorkspaceId}</span>
</Badge>
</div>
</div>
{/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}
<WorkspaceDeployItemSummary
path={draftItem.path}
{editUrl}
{oldSummary}
{newSummary}
renamed={!draftItem.draft_only &&
oldSummary != null &&
newSummary != null &&
oldSummary !== newSummary}
/>
{/snippet}
{#snippet itemActions(item)}
{@const draftItem = item as unknown as Row}
{#if draftItem.draft_only}
<Badge color="indigo" size="xs">New</Badge>
{/if}
{#if deploymentStatus[draftItem.key]?.status !== 'deployed'}
<Button
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: DiffIcon }}
onClick={() => showDiff(draftItem)}
>
Show diff
</Button>
<Button
unifiedSize="xs"
variant="subtle"
destructive
startIcon={{ icon: Undo2 }}
onClick={() => (discardTarget = draftItem)}
>
Discard draft
</Button>
{/if}
{/snippet}
{#snippet footer()}
<div class="flex items-center justify-end">
<Button
variant="accent"
disabled={selectedCount === 0 || deploying}
loading={deploying}
onClick={deploySelected}
>
Deploy {selectedCount} draft{selectedCount !== 1 ? 's' : ''}
</Button>
</div>
{/snippet}
</WorkspaceDeployLayout>
</div>
<DiffDrawer bind:this={diffDrawer} {isFlow} />
</div>
<ConfirmationModal
open={discardTarget !== undefined}
title={discardTarget?.draft_only ? 'Delete item' : 'Discard draft'}
confirmationText={discardTarget?.draft_only ? 'Delete' : 'Discard'}
onConfirmed={confirmDiscard}
onCanceled={() => (discardTarget = undefined)}
>
{#if discardTarget?.draft_only}
<p>
<span class="font-mono font-medium text-primary">{discardTarget?.path}</span> exists only as a
draft. Discarding it will permanently delete the item. This cannot be undone.
</p>
{:else}
<p>
Discard the draft of
<span class="font-mono font-medium text-primary">{discardTarget?.path}</span>? The deployed
version is unaffected.
</p>
{/if}
</ConfirmationModal>
@@ -0,0 +1,61 @@
<script lang="ts" module>
// The merged compare control: fork direction (deploy_to / update) and the
// deployed↔draft comparison live in one toggle. `deploy_to`/`update` only
// apply in a fork; a non-fork workspace has draft as the sole option (the
// caller hides the toggle entirely in that case).
export type CompareMode = 'deploy_to' | 'update' | 'draft'
</script>
<script lang="ts">
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { ArrowUp, ArrowDown, Pencil } from 'lucide-svelte'
interface Props {
selected: CompareMode
isFork: boolean
parentWorkspaceId?: string
deployCount?: number
updateCount?: number
draftCount?: number
disabled?: boolean
onSelected: (v: CompareMode) => void
}
let {
selected,
isFork,
parentWorkspaceId,
deployCount = 0,
updateCount = 0,
draftCount = 0,
disabled = false,
onSelected
}: Props = $props()
// Append a count suffix only when there is something to act on, mirroring the
// draft toggle (no "(0)" noise).
function withCount(label: string, count: number): string {
return count > 0 ? `${label} (${count})` : label
}
</script>
<ToggleButtonGroup {disabled} {selected} onSelected={(v) => onSelected(v as CompareMode)} noWFull>
{#snippet children({ item })}
{#if isFork}
<ToggleButton
value="deploy_to"
label={withCount(`Deploy to ${parentWorkspaceId}`, deployCount)}
icon={ArrowUp}
{item}
/>
<ToggleButton
value="update"
label={withCount('Update current', updateCount)}
icon={ArrowDown}
{item}
/>
{/if}
<ToggleButton value="draft" label={`Deploy draft (${draftCount})`} icon={Pencil} {item} />
{/snippet}
</ToggleButtonGroup>
@@ -1,10 +1,8 @@
<script lang="ts">
import {
AlertTriangle,
ArrowDown,
ArrowDownRight,
ArrowRight,
ArrowUp,
ArrowUpRight,
Building,
CircleCheck,
@@ -29,7 +27,9 @@
type WorkspaceItemDiff
} from '$lib/gen'
import Button from './common/button/Button.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import DiffDrawer from './DiffDrawer.svelte'
import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
@@ -54,22 +54,58 @@
import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte'
import { userStore } from '$lib/stores'
import { base } from '$lib/base'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte'
import { editUrlFor } from './sessions/forkEditUrl'
import DatatableSchemaDiff from './DatatableSchemaDiff.svelte'
interface Props {
currentWorkspaceId: string
parentWorkspaceId: string
comparison: WorkspaceComparison | undefined
/** Initial merge direction; lets the page restore the chosen direction when
* switching back from draft mode (deploy_to → true, update → false). */
initialMergeIntoParent?: boolean
/** Per-direction counts for the merged toggle badges (the page owns them). */
deployCount?: number
updateCount?: number
/** Draft count for the merged toggle's badge (the page owns it). */
draftCount?: number
/** Keys (`kind:path`) of fork items that are deployed *and* have a pending
* draft (has_draft). Such rows get a "+Draft" warning badge and are left
* out of the default selection — deploying/updating moves the deployed
* version, not the draft. The page derives this from the fork drafts. */
draftKeys?: Set<string>
/** Selecting `draft` asks the page to swap us out for CompareDrafts;
* deploy_to/update are handled internally but reported so the page can
* remember the direction. */
onModeSelected?: (v: CompareMode) => void
/** Fired after a deploy/update so the page re-fetches the comparison and
* draft count, keeping the toggle badges in sync with the new state. */
onChanged?: () => void
}
let { currentWorkspaceId, parentWorkspaceId, comparison }: Props = $props()
let {
currentWorkspaceId,
parentWorkspaceId,
comparison,
initialMergeIntoParent = true,
deployCount = 0,
updateCount = 0,
draftCount = 0,
draftKeys = new Set<string>(),
onModeSelected,
onChanged
}: Props = $props()
// A fork row has a pending draft when its key is in the page-provided set.
function hasDraft(diff: WorkspaceItemDiff): boolean {
return draftKeys.has(getItemKey(diff))
}
let currentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == currentWorkspaceId))
let parentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == parentWorkspaceId))
let mergeIntoParent = $state(true)
let mergeIntoParent = $state(initialMergeIntoParent)
let deploying = $state(false)
let hasAutoSelected = $state(false)
let canDeployToParent = $state(true)
@@ -89,6 +125,32 @@
let selectedItems = $state<string[]>([])
// Selected items that carry a pending draft. They're opt-in (excluded from the
// default selection), so a non-empty list means the user explicitly picked an
// item whose draft won't be included — confirm before deploying.
let selectedDraftKeys = $derived(selectedItems.filter((k) => draftKeys.has(k)))
let draftConfirmOpen = $state(false)
function requestDeploy() {
if (selectedDraftKeys.length > 0) {
draftConfirmOpen = true
} else {
deployChanges()
}
}
// Nothing actionable in the current direction (no items ahead to deploy, or
// none behind to update). When so we show a message instead of a table of
// greyed, non-actionable rows.
let nothingToAct = $derived(selectableDiffs.length === 0)
let emptyDeployMessage = $derived(
(comparison?.diffs.length ?? 0) === 0
? 'No changes between this fork and its parent.'
: mergeIntoParent
? `Nothing to deploy — ${parentWorkspaceId} already has every change from this fork.`
: `Nothing to update — this fork is up to date with ${parentWorkspaceId}.`
)
let conflictingDiffs = $derived(
comparison?.diffs.filter((diff) => diff.ahead > 0 && diff.behind > 0) ?? []
)
@@ -419,6 +481,10 @@
console.error('Failed to close open deployment request after merge', e)
}
}
// Deployed items are now in sync and should drop off the comparison; ask
// the page to re-fetch so the list and toggle badges reflect the new state.
onChanged?.()
}
function toggleKey(key: string) {
@@ -434,7 +500,9 @@
// parent (kafka group_id, postgres replication slot, schedule firing time)
// and pushing them by default would surprise users running a routine "Deploy
// to parent" flow. The user picks them à la carte by clicking the row.
const filtered = selectableDiffs.filter((d) => !isTriggerOrScheduleKind(d.kind))
// Items with a pending draft are also left out by default: the deployed
// version (not the draft) is what moves, so we make the user opt in.
const filtered = selectableDiffs.filter((d) => !isTriggerOrScheduleKind(d.kind) && !hasDraft(d))
const conflictSafe = mergeIntoParent
? filtered
: filtered.filter((d) => !(d.ahead > 0 && d.behind > 0))
@@ -449,6 +517,15 @@
selectDefault()
}
// Merged toggle: deploy_to/update flip the direction in place; draft asks the
// page to swap us out for CompareDrafts. Either way report it up so the page
// remembers the chosen direction across mode switches.
function onToggleMode(v: CompareMode) {
onModeSelected?.(v)
if (v === 'draft') return
toggleDeploymentDirection(v)
}
// Fetch user permissions for both workspaces
$effect(() => {
;[currentWorkspaceId, parentWorkspaceId]
@@ -601,7 +678,7 @@
<div class="flex flex-col gap-4">
<div class="bg-surface-tertiary p-4 rounded-md border">
<WorkspaceDeployLayout
items={deployableItems}
items={nothingToAct ? [] : deployableItems}
{selectedItems}
{deploymentStatus}
selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)}
@@ -609,28 +686,22 @@
onToggleItem={(item) => toggleKey(item.key)}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
emptyMessage="No comparison data available"
emptyMessage={emptyDeployMessage}
>
{#snippet header()}
<div class="flex items-center justify-between bg-surface-tertiary">
<div class="flex flex-col gap-2 w-full pb-4 border-b">
<div class="flex flex-col gap-2 w-full pb-4">
<div class="flex flex-wrap gap-1 items-center">
<ToggleButtonGroup
<CompareModeToggle
selected={mergeIntoParent ? 'deploy_to' : 'update'}
isFork={true}
{parentWorkspaceId}
{deployCount}
{updateCount}
{draftCount}
disabled={deploying}
selected="deploy_to"
onSelected={toggleDeploymentDirection}
noWFull
>
{#snippet children({ item })}
<ToggleButton
value="deploy_to"
label="Deploy to {parentWorkspaceId}"
icon={ArrowUp}
{item}
/>
<ToggleButton value="update" label="Update current" icon={ArrowDown} {item} />
{/snippet}
</ToggleButtonGroup>
onSelected={onToggleMode}
/>
{#if currentWorkspaceInfo && parentWorkspaceInfo}
<div class="flex-1 flex gap-1 items-center">
<Badge
@@ -719,6 +790,24 @@
{/if}
{#snippet alerts()}
{#if draftCount > 0}
<Alert title="Undeployed drafts" type="warning" size="xs" class="my-2">
<div class="flex items-center gap-2 flex-wrap">
<span>
{#if mergeIntoParent}
This workspace has {draftCount} undeployed draft{draftCount !== 1 ? 's' : ''}.
Only deployed versions in this fork can be sent to {parentWorkspaceId} — deploy
{draftCount !== 1 ? 'them' : 'it'} first, otherwise those changes won't be included.
{:else}
This workspace has {draftCount} undeployed draft{draftCount !== 1 ? 's' : ''}.
{/if}
</span>
<Button variant="subtle" unifiedSize="xs" onclick={() => onModeSelected?.('draft')}>
See drafts
</Button>
</div>
</Alert>
{/if}
{#if mergeIntoParent}
<ParentWorkspaceProtectionAlert
{parentWorkspaceId}
@@ -780,6 +869,13 @@
{#snippet itemSummary(item)}
{@const diff = item.diff as WorkspaceItemDiff}
{@const key = item.key}
<!-- Point the edit link at the workspace the item actually lives in:
a parent-only row (deleted/absent in the fork) would 404 if linked
into the fork, so link it into the parent instead. -->
{@const editUrl = editUrlFor(
diff,
diff.exists_in_fork ? currentWorkspaceId : parentWorkspaceId
)}
{#if isTriggerOrScheduleKind(diff.kind)}
<span class="text-emphasis">
{KIND_DISPLAY_NAMES[diff.kind as string] ?? diff.kind}
@@ -798,14 +894,13 @@
(diff.exists_in_fork && !diff.exists_in_source) ||
(!diff.exists_in_fork && diff.exists_in_source)
)}
{#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
<span class="line-through text-secondary">{oldSummary || diff.path}</span>
{newSummary || diff.path}
{:else if !existsInBothWorkspaces}
{newSummary || oldSummary || diff.path}
{:else}
{newSummary || diff.path}
{/if}
<WorkspaceDeployItemSummary
path={diff.path}
{editUrl}
{oldSummary}
{newSummary}
renamed={oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
/>
{/if}
{/snippet}
@@ -836,6 +931,21 @@
{#if diff.kind === 'raw_app'}
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
{#if hasDraft(diff)}
<!-- This deployed fork item also has a pending draft. Deploying/updating
moves the deployed version, not the draft — so we warn (yellow, ahead
of the New/status badges) and leave it out of the default selection
(see selectDefault). -->
<Badge
title={mergeIntoParent
? 'This item has a draft — deploying sends the deployed version, not the draft.'
: 'This item has a draft — updating replaces the deployed version your draft is based on.'}
color="yellow"
size="xs"
>
<AlertTriangle class="w-3 h-3 inline mr-0.5" />+Draft
</Badge>
{/if}
<!-- Status badges -->
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
<Badge
@@ -898,11 +1008,11 @@
</div>
<div class:invisible={!existsInBothWorkspaces}>
<Button
size="xs"
unifiedSize="xs"
variant="subtle"
onclick={() => showDiff(diff.kind as Kind, diff.path)}
startIcon={{ icon: DiffIcon }}
onClick={() => showDiff(diff.kind as Kind, diff.path)}
>
<DiffIcon class="w-3 h-3" />
Show diff
</Button>
</div>
@@ -910,63 +1020,65 @@
{/snippet}
{#snippet footer()}
<div class="flex items-center justify-between">
<div></div>
{#if !nothingToAct}
<div class="flex items-center justify-between">
<div></div>
<div class="flex flex-col items-end gap-2">
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
<div class="flex items-center gap-2">
{#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
<Button
variant="default"
startIcon={{ icon: UserPlus }}
on:click={() => deploymentRequestPanel?.openRequestDialog()}
>
Request deployment
</Button>
{/if}
<Button
variant="accent"
disabled={selectedItems.length === 0 ||
deploying ||
(hasBehindChanges && !allowBehindChangesOverride) ||
(mergeIntoParent && !canDeployToParent) ||
hasUnselectedOnBehalfOf}
loading={deploying}
on:click={deployChanges}
>
{mergeIntoParent ? 'Deploy' : 'Update'}
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
{#if selectedConflicts != 0}
({selectedConflicts} conflicts)
<div class="flex flex-col items-end gap-2">
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
<div class="flex items-center gap-2">
{#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
<Button
variant="default"
startIcon={{ icon: UserPlus }}
on:click={() => deploymentRequestPanel?.openRequestDialog()}
>
Request deployment
</Button>
{/if}
</Button>
</div>
{#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf}
<span class="text-xs text-yellow-600">
You must set the "on behalf of" user for all items before deploying
<Tooltip class="text-yellow-600">
The "run on behalf of" field defines which user's permissions will be applied
during execution. Make sure this is set to an appropriate user before
deploying.
</Tooltip>
</span>
<Button
variant="accent"
disabled={selectedItems.length === 0 ||
deploying ||
(hasBehindChanges && !allowBehindChangesOverride) ||
(mergeIntoParent && !canDeployToParent) ||
hasUnselectedOnBehalfOf}
loading={deploying}
on:click={requestDeploy}
>
{mergeIntoParent ? 'Deploy' : 'Update'}
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
{#if selectedConflicts != 0}
({selectedConflicts} conflicts)
{/if}
</Button>
</div>
{#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf}
<span class="text-xs text-yellow-600">
You must set the "on behalf of" user for all items before deploying
<Tooltip class="text-yellow-600">
The "run on behalf of" field defines which user's permissions will be
applied during execution. Make sure this is set to an appropriate user
before deploying.
</Tooltip>
</span>
{/if}
{/if}
{/if}
{#if deploymentErrorMessage != ''}
<Alert
title="Cannot {mergeIntoParent ? 'deploy these changes' : 'update these items'}"
type="error"
class="my-2 max-w-80"
>
<span>
{deploymentErrorMessage}
</span>
</Alert>
{/if}
{#if deploymentErrorMessage != ''}
<Alert
title="Cannot {mergeIntoParent ? 'deploy these changes' : 'update these items'}"
type="error"
class="my-2 max-w-80"
>
<span>
{deploymentErrorMessage}
</span>
</Alert>
{/if}
</div>
</div>
</div>
{/if}
{/snippet}
</WorkspaceDeployLayout>
@@ -988,6 +1100,35 @@
</div>
<DiffDrawer bind:this={diffDrawer} {isFlow} />
<ConfirmationModal
open={draftConfirmOpen}
title={mergeIntoParent ? 'Deploy items with a draft?' : 'Update items with a draft?'}
confirmationText={mergeIntoParent ? 'Deploy anyway' : 'Update anyway'}
onConfirmed={() => {
draftConfirmOpen = false
deployChanges()
}}
onCanceled={() => (draftConfirmOpen = false)}
>
<div class="flex flex-col gap-2">
<p>
{selectedDraftKeys.length} selected item{selectedDraftKeys.length !== 1 ? 's' : ''}
{selectedDraftKeys.length !== 1 ? 'have' : 'has'} an undeployed draft.
{#if mergeIntoParent}
Deploying sends the deployed version, not the draft — those draft changes won't be
included.
{:else}
Updating replaces the deployed version your draft is based on.
{/if}
</p>
<ul class="list-disc pl-5 text-sm font-mono text-secondary">
{#each selectedDraftKeys as k (k)}
<li>{k.split(':').slice(1).join(':')}</li>
{/each}
</ul>
</div>
</ConfirmationModal>
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-gray-500">No comparison data available</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import { ExternalLink } from 'lucide-svelte'
import type { Snippet } from 'svelte'
// Shared "open in a new tab" link used by the compare/diff row titles
// (CompareDrafts, CompareWorkspaces, WorkspaceDiffDrawer). Wraps the common
// boilerplate — target/rel, the click-through stopPropagation (so following
// the link doesn't toggle row selection), and the hover-revealed external
// icon. Each caller supplies its own title text, extra classes, and inner
// label content via the `children` snippet.
let {
href,
title,
class: klass = '',
children
}: {
href: string
title?: string
class?: string
children: Snippet
} = $props()
</script>
<a
{href}
{title}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="group inline-flex items-center gap-1 max-w-full hover:underline {klass}"
>
{@render children()}
<ExternalLink class="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-60 transition-opacity" />
</a>
@@ -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.</span
>
{:else if showDraftsOnly}
<span class="text-blue-700 dark:text-blue-100">
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
</span>
{:else}
<span class="text-blue-600 dark:text-blue-200"> Everything is up to date </span>
{/if}
@@ -278,8 +308,14 @@
</div>
<div class="flex items-center gap-2">
<Button size="xs" color="blue" on:click={openComparisonDrawer}>
{#if (comparison?.summary.total_ahead ?? 0) > 0}
<Button
variant="default"
unifiedSize="sm"
onclick={showDraftsOnly ? openDraftCompare : openComparisonDrawer}
>
{#if showDraftsOnly}
Review & deploy drafts
{:else if (comparison?.summary.total_ahead ?? 0) > 0}
Review & Deploy Changes
{:else}
Review & Update fork
@@ -0,0 +1,43 @@
<script lang="ts">
import ExternalEditLink from './ExternalEditLink.svelte'
interface Props {
/** Path of the item, used as the fallback label and link title. */
path: string
/** Editor URL; when set the summary becomes a new-tab link. */
editUrl?: string
/** Deployed/source-side display name (struck through when renamed). */
oldSummary?: string
/** Draft/target-side display name (the surviving name when renamed). */
newSummary?: string
/** Render `~~oldSummary~~ newSummary`. The caller decides this from its
* own concepts (fork: exists-in-both & selectable; draft: !draft_only),
* keeping page-specific logic out of this presentational component. */
renamed: boolean
}
let { path, editUrl, oldSummary, newSummary, renamed }: Props = $props()
</script>
{#snippet label()}
{#if renamed}
<!-- Two names side by side: don't truncate, mirror the fork compare page. -->
<span class="line-through text-secondary">{oldSummary || path}</span>
{newSummary || path}
{:else}
<span class="truncate">{newSummary || oldSummary || path}</span>
{/if}
{/snippet}
{#if editUrl}
<!-- Truncate the single-name case; let a rename pair render full-width. -->
<ExternalEditLink
href={editUrl}
title="Open {path} in a new tab"
class={renamed ? 'text-emphasis' : 'text-emphasis truncate'}
>
{@render label()}
</ExternalEditLink>
{:else}
{@render label()}
{/if}
@@ -55,6 +55,12 @@
let selectableItems = $derived(items.filter(selectablePredicate))
let hasSelectableItems = $derived(selectableItems.length > 0)
// Plain row click and the checkbox both toggle this row in/out — multi-select
// is the default, no modifier needed.
function handleSelect(item: DeployableItem) {
onToggleItem?.(item)
}
</script>
<div class="flex flex-col h-full">
@@ -73,9 +79,10 @@
{#if items.length > 0}
<!-- Select all row -->
<div class="px-4 py-2 flex items-center justify-between">
<div
<label
class="flex items-center gap-2 text-secondary text-xs"
class:opacity-50={!hasSelectableItems}
class:cursor-pointer={hasSelectableItems}
>
<input
type="checkbox"
@@ -84,7 +91,7 @@
onchange={allSelected ? onDeselectAll : onSelectAll}
class="rounded max-w-4 w-full"
/> Select all
</div>
</label>
</div>
<!-- Items list -->
@@ -98,10 +105,11 @@
<Row
isSelectable={isSelectable && !isDeployed}
selectOnRowClick={true}
alignWithSelectable={true}
disabled={!isSelectable}
selected={isSelected && !isDeployed}
onSelect={() => onToggleItem?.(item)}
onSelect={() => handleSelect(item)}
path={item.kind !== 'resource' &&
item.kind !== 'variable' &&
item.kind !== 'resource_type'
@@ -0,0 +1,48 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import { Pencil } from 'lucide-svelte'
import { goto } from '$app/navigation'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
// Surfaces pending drafts (scripts/flows/apps) for the current workspace and
// links to the compare page in draft mode. Mutually exclusive with
// ForkWorkspaceBanner: that one self-gates on `isFork`, this one on `!isFork`,
// so a fork workspace never shows both. In a fork, drafts are discovered via
// the on-page "Deployed ↔ draft (N)" toggle badge instead.
let isFork = $derived($workspaceStore?.startsWith('wm-fork-') ?? false)
// Count comes from the shared Workspace Drafts resource (count ≡ the draft
// list; refreshes itself on deploy/discard). Pass undefined in a fork or with
// no workspace so it doesn't fetch and the banner stays hidden.
const drafts = useWorkspaceDrafts(() => (!isFork ? ($workspaceStore ?? undefined) : undefined))
const draftCount = $derived(drafts.count)
function openDraftCompare() {
if ($workspaceStore) {
goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore) + '&mode=draft', {
replaceState: true
})
}
}
</script>
{#if !isFork && draftCount > 0}
<div class="w-full bg-blue-50 dark:bg-blue-900 text-xs rounded-b-md max-w-7xl mx-auto">
<div class="px-4 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Pencil class="w-4 h-4 text-accent" />
<span class="text-sm font-medium text-blue-900 dark:text-blue-100">
This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''}
</span>
</div>
<!-- Same button as the sibling ForkWorkspaceBanner CTA (they sit on the
same home page), kept visually identical on purpose. -->
<Button variant="default" unifiedSize="sm" onclick={openDraftCompare}>
Review & deploy drafts
</Button>
</div>
</div>
</div>
{/if}
@@ -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 })
}
</script>
{#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}
<input type="checkbox" checked={selected} onchange={onSelect} class="rounded max-w-4 w-full" />
@@ -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-['']"
>
<pre
class="overflow-auto whitespace-pre-wrap text-xs max-h-60">{buildError}</pre>
<pre class="overflow-auto whitespace-pre-wrap text-xs max-h-60"
>{buildError}</pre
>
</Alert>
</div>
{/if}
@@ -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
@@ -0,0 +1,82 @@
<script lang="ts">
import WorkspaceDiffDrawer, { type DiffRow } from './WorkspaceDiffDrawer.svelte'
import { Pencil } from 'lucide-svelte'
import { type WorkspaceItemDiff } from '$lib/gen'
import { userWorkspaces } from '$lib/stores'
import { editUrlFor as buildEditUrl } from './forkEditUrl'
import { getDraftDiffValues, type DraftKind } from '$lib/utils_draft_deploy'
import { getDraftItems } from '$lib/workspaceDrafts.svelte'
// Thin wrapper: supplies the deployed ↔ draft data source (server `draft`
// table, same as the compare page) to the generic WorkspaceDiffDrawer.
// Read-only, mirroring ForkDiffDrawer; deploy/discard live on the Review page.
let { workspaceId }: { workspaceId: string } = $props()
let inner: WorkspaceDiffDrawer | undefined = $state(undefined)
let rows: DiffRow[] = $state([])
let loading = $state(false)
let error: string | undefined = $state(undefined)
// draft_only per item — drives the "added" rendering (empty before).
let draftOnlyByKey: Record<string, boolean> = $state({})
const ws = $derived($userWorkspaces.find((w) => w.id === workspaceId))
export function open() {
void fetchDrafts()
inner?.open()
}
async function fetchDrafts() {
loading = true
error = undefined
try {
// One source of truth (Workspace Drafts) — same list the compare page and
// the count use.
const items = await getDraftItems(workspaceId)
const donly: Record<string, boolean> = {}
rows = items.map((it) => {
// Raw apps must surface as `raw_app` so the row's edit link points at the
// raw-app editor (mirrors CompareDrafts); `getDraftItems` carries the flag.
const kind = it.raw_app ? 'raw_app' : it.kind
donly[`${kind}/${it.path}`] = it.draft_only
return { kind, path: it.path, status: it.draft_only ? 'added' : 'modified' }
})
draftOnlyByKey = donly
} catch (e) {
console.error('Draft diff: list failed', e)
error = `Failed to load drafts: ${e}`
rows = []
} finally {
loading = false
}
}
async function loadValues(d: DiffRow): Promise<{ before: unknown; after: unknown }> {
const draftOnly = draftOnlyByKey[`${d.kind}/${d.path}`] ?? false
// getDraftDiffValues works on the draft_type kind ('app' for raw apps too).
const kind: DraftKind = d.kind === 'raw_app' ? 'app' : (d.kind as DraftKind)
const { deployed, draft } = await getDraftDiffValues(kind, d.path, workspaceId, draftOnly)
// draft_only items have never been deployed → render as "added" (empty
// before), matching how the fork drawer renders added items.
return { before: draftOnly ? undefined : deployed, after: draft }
}
</script>
<WorkspaceDiffDrawer
bind:this={inner}
diffs={rows}
{loadValues}
{loading}
{error}
emptyMessage="No drafts in this workspace."
title="Drafts"
reviewHref={`/forks/compare?workspace_id=${encodeURIComponent(workspaceId)}&mode=draft`}
editUrlFor={(d) => buildEditUrl(d as unknown as WorkspaceItemDiff, workspaceId)}
>
{#snippet titleExtra()}
<div class="flex items-center gap-2 text-xs text-secondary">
<Pencil class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium truncate">{ws?.name ?? workspaceId}</span>
</div>
{/snippet}
</WorkspaceDiffDrawer>
@@ -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}
@@ -1,84 +1,71 @@
<script lang="ts">
import { parentFolderKey } from './forkDiffNav'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import WorkspaceDiffDrawer, { type DiffRow } from './WorkspaceDiffDrawer.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import {
AlertTriangle,
ArrowRight,
ChevronDown,
ChevronRight,
Folder,
GitFork,
GitMerge,
Loader2,
Minus,
Pencil,
Plus,
User
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import WorkspaceItemDiffViewer from '$lib/components/WorkspaceItemDiffViewer.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, ExternalLink, SquareSplitHorizontal } from 'lucide-svelte'
import { AlertTriangle, ArrowRight, GitFork } from 'lucide-svelte'
import { WorkspaceService, type WorkspaceComparison, type WorkspaceItemDiff } from '$lib/gen'
import { getItemValue } from '$lib/utils_workspace_deploy'
import { userWorkspaces } from '$lib/stores'
import { editUrlFor as buildEditUrl } from './forkEditUrl'
// Thin wrapper: supplies the deployed-parent ↔ deployed-fork data source to
// the generic WorkspaceDiffDrawer. Display/behavior unchanged from before.
let {
forkWorkspaceId,
parentWorkspaceId
}: { forkWorkspaceId: string; parentWorkspaceId: string } = $props()
let drawer: Drawer | undefined = $state(undefined)
let inner: WorkspaceDiffDrawer | undefined = $state(undefined)
let comparison: WorkspaceComparison | undefined = $state(undefined)
let loading = $state(false)
let error: string | undefined = $state(undefined)
let searchQuery = $state('')
let diffStyle = $state<'sbs' | 'inline'>('sbs')
const inlineDiff = $derived(diffStyle === 'inline')
const forkWs = $derived($userWorkspaces.find((w) => w.id === forkWorkspaceId))
const parentWs = $derived($userWorkspaces.find((w) => w.id === parentWorkspaceId))
export function open() {
drawer?.openDrawer()
void fetchComparison()
// Pull focus into the filter input so keyboard nav works without an
// extra click — drawer transition needs a tick first.
setTimeout(() => searchInputEl?.focus(), 50)
function statusOf(d: WorkspaceItemDiff): DiffRow['status'] {
if (d.exists_in_fork && !d.exists_in_source) return 'added'
if (!d.exists_in_fork && d.exists_in_source) return 'removed'
if (d.ahead > 0 && d.behind > 0) return 'conflict'
return 'modified'
}
function openReview() {
goto(`/forks/compare?workspace_id=${encodeURIComponent(forkWorkspaceId)}`)
// Keep the original diffs by key so loadValues can honor exists flags
// (skip the non-existent side for added/removed items).
let diffByKey: Record<string, WorkspaceItemDiff> = $state({})
const rows = $derived.by<DiffRow[]>(() =>
(comparison?.diffs ?? []).map((d) => ({
kind: d.kind,
path: d.path,
status: statusOf(d),
ahead: d.ahead,
behind: d.behind
}))
)
function skipNotice(c: WorkspaceComparison | undefined): string | undefined {
return c?.skipped_comparison
? 'This fork was created before change tracking was added — diffs are not available.'
: undefined
}
const notice = $derived(skipNotice(comparison))
export function open() {
void fetchComparison()
inner?.open()
}
async function fetchComparison() {
loading = true
error = undefined
// Per-item raw diffs are cached for the lifetime of the drawer.
// `loadDiffFor` early-returns on cache hit, so without this reset an
// edit-then-reopen would show fresh summary/counts but stale expanded
// raw content for any item the user had already drilled into.
loadedDiffs = {}
summaries = {}
try {
comparison = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: forkWorkspaceId
})
// Diffs are expanded by default, so eagerly populate each row's
// content. Each loadDiffFor is idempotent and per-item, so
// rendering proceeds as values arrive.
if (comparison) {
for (const d of comparison.diffs) {
void loadDiffFor(d)
}
}
diffByKey = Object.fromEntries(
(comparison?.diffs ?? []).map((d) => [`${d.kind}/${d.path}`, d])
)
} catch (e) {
console.error('Fork diff: comparison failed', e)
error = `Failed to load comparison: ${e}`
@@ -88,645 +75,51 @@
}
}
type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict'
function statusOf(d: WorkspaceItemDiff): DiffStatus {
if (d.exists_in_fork && !d.exists_in_source) return 'added'
if (!d.exists_in_fork && d.exists_in_source) return 'removed'
if (d.ahead > 0 && d.behind > 0) return 'conflict'
return 'modified'
}
function itemKey(d: WorkspaceItemDiff): string {
return `${d.kind}/${d.path}`
}
// Editor URL for a diff row, scoped to the fork workspace.
function editUrlFor(d: WorkspaceItemDiff): string | undefined {
return buildEditUrl(d, forkWorkspaceId)
}
const KIND_LABELS: Record<string, string> = {
script: 'Script',
flow: 'Flow',
app: 'App',
raw_app: 'Raw app',
resource: 'Resource',
variable: 'Variable',
resource_type: 'Resource type',
folder: 'Folder',
schedule: 'Schedule',
http_trigger: 'HTTP route',
websocket_trigger: 'Websocket trigger',
kafka_trigger: 'Kafka trigger',
nats_trigger: 'NATS trigger',
postgres_trigger: 'Postgres trigger',
mqtt_trigger: 'MQTT trigger',
sqs_trigger: 'SQS trigger',
gcp_trigger: 'GCP trigger',
azure_trigger: 'Azure trigger',
email_trigger: 'Email trigger'
}
// Lazily-loaded raw values per item, keyed by itemKey. Shaping (content
// vs metadata, YAML, lang detection) is owned by WorkspaceItemDiffViewer.
type LoadedDiff = {
state: 'loading' | 'ready' | 'error'
error?: string
parentRaw?: unknown
forkRaw?: unknown
}
let loadedDiffs: Record<string, LoadedDiff> = $state({})
// Per-item summary, derived from the fetched raw value so the tree on
// the left can show summary above the mono path (matches the picker).
let summaries: Record<string, string | undefined> = $state({})
async function loadDiffFor(d: WorkspaceItemDiff) {
const key = itemKey(d)
if (loadedDiffs[key]) return
loadedDiffs[key] = { state: 'loading' }
try {
// Source (parent) — empty for items only in fork. Fork — empty for
// items only in source. We swallow per-side errors so an "added"
// item still renders cleanly against an empty original.
const [parentRaw, forkRaw] = await Promise.all([
d.exists_in_source
? getItemValue(d.kind, d.path, parentWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined),
d.exists_in_fork
? getItemValue(d.kind, d.path, forkWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined)
])
loadedDiffs[key] = { state: 'ready', parentRaw, forkRaw }
// Prefer the fork's summary (the "current" side); fall back to parent.
const summary =
(forkRaw && typeof forkRaw === 'object' && (forkRaw as any).summary) ||
(parentRaw && typeof parentRaw === 'object' && (parentRaw as any).summary) ||
undefined
if (typeof summary === 'string' && summary.trim().length > 0) {
summaries[key] = summary
}
} catch (e) {
console.error('Fork diff: loadDiff failed', d, e)
loadedDiffs[key] = {
state: 'error',
error: String(e)
}
}
}
function onDetailsToggle(d: WorkspaceItemDiff, e: Event) {
const target = e.currentTarget as HTMLDetailsElement | null
if (target?.open) {
void loadDiffFor(d)
}
}
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' {
if (s === 'added') return 'green'
if (s === 'removed') return 'red'
if (s === 'conflict') return 'orange'
return 'blue'
}
const statusIcons = {
added: Plus,
removed: Minus,
modified: Pencil,
conflict: AlertTriangle
}
// File tree built from the diff paths. Top-level rows mirror
// WorkspaceItemDrillPicker: `f/foo` and `u/alice` collapse to a single
// "scope" row, then deeper segments split per `/`. Leaves carry their
// diff entry.
type FolderNode = {
type: 'folder'
name: string
fullPath: string
isScope: boolean
children: TreeNode[]
}
type FileNode = { type: 'file'; name: string; diff: WorkspaceItemDiff }
type TreeNode = FolderNode | FileNode
function buildTree(diffs: WorkspaceItemDiff[]): FolderNode {
const root: FolderNode = {
type: 'folder',
name: '',
fullPath: '',
isScope: false,
children: []
}
const folderCache = new Map<string, FolderNode>()
for (const d of diffs) {
const parts = d.path.split('/')
if (parts.length < 2) {
root.children.push({ type: 'file', name: d.path, diff: d })
continue
}
const scopeKey = parts.slice(0, 2).join('/')
let scope = folderCache.get(scopeKey)
if (!scope) {
scope = {
type: 'folder',
name: scopeKey,
fullPath: scopeKey,
isScope: true,
children: []
}
folderCache.set(scopeKey, scope)
root.children.push(scope)
}
if (parts.length === 2) {
scope.children.push({ type: 'file', name: scopeKey, diff: d })
continue
}
const rest = parts.slice(2)
let parent = scope
let folderKey = scopeKey
for (let i = 0; i < rest.length - 1; i++) {
folderKey = `${folderKey}/${rest[i]}`
let folder = folderCache.get(folderKey)
if (!folder) {
folder = {
type: 'folder',
name: rest[i],
fullPath: folderKey,
isScope: false,
children: []
}
folderCache.set(folderKey, folder)
parent.children.push(folder)
}
parent = folder
}
parent.children.push({ type: 'file', name: rest[rest.length - 1], diff: d })
}
const sortRec = (n: FolderNode) => {
n.children.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return a.name.localeCompare(b.name)
})
for (const c of n.children) if (c.type === 'folder') sortRec(c)
}
sortRec(root)
return root
}
// Searchable string per diff: path + summary (when loaded) + kind label.
// SearchItems' uFuzzy runs fuzzy matching over these. Reads `summaries`
// directly so the index re-derives as summaries trickle in from
// loadDiffFor.
function searchableText(d: WorkspaceItemDiff): string {
const parts = [d.path, KIND_LABELS[d.kind] ?? d.kind]
const s = summaries[itemKey(d)]
if (s) parts.push(s)
return parts.join(' ')
}
let searchedDiffs: (WorkspaceItemDiff & { marked?: string })[] | undefined = $state(undefined)
// Empty query bypasses SearchItems entirely so we don't wait a tick for
// the async filter to run after open.
const filteredDiffs = $derived.by(() => {
const c = comparison
if (!c) return [] as WorkspaceItemDiff[]
const q = searchQuery.trim()
if (!q) return c.diffs
return (searchedDiffs ?? []) as WorkspaceItemDiff[]
})
const tree = $derived.by(() => {
const c = comparison
return c ? buildTree(filteredDiffs) : undefined
})
function rowId(d: WorkspaceItemDiff): string {
return `fork-diff-${itemKey(d)}`
}
function scrollToDiff(d: WorkspaceItemDiff) {
const el = document.getElementById(rowId(d)) as HTMLDetailsElement | null
if (!el) return
el.open = true
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// ── Keyboard nav (matches WorkspaceItemDrillPicker) ─────────────────────
// Per-folder open/closed state. Defaults to open; user toggles via the
// <details> summary or via Enter when a folder row is highlighted.
let folderOpen: Record<string, boolean> = $state({})
function isFolderOpen(key: string): boolean {
return folderOpen[key] ?? true
}
function folderKey(node: FolderNode): string {
return `folder:${node.fullPath}`
}
type NavEntry =
| { type: 'folder'; key: string; node: FolderNode }
| { type: 'file'; key: string; diff: WorkspaceItemDiff }
function flattenVisible(node: FolderNode): NavEntry[] {
const out: NavEntry[] = []
const walk = (n: TreeNode) => {
if (n.type === 'file') {
out.push({ type: 'file', key: itemKey(n.diff), diff: n.diff })
return
}
const fkey = folderKey(n)
out.push({ type: 'folder', key: fkey, node: n })
if (isFolderOpen(fkey)) for (const c of n.children) walk(c)
}
for (const c of node.children) walk(c)
return out
}
const navEntries = $derived(tree ? flattenVisible(tree) : [])
const navKeys = $derived(navEntries.map((e) => e.key))
const entryByKey = $derived(new Map(navEntries.map((e) => [e.key, e])))
let highlightedKey: string | undefined = $state(undefined)
let mouseActive = $state(false)
let searchInputEl: HTMLInputElement | undefined = $state()
let sidebarRoot: HTMLElement | undefined = $state()
$effect(() => {
if (navKeys.length === 0) return
if (!highlightedKey || !navKeys.includes(highlightedKey)) {
highlightedKey = navKeys[0]
}
})
function scrollHighlightIntoView() {
if (!sidebarRoot || !highlightedKey) return
const el = sidebarRoot.querySelector<HTMLElement>(
`[data-nav-key="${CSS.escape(highlightedKey)}"]`
)
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
function moveHighlight(delta: 1 | -1) {
if (navKeys.length === 0) return
const cur = navKeys.indexOf(highlightedKey ?? '')
const next = cur < 0 ? 0 : (cur + delta + navKeys.length) % navKeys.length
highlightedKey = navKeys[next]
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function setHoverHighlight(key: string) {
// Same defense as the picker: ignore until the user actually moves the
// mouse, so a cursor parked over a row doesn't clobber the keyboard
// highlight when the layout shifts.
if (mouseActive) highlightedKey = key
}
function activateHighlighted() {
if (!highlightedKey) return
const entry = entryByKey.get(highlightedKey)
if (!entry) return
if (entry.type === 'file') {
scrollToDiff(entry.diff)
} else {
folderOpen[entry.key] = !isFolderOpen(entry.key)
}
}
// The folder containing an entry, as a folder key (or undefined if the
// entry is at the top scope and has no parent folder). Pure logic lives in
// forkDiffNav.parentFolderKey (unit-tested).
function parentFolderKeyFor(entry: NavEntry): string | undefined {
const path = entry.type === 'folder' ? entry.node.fullPath : entry.diff.path
return parentFolderKey(entry.type, path)
}
function firstChildKey(node: FolderNode): string | undefined {
const c = node.children[0]
if (!c) return undefined
return c.type === 'folder' ? folderKey(c) : itemKey(c.diff)
}
function selectKey(key: string) {
highlightedKey = key
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
moveHighlight(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
moveHighlight(-1)
} else if (e.key === 'Enter') {
e.preventDefault()
activateHighlighted()
} else if (e.key === 'ArrowRight') {
// On a closed folder: open it. On an open folder: jump to its first
// child (folder or file). On a file: no-op.
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry || entry.type !== 'folder') return
if (!isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = true
return
}
const child = firstChildKey(entry.node)
if (child) {
e.preventDefault()
selectKey(child)
}
} else if (e.key === 'ArrowLeft') {
// On an open folder: collapse it. On a closed folder (or a file):
// jump to the parent folder.
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry) return
if (entry.type === 'folder' && isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = false
return
}
const parent = parentFolderKeyFor(entry)
if (parent && entryByKey.has(parent)) {
e.preventDefault()
selectKey(parent)
}
}
async function loadValues(d: DiffRow): Promise<{ before: unknown; after: unknown }> {
const od = diffByKey[`${d.kind}/${d.path}`]
const existsSource = od ? od.exists_in_source !== false : true
const existsFork = od ? od.exists_in_fork !== false : true
const [before, after] = await Promise.all([
existsSource
? getItemValue(d.kind as any, d.path, parentWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined),
existsFork
? getItemValue(d.kind as any, d.path, forkWorkspaceId).catch(() => undefined)
: Promise.resolve(undefined)
])
return { before, after }
}
</script>
<SearchItems
filter={searchQuery}
items={comparison?.diffs ?? []}
bind:filteredItems={searchedDiffs}
f={(d: WorkspaceItemDiff) => 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}
<details
{open}
ontoggle={(e) => (folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)}
class="select-none"
>
<summary
role="option"
aria-selected={isHl}
data-nav-key={fkey}
onmouseenter={() => 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"
>
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary tree-chevron-open" />
<ChevronRight class="w-3 h-3 shrink-0 text-tertiary tree-chevron-closed" />
{#if isUserScope}
<User size={12} class="shrink-0 text-tertiary" />
{:else}
<Folder size={12} class="shrink-0 text-tertiary" />
{/if}
<span class="truncate" title={node.name}>{node.name}</span>
</summary>
<div>
{#each node.children as child}
{@render renderTreeNode(child, depth + 1)}
{/each}
</div>
</details>
{:else}
{@const status = statusOf(node.diff)}
{@const key = itemKey(node.diff)}
<WorkspaceItemRow
kind={node.diff.kind}
summary={summaries[key]}
secondary={node.name}
highlighted={key === highlightedKey}
navKey={key}
indent={depth * 12 + 20}
title={node.diff.path}
onclick={() => {
highlightedKey = key
scrollToDiff(node.diff)
}}
onmouseenter={() => setHoverHighlight(key)}
>
{#snippet extras()}
<span
class="w-1.5 h-1.5 rounded-full shrink-0 {status === 'added'
? 'bg-green-500'
: status === 'removed'
? 'bg-red-500'
: status === 'conflict'
? 'bg-orange-500'
: 'bg-blue-500'}"
></span>
{/snippet}
</WorkspaceItemRow>
{/if}
{/snippet}
<Drawer bind:this={drawer} size="1200px">
<DrawerContent
title="Fork changes"
on:close={() => drawer?.closeDrawer()}
documentationLink={undefined}
noPadding
overflow_y={false}
>
{#snippet titleExtra()}
<div class="flex items-center gap-2 text-xs text-secondary">
<GitFork class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium truncate">{forkWs?.name ?? forkWorkspaceId}</span>
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="font-medium truncate">{parentWs?.name ?? parentWorkspaceId}</span>
{#if comparison}
<Badge color="transparent" class="ml-2">
{comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''}
<WorkspaceDiffDrawer
bind:this={inner}
diffs={rows}
{loadValues}
{loading}
{error}
{notice}
emptyMessage="No changes between this fork and its parent."
title="Fork changes"
reviewHref={`/forks/compare?workspace_id=${encodeURIComponent(forkWorkspaceId)}`}
editUrlFor={(d) => buildEditUrl(d as unknown as WorkspaceItemDiff, forkWorkspaceId)}
>
{#snippet titleExtra()}
<div class="flex items-center gap-2 text-xs text-secondary">
<GitFork class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium truncate">{forkWs?.name ?? forkWorkspaceId}</span>
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="font-medium truncate">{parentWs?.name ?? parentWorkspaceId}</span>
{#if comparison}
<Badge color="transparent" class="ml-2">
{comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''}
</Badge>
{#if comparison.summary.conflicts > 0}
<Badge color="orange">
<AlertTriangle class="w-3 h-3 inline mr-1" />
{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''}
</Badge>
{#if comparison.summary.conflicts > 0}
<Badge color="orange">
<AlertTriangle class="w-3 h-3 inline mr-1" />
{comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''}
</Badge>
{/if}
{/if}
</div>
{/snippet}
{#snippet actions()}
<ToggleButtonGroup bind:selected={diffStyle} noWFull>
{#snippet children({ item })}
<ToggleButton
value="sbs"
label="Side-by-side"
icon={SquareSplitHorizontal}
tooltip="Side-by-side diff"
iconOnly
{item}
/>
<ToggleButton
value="inline"
label="Unified"
icon={DiffIcon}
tooltip="Unified diff"
iconOnly
{item}
/>
{/snippet}
</ToggleButtonGroup>
<Button variant="accent" unifiedSize="sm" startIcon={{ icon: GitMerge }} onclick={openReview}>
Review
</Button>
{/snippet}
<div class="flex flex-row h-full min-h-0">
{#if comparison && comparison.diffs.length > 0}
<aside
bind:this={sidebarRoot}
onmousemove={() => (mouseActive = true)}
class="flex-none w-56 border-r border-light flex flex-col min-h-0"
>
<div class="px-3 pt-3 pb-2 shrink-0">
<input
bind:this={searchInputEl}
type="search"
bind:value={searchQuery}
placeholder="Filter files..."
onkeydown={handleSearchKeydown}
class="w-full text-xs px-2 py-1 rounded border border-light bg-surface focus:outline-none focus:border-accent"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-3 flex flex-col gap-1">
{#if tree && tree.children.length > 0}
{#each tree.children as child}
{@render renderTreeNode(child, 0)}
{/each}
{:else}
<div class="text-2xs text-tertiary px-3 py-2">No matches</div>
{/if}
</div>
</aside>
{/if}
<main class="flex-1 min-w-0 overflow-y-auto">
<div class="px-3 pt-3 pb-4 flex flex-col gap-3">
{#if loading && !comparison}
<div class="flex items-center gap-2 text-sm text-secondary py-8 self-center">
<Loader2 class="w-4 h-4 animate-spin" />
Loading comparison...
</div>
{:else if error}
<div class="text-sm text-red-600 dark:text-red-400 py-4">{error}</div>
{:else if comparison?.skipped_comparison}
<div class="text-sm text-secondary py-4">
This fork was created before change tracking was added — diffs are not available.
</div>
{:else if comparison && comparison.diffs.length === 0}
<div class="text-sm text-secondary py-4"
>No changes between this fork and its parent.</div
>
{:else if comparison && filteredDiffs.length === 0}
<div class="text-sm text-secondary py-4">No files match "{searchQuery}".</div>
{:else if comparison}
<div class="flex flex-col gap-2">
{#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)}
<details
open
id={rowId(d)}
class="border border-light rounded-md bg-surface scroll-mt-2"
ontoggle={(e) => onDetailsToggle(d, e)}
>
<summary
class="sticky top-0 z-30 bg-surface flex items-center gap-2 px-3 py-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden border-b border-transparent rounded-md relative before:content-[''] before:absolute before:inset-0 before:bg-surface-hover before:opacity-0 before:pointer-events-none before:transition-opacity hover:before:opacity-100"
>
<ChevronDown
class="w-3.5 h-3.5 shrink-0 text-tertiary transition-transform chevron"
/>
<RowIcon kind={d.kind} size={14} />
<div class="min-w-0 flex-1">
{#if editUrl}
<a
href={editUrl}
target="_blank"
rel="noopener noreferrer"
title={d.path}
onclick={(e) => e.stopPropagation()}
class="group inline-flex items-center gap-1 max-w-full text-xs text-primary font-mono truncate hover:underline"
>
<span class="truncate">{d.path}</span>
<ExternalLink
class="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-60 transition-opacity"
/>
</a>
{:else}
<div class="text-xs text-primary font-mono truncate" title={d.path}>
{d.path}
</div>
{/if}
</div>
<div class="shrink-0 flex items-center gap-2">
{#if d.ahead > 0}
<span class="text-2xs text-secondary">{d.ahead} ahead</span>
{/if}
{#if d.behind > 0}
<span class="text-2xs text-secondary">{d.behind} behind</span>
{/if}
<Badge color={statusBadgeColor(status)}>
<StatusIcon class="w-3 h-3 inline mr-0.5" />
{status}
</Badge>
</div>
</summary>
<div
class="border-t border-light bg-surface-tertiary rounded-b-md overflow-hidden"
>
{#if !loaded || loaded.state === 'loading'}
<div class="flex items-center gap-2 text-xs text-secondary p-3">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
Loading diff…
</div>
{:else if loaded.state === 'error'}
<div class="text-xs text-red-600 dark:text-red-400">{loaded.error}</div>
{:else if loaded.state === 'ready'}
<WorkspaceItemDiffViewer
kind={d.kind}
originalRaw={loaded.parentRaw}
currentRaw={loaded.forkRaw}
{inlineDiff}
/>
{/if}
</div>
</details>
{/each}
</div>
{/if}
</div></main
></div
>
</DrawerContent>
</Drawer>
<style>
/* Diff rows use a ChevronDown; rotate it back when collapsed. */
details:not([open]) :global(.chevron) {
transform: rotate(-90deg);
}
/* Tree folder rows: swap chevrons based on the folder's open state. */
details:not([open]) > .tree-summary :global(.tree-chevron-open) {
display: none;
}
details[open] > .tree-summary :global(.tree-chevron-closed) {
display: none;
}
</style>
</div>
{/snippet}
</WorkspaceDiffDrawer>
@@ -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"
@@ -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}
@@ -0,0 +1,24 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { DiffIcon } from 'lucide-svelte'
// Shared diff-drawer trigger used by both the fork bar and the draft bar: a
// default button showing the ± diff icon and the item count.
let {
count,
onclick,
title = 'Open diff',
disabled = false
}: { count: number; onclick: () => void; title?: string; disabled?: boolean } = $props()
</script>
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: DiffIcon }}
{disabled}
{title}
{onclick}
>
{count}
</Button>
@@ -0,0 +1,70 @@
<script lang="ts">
import { Pencil } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { goto } from '$lib/navigation'
import { sessionState, type Session } from './sessionState.svelte'
import { getRuntime } from './sessionRuntime.svelte'
import DraftDiffDrawer from './DraftDiffDrawer.svelte'
import SessionDiffButton from './SessionDiffButton.svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
let { session }: { session: Session } = $props()
// Only meaningful once the session committed to a workspace (post first send).
// The Draft Count comes from the shared Workspace Drafts resource: it fetches
// on mount and whenever a Server-Draft mutation invalidates the workspace, so
// the count is fresh on every (re)open with no per-session caching.
const committedId = $derived(session.workspace_id)
const drafts = useWorkspaceDrafts(() => committedId)
const count = $derived(drafts.count)
// Deploys/saves from the Preview editor invalidate the workspace directly
// (ScriptEditorView / FlowEditorView / RawAppEditorView call
// invalidateWorkspaceDrafts). The chat itself can also deploy items, but
// those happen server-side and the frontend never sees the individual calls —
// so refresh on the same coarse signals the fork bar uses: the AI turn ending
// and the tab regaining visibility.
const runtime = $derived(getRuntime(session.id))
let wasLoading = $state(false)
$effect(() => {
const isLoading = runtime?.manager.loading ?? false
if (wasLoading && !isLoading) drafts.refresh()
wasLoading = isLoading
})
$effect(() => {
if (!committedId) return
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
if (sessionState.currentSessionId !== session.id) return
drafts.refresh()
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => document.removeEventListener('visibilitychange', onVisibilityChange)
})
let drawer: DraftDiffDrawer | undefined = $state(undefined)
function openReview() {
if (!committedId) return
goto(`/forks/compare?workspace_id=${encodeURIComponent(committedId)}&mode=draft`)
}
</script>
{#if committedId && count > 0}
<div
class="flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md bg-surface-tertiary"
>
<div class="flex items-center gap-1.5 min-w-0">
<span class="inline-flex shrink-0"><Pencil class="w-3.5 h-3.5 text-secondary" /></span>
<span class="truncate text-secondary">{count} draft{count === 1 ? '' : 's'}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<SessionDiffButton {count} onclick={() => drawer?.open()} />
<Button variant="default" unifiedSize="xs" onclick={openReview}>Review</Button>
</div>
</div>
<DraftDiffDrawer bind:this={drawer} workspaceId={committedId} />
{/if}
@@ -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 @@
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: Diff }}
<SessionDiffButton
count={totalDiffs}
disabled={totalDiffs === 0}
title="{totalDiffs} modified item{totalDiffs === 1 ? '' : 's'}"
onclick={() => diffDrawer?.open()}
>
{totalDiffs}
</Button>
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: GitMerge }}
onclick={openReview}
>
Review
</Button>
/>
<Button variant="default" unifiedSize="xs" onclick={openReview}>Review</Button>
</div>
</div>
@@ -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}
<SessionWorkspaceBar {session} />
{/if}
<SessionForkBar
{session}
onMove={(workspaceId) => moveAndActivate(workspaceId)}
onCreateForkAndMove={(fork) => createForkAndMove(fork)}
onArchive={() => archiveAndReset()}
onDelete={() => (deleteConfirmOpen = true)}
/>
<!-- gap-1 (4px) spaces the fork bar and draft bar when both are visible.
Each bar renders a single in-flow root (or nothing); the draft drawer
is position:fixed, so it doesn't count as a flex item — no stray gap
when only one bar shows. -->
<div class="flex flex-col gap-1">
<SessionForkBar
{session}
onMove={(workspaceId) => moveAndActivate(workspaceId)}
onCreateForkAndMove={(fork) => createForkAndMove(fork)}
onArchive={() => archiveAndReset()}
onDelete={() => (deleteConfirmOpen = true)}
/>
<SessionDraftBar {session} />
</div>
{/snippet}
<!-- Override the chat's default keyboard-shortcut hint with nothing —
@@ -0,0 +1,638 @@
<script lang="ts" module>
export type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict'
// One changed item. `status` drives the dot/badge; `ahead`/`behind` are
// optional (fork-only) and only render when present.
export type DiffRow = {
kind: string
path: string
status: DiffStatus
ahead?: number
behind?: number
}
</script>
<script lang="ts">
import { parentFolderKey } from './forkDiffNav'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Folder,
GitMerge,
Loader2,
Minus,
Pencil,
Plus,
User
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import WorkspaceItemDiffViewer from '$lib/components/WorkspaceItemDiffViewer.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, SquareSplitHorizontal } from 'lucide-svelte'
import { untrack, type Snippet } from 'svelte'
import ExternalEditLink from '../ExternalEditLink.svelte'
// Read-only, multi-item before/after diff viewer with a file tree. The data
// source (fork comparison vs deployed-vs-draft) is supplied by the parent
// wrapper via `diffs` + `loadValues`; everything here is generic rendering.
let {
diffs,
loadValues,
loading = false,
error = undefined,
notice = undefined,
emptyMessage = 'No changes.',
title,
reviewHref,
reviewLabel = 'Review',
editUrlFor = undefined,
titleExtra
}: {
diffs: DiffRow[]
loadValues: (d: DiffRow) => Promise<{ before: unknown; after: unknown }>
loading?: boolean
error?: string | undefined
/** Replaces the diff list with an informational message (e.g. fork
* created before change tracking). */
notice?: string | undefined
emptyMessage?: string
title: string
reviewHref: string
reviewLabel?: string
editUrlFor?: (d: DiffRow) => string | undefined
titleExtra?: Snippet
} = $props()
let drawer: Drawer | undefined = $state(undefined)
let searchQuery = $state('')
let diffStyle = $state<'sbs' | 'inline'>('sbs')
const inlineDiff = $derived(diffStyle === 'inline')
export function open() {
// Reset per-item caches so an edit-then-reopen doesn't show stale
// expanded content. The wrapper re-fetches `diffs`, which re-triggers
// the eager-load effect below.
loadedDiffs = {}
summaries = {}
drawer?.openDrawer()
setTimeout(() => searchInputEl?.focus(), 50)
}
function itemKey(d: DiffRow): string {
return `${d.kind}/${d.path}`
}
const KIND_LABELS: Record<string, string> = {
script: 'Script',
flow: 'Flow',
app: 'App',
raw_app: 'Raw app',
resource: 'Resource',
variable: 'Variable',
resource_type: 'Resource type',
folder: 'Folder',
schedule: 'Schedule',
http_trigger: 'HTTP route',
websocket_trigger: 'Websocket trigger',
kafka_trigger: 'Kafka trigger',
nats_trigger: 'NATS trigger',
postgres_trigger: 'Postgres trigger',
mqtt_trigger: 'MQTT trigger',
sqs_trigger: 'SQS trigger',
gcp_trigger: 'GCP trigger',
azure_trigger: 'Azure trigger',
email_trigger: 'Email trigger'
}
type LoadedDiff = {
state: 'loading' | 'ready' | 'error'
error?: string
before?: unknown
after?: unknown
}
let loadedDiffs: Record<string, LoadedDiff> = $state({})
let summaries: Record<string, string | undefined> = $state({})
async function loadDiffFor(d: DiffRow) {
const key = itemKey(d)
if (loadedDiffs[key]) return
loadedDiffs[key] = { state: 'loading' }
try {
const { before, after } = await loadValues(d)
loadedDiffs[key] = { state: 'ready', before, after }
const summary =
(after && typeof after === 'object' && (after as any).summary) ||
(before && typeof before === 'object' && (before as any).summary) ||
undefined
if (typeof summary === 'string' && summary.trim().length > 0) {
summaries[key] = summary
}
} catch (e) {
console.error('WorkspaceDiffDrawer: loadValues failed', d, e)
loadedDiffs[key] = { state: 'error', error: String(e) }
}
}
// Eagerly load each row (diffs render expanded). Tracks `diffs` only; the
// cache reads/writes are untracked so this doesn't loop on itself.
$effect(() => {
const ds = diffs
untrack(() => {
for (const d of ds) void loadDiffFor(d)
})
})
function onDetailsToggle(d: DiffRow, e: Event) {
const target = e.currentTarget as HTMLDetailsElement | null
if (target?.open) void loadDiffFor(d)
}
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' {
if (s === 'added') return 'green'
if (s === 'removed') return 'red'
if (s === 'conflict') return 'orange'
return 'blue'
}
const statusIcons = { added: Plus, removed: Minus, modified: Pencil, conflict: AlertTriangle }
// ── File tree ───────────────────────────────────────────────────────────
type FolderNode = {
type: 'folder'
name: string
fullPath: string
isScope: boolean
children: TreeNode[]
}
type FileNode = { type: 'file'; name: string; diff: DiffRow }
type TreeNode = FolderNode | FileNode
function buildTree(rows: DiffRow[]): FolderNode {
const root: FolderNode = {
type: 'folder',
name: '',
fullPath: '',
isScope: false,
children: []
}
const folderCache = new Map<string, FolderNode>()
for (const d of rows) {
const parts = d.path.split('/')
if (parts.length < 2) {
root.children.push({ type: 'file', name: d.path, diff: d })
continue
}
const scopeKey = parts.slice(0, 2).join('/')
let scope = folderCache.get(scopeKey)
if (!scope) {
scope = { type: 'folder', name: scopeKey, fullPath: scopeKey, isScope: true, children: [] }
folderCache.set(scopeKey, scope)
root.children.push(scope)
}
if (parts.length === 2) {
scope.children.push({ type: 'file', name: parts[1], diff: d })
continue
}
const rest = parts.slice(2)
let parent = scope
let fkey = scopeKey
for (let i = 0; i < rest.length - 1; i++) {
fkey = `${fkey}/${rest[i]}`
let folder = folderCache.get(fkey)
if (!folder) {
folder = { type: 'folder', name: rest[i], fullPath: fkey, isScope: false, children: [] }
folderCache.set(fkey, folder)
parent.children.push(folder)
}
parent = folder
}
parent.children.push({ type: 'file', name: rest[rest.length - 1], diff: d })
}
const sortRec = (n: FolderNode) => {
n.children.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return a.name.localeCompare(b.name)
})
for (const c of n.children) if (c.type === 'folder') sortRec(c)
}
sortRec(root)
return root
}
function searchableText(d: DiffRow): string {
const parts = [d.path, KIND_LABELS[d.kind] ?? d.kind]
const s = summaries[itemKey(d)]
if (s) parts.push(s)
return parts.join(' ')
}
let searchedDiffs: (DiffRow & { marked?: string })[] | undefined = $state(undefined)
const filteredDiffs = $derived.by(() => {
const q = searchQuery.trim()
if (!q) return diffs
return (searchedDiffs ?? []) as DiffRow[]
})
const tree = $derived(buildTree(filteredDiffs))
function rowId(d: DiffRow): string {
return `ws-diff-${itemKey(d)}`
}
function scrollToDiff(d: DiffRow) {
const el = document.getElementById(rowId(d)) as HTMLDetailsElement | null
if (!el) return
el.open = true
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// ── Keyboard nav (matches WorkspaceItemDrillPicker) ─────────────────────
let folderOpen: Record<string, boolean> = $state({})
function isFolderOpen(key: string): boolean {
return folderOpen[key] ?? true
}
function folderKey(node: FolderNode): string {
return `folder:${node.fullPath}`
}
type NavEntry =
| { type: 'folder'; key: string; node: FolderNode }
| { type: 'file'; key: string; diff: DiffRow }
function flattenVisible(node: FolderNode): NavEntry[] {
const out: NavEntry[] = []
const walk = (n: TreeNode) => {
if (n.type === 'file') {
out.push({ type: 'file', key: itemKey(n.diff), diff: n.diff })
return
}
const fkey = folderKey(n)
out.push({ type: 'folder', key: fkey, node: n })
if (isFolderOpen(fkey)) for (const c of n.children) walk(c)
}
for (const c of node.children) walk(c)
return out
}
const navEntries = $derived(flattenVisible(tree))
const navKeys = $derived(navEntries.map((e) => e.key))
const entryByKey = $derived(new Map(navEntries.map((e) => [e.key, e])))
let highlightedKey: string | undefined = $state(undefined)
let mouseActive = $state(false)
let searchInputEl: HTMLInputElement | undefined = $state()
let sidebarRoot: HTMLElement | undefined = $state()
$effect(() => {
if (navKeys.length === 0) return
if (!highlightedKey || !navKeys.includes(highlightedKey)) {
highlightedKey = navKeys[0]
}
})
function scrollHighlightIntoView() {
if (!sidebarRoot || !highlightedKey) return
const el = sidebarRoot.querySelector<HTMLElement>(
`[data-nav-key="${CSS.escape(highlightedKey)}"]`
)
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
function moveHighlight(delta: 1 | -1) {
if (navKeys.length === 0) return
const cur = navKeys.indexOf(highlightedKey ?? '')
const next = cur < 0 ? 0 : (cur + delta + navKeys.length) % navKeys.length
highlightedKey = navKeys[next]
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function setHoverHighlight(key: string) {
if (mouseActive) highlightedKey = key
}
function activateHighlighted() {
if (!highlightedKey) return
const entry = entryByKey.get(highlightedKey)
if (!entry) return
if (entry.type === 'file') {
scrollToDiff(entry.diff)
} else {
folderOpen[entry.key] = !isFolderOpen(entry.key)
}
}
function parentFolderKeyFor(entry: NavEntry): string | undefined {
const path = entry.type === 'folder' ? entry.node.fullPath : entry.diff.path
return parentFolderKey(entry.type, path)
}
function firstChildKey(node: FolderNode): string | undefined {
const c = node.children[0]
if (!c) return undefined
return c.type === 'folder' ? folderKey(c) : itemKey(c.diff)
}
function selectKey(key: string) {
highlightedKey = key
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
moveHighlight(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
moveHighlight(-1)
} else if (e.key === 'Enter') {
e.preventDefault()
activateHighlighted()
} else if (e.key === 'ArrowRight') {
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry || entry.type !== 'folder') return
if (!isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = true
return
}
const child = firstChildKey(entry.node)
if (child) {
e.preventDefault()
selectKey(child)
}
} else if (e.key === 'ArrowLeft') {
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry) return
if (entry.type === 'folder' && isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = false
return
}
const parent = parentFolderKeyFor(entry)
if (parent && entryByKey.has(parent)) {
e.preventDefault()
selectKey(parent)
}
}
}
</script>
<SearchItems
filter={searchQuery}
items={diffs}
bind:filteredItems={searchedDiffs}
f={(d: DiffRow) => 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}
<details
{open}
ontoggle={(e) => (folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)}
class="select-none"
>
<summary
role="option"
aria-selected={isHl}
data-nav-key={fkey}
onmouseenter={() => 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"
>
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary tree-chevron-open" />
<ChevronRight class="w-3 h-3 shrink-0 text-tertiary tree-chevron-closed" />
{#if isUserScope}
<User size={12} class="shrink-0 text-tertiary" />
{:else}
<Folder size={12} class="shrink-0 text-tertiary" />
{/if}
<span class="truncate" title={node.name}>{node.name}</span>
</summary>
<div>
{#each node.children as child}
{@render renderTreeNode(child, depth + 1)}
{/each}
</div>
</details>
{:else}
{@const status = node.diff.status}
{@const key = itemKey(node.diff)}
<WorkspaceItemRow
kind={node.diff.kind as any}
summary={summaries[key]}
secondary={node.name}
highlighted={key === highlightedKey}
navKey={key}
indent={depth * 12 + 20}
title={node.diff.path}
onclick={() => {
highlightedKey = key
scrollToDiff(node.diff)
}}
onmouseenter={() => setHoverHighlight(key)}
>
{#snippet extras()}
<span
class="w-1.5 h-1.5 rounded-full shrink-0 {status === 'added'
? 'bg-green-500'
: status === 'removed'
? 'bg-red-500'
: status === 'conflict'
? 'bg-orange-500'
: 'bg-blue-500'}"
></span>
{/snippet}
</WorkspaceItemRow>
{/if}
{/snippet}
<Drawer bind:this={drawer} size="1200px">
<DrawerContent
{title}
{titleExtra}
on:close={() => drawer?.closeDrawer()}
documentationLink={undefined}
noPadding
overflow_y={false}
>
{#snippet actions()}
<ToggleButtonGroup bind:selected={diffStyle} noWFull>
{#snippet children({ item })}
<ToggleButton
value="sbs"
label="Side-by-side"
icon={SquareSplitHorizontal}
tooltip="Side-by-side diff"
iconOnly
{item}
/>
<ToggleButton
value="inline"
label="Unified"
icon={DiffIcon}
tooltip="Unified diff"
iconOnly
{item}
/>
{/snippet}
</ToggleButtonGroup>
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: GitMerge }}
onclick={() => goto(reviewHref)}
>
{reviewLabel}
</Button>
{/snippet}
<div class="flex flex-row h-full min-h-0">
{#if diffs.length > 0}
<aside
bind:this={sidebarRoot}
onmousemove={() => (mouseActive = true)}
class="flex-none w-56 border-r border-light flex flex-col min-h-0"
>
<div class="px-3 pt-3 pb-2 shrink-0">
<!-- Raw input (not the design-system TextInput) on purpose: this is a
bespoke filter wired to the file tree's keyboard navigation — it
needs a direct element ref to focus (the `/` shortcut) and a
keydown handler that hands ArrowDown/Enter off to the tree.
TextInput/ClearableInput swallow/rebubble those and don't expose
the element ref. -->
<input
bind:this={searchInputEl}
type="search"
bind:value={searchQuery}
placeholder="Filter files..."
onkeydown={handleSearchKeydown}
class="w-full text-xs px-2 py-1 rounded border border-light bg-surface focus:outline-none focus:border-accent"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-3 flex flex-col gap-1">
{#if tree.children.length > 0}
{#each tree.children as child}
{@render renderTreeNode(child, 0)}
{/each}
{:else}
<div class="text-2xs text-tertiary px-3 py-2">No matches</div>
{/if}
</div>
</aside>
{/if}
<main class="flex-1 min-w-0 overflow-y-auto">
<div class="px-3 pt-3 pb-4 flex flex-col gap-3">
{#if loading && diffs.length === 0}
<div class="flex items-center gap-2 text-sm text-secondary py-8 self-center">
<Loader2 class="w-4 h-4 animate-spin" />
Loading comparison...
</div>
{:else if error}
<div class="text-sm text-red-600 dark:text-red-400 py-4">{error}</div>
{:else if notice}
<div class="text-sm text-secondary py-4">{notice}</div>
{:else if diffs.length === 0}
<div class="text-sm text-secondary py-4">{emptyMessage}</div>
{:else if filteredDiffs.length === 0}
<div class="text-sm text-secondary py-4">No files match "{searchQuery}".</div>
{:else}
<div class="flex flex-col gap-2">
{#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)}
<details
open
id={rowId(d)}
class="border border-light rounded-md bg-surface scroll-mt-2"
ontoggle={(e) => onDetailsToggle(d, e)}
>
<summary
class="sticky top-0 z-30 bg-surface flex items-center gap-2 px-3 py-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden border-b border-transparent rounded-md relative before:content-[''] before:absolute before:inset-0 before:bg-surface-hover before:opacity-0 before:pointer-events-none before:transition-opacity hover:before:opacity-100"
>
<ChevronDown
class="w-3.5 h-3.5 shrink-0 text-tertiary transition-transform chevron"
/>
<RowIcon kind={d.kind as any} size={14} />
<div class="min-w-0 flex-1">
{#if editUrl}
<ExternalEditLink
href={editUrl}
title={d.path}
class="text-xs text-primary font-mono truncate"
>
<span class="truncate">{d.path}</span>
</ExternalEditLink>
{:else}
<div class="text-xs text-primary font-mono truncate" title={d.path}>
{d.path}
</div>
{/if}
</div>
<div class="shrink-0 flex items-center gap-2">
{#if d.ahead && d.ahead > 0}
<span class="text-2xs text-secondary">{d.ahead} ahead</span>
{/if}
{#if d.behind && d.behind > 0}
<span class="text-2xs text-secondary">{d.behind} behind</span>
{/if}
<Badge color={statusBadgeColor(status)}>
<StatusIcon class="w-3 h-3 inline mr-0.5" />
{status}
</Badge>
</div>
</summary>
<div
class="border-t border-light bg-surface-tertiary rounded-b-md overflow-hidden"
>
{#if !loaded || loaded.state === 'loading'}
<div class="flex items-center gap-2 text-xs text-secondary p-3">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
Loading diff…
</div>
{:else if loaded.state === 'error'}
<div class="text-xs text-red-600 dark:text-red-400">{loaded.error}</div>
{:else if loaded.state === 'ready'}
<WorkspaceItemDiffViewer
kind={d.kind}
originalRaw={loaded.before}
currentRaw={loaded.after}
{inlineDiff}
/>
{/if}
</div>
</details>
{/each}
</div>
{/if}
</div></main
>
</div>
</DrawerContent>
</Drawer>
<style>
details:not([open]) :global(.chevron) {
transform: rotate(-90deg);
}
details:not([open]) > .tree-summary :global(.tree-chevron-open) {
display: none;
}
details[open] > .tree-summary :global(.tree-chevron-closed) {
display: none;
}
</style>
+122
View File
@@ -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<string, any>): 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<string, any>
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<void> {
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<AppDraftValue['policy']> & 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
}
})
}
}
+219
View File
@@ -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<DraftKind, (draft: any) => 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<DraftDiffValues> {
// 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<void> {
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<string[]>([]), 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<DeployResult> {
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<DeployResult> {
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) }
}
}
+143
View File
@@ -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<DraftListEntry[]>
): Promise<DraftListEntry[]> {
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<DraftItem[]> {
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<string, number> = $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()
}
}
}
@@ -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;"
>
<ForkWorkspaceBanner />
<WorkspaceDraftsBanner />
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full">
{#if $workspaceStore == 'admins'}
<div class="my-4"></div>
@@ -1,5 +1,5 @@
export function load() {
return {
stuff: { title: 'Compare / Deploy to main workspace' }
stuff: { title: 'Compare & Deploy' }
}
}
@@ -1,9 +1,11 @@
<script lang="ts">
import CompareWorkspaces from '$lib/components/CompareWorkspaces.svelte'
import CompareDrafts from '$lib/components/CompareDrafts.svelte'
import { WorkspaceService, type WorkspaceComparison } from '$lib/gen'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { page } from '$app/state'
import { userWorkspaces, usersWorkspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
import { onDestroy, untrack } from 'svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Button from '$lib/components/common/button/Button.svelte'
@@ -13,14 +15,82 @@
import { switchWorkspace } from '$lib/storeUtils'
import { goto } from '$lib/navigation'
type CompareMode = 'fork' | 'draft'
let comparison: WorkspaceComparison | undefined = $state(undefined)
let currentWorkspaceId: string | undefined = $state(
page.url.searchParams.get('workspace_id') ?? undefined
page.url.searchParams.get('workspace_id') ?? $workspaceStore ?? undefined
)
let currentWorkspaceData = $derived($userWorkspaces.find((w) => w.id === currentWorkspaceId))
let parentWorkspaceId = $derived(currentWorkspaceData?.parent_workspace_id)
const isFork = $derived(!!parentWorkspaceId && currentWorkspaceId?.startsWith('wm-fork-'))
// Mode is seeded from the URL (?mode=draft|fork). `draft` is valid for any
// workspace, so it resolves immediately. `fork` is only valid for an actual
// fork, so it (like an absent mode) defers to the effect below, which falls
// back to draft for a non-fork once the workspace list has loaded (so `isFork`
// is known) — otherwise `?mode=fork` on a non-fork would strand the page on the
// fork UI, which can't render without a parent.
const urlMode = page.url.searchParams.get('mode')
let mode = $state<CompareMode>(urlMode === 'draft' ? 'draft' : 'fork')
let modeResolved = $state(urlMode === 'draft')
// Which fork direction to restore when switching back from draft mode. The
// merged toggle (CompareModeToggle, rendered inside each card) reports its
// selection here; the page only swaps which comparison component is shown.
let forkDirection = $state<'deploy_to' | 'update'>('deploy_to')
function selectMode(v: 'deploy_to' | 'update' | 'draft') {
if (v === 'draft') {
mode = 'draft'
} else {
forkDirection = v
mode = 'fork'
}
}
// Draft count drives the "Deployed ↔ draft" toggle badge. Reads the shared
// Workspace Drafts resource — count ≡ the draft list, and it refreshes itself
// when a deploy/discard invalidates the workspace.
const drafts = useWorkspaceDrafts(() => currentWorkspaceId)
const draftCount = $derived(drafts.count)
// Keys (`kind:path`) of fork items that are deployed *and* carry a pending
// draft (has_draft, i.e. not draft_only). CompareWorkspaces uses this to flag
// those rows — deploying/updating moves the deployed version, not the draft —
// and to leave them out of the default selection. Raw apps map to the
// `raw_app:` diff kind. draft_only items (never deployed) are excluded: they
// don't appear in the fork comparison as deployed rows.
const draftKeys = $derived(
new Set(
drafts.items
.filter((d) => !d.draft_only)
.map((d) => `${d.raw_app ? 'raw_app' : d.kind}:${d.path}`)
)
)
// Per-direction counts for the merged toggle badges. Deployable = items ahead
// (fork has changes the parent lacks); updateable = items behind. Computed
// here so they show on the toggle in draft mode too (where CompareDrafts has
// no comparison data of its own). Typed helpers avoid a $state `never`
// inference quirk on `comparison` inside $derived. A conflict (ahead AND
// behind) is intentionally counted in both directions — it's actionable either
// way.
function countDir(c: WorkspaceComparison | undefined, dir: 'ahead' | 'behind'): number {
return c?.diffs.filter((d) => d[dir] > 0).length ?? 0
}
const deployCount = $derived(countDir(comparison, 'ahead'))
const updateCount = $derived(countDir(comparison, 'behind'))
$effect(() => {
if (modeResolved || !currentWorkspaceData) return
untrack(() => {
mode = isFork ? 'fork' : 'draft'
modeResolved = true
})
})
async function checkForChanges() {
if (!currentWorkspaceId || !parentWorkspaceId) {
@@ -45,6 +115,25 @@
untrack(() => checkForChanges())
})
// Refresh the *fork comparison* after a child mutates state (deploy / update /
// discard). The Draft Count refreshes itself (the mutation invalidates the
// Workspace Drafts resource). The fork comparison (workspace_diff) is
// recomputed *asynchronously* (~hundreds of ms after the action), so an
// immediate re-fetch returns the pre-change diff — re-poll a few times to let
// the tally catch up.
let comparisonPollTimers: ReturnType<typeof setTimeout>[] = []
function refreshCounts() {
checkForChanges()
comparisonPollTimers.forEach(clearTimeout)
comparisonPollTimers = [800, 1800, 3500].map((delay) =>
setTimeout(() => checkForChanges(), delay)
)
}
// Don't let the catch-up timers fire after navigating away (network call +
// $state write on a gone component).
onDestroy(() => comparisonPollTimers.forEach(clearTimeout))
// Fork lifecycle actions — placed in the page header so they're available
// regardless of merge state. Both go through a confirmation modal because
// archive is reversible-ish but delete is irreversible, and either way the
@@ -99,14 +188,15 @@
acting = false
}
}
const isFork = $derived(!!parentWorkspaceId && currentWorkspaceId?.startsWith('wm-fork-'))
</script>
<CenteredPage>
<PageHeader title="Merge workspaces">
{#if isFork}
<div class="flex flex-row gap-2 items-center">
<PageHeader title="Compare & Deploy">
<div class="flex flex-row gap-2 items-center">
<!-- The merged compare toggle (fork direction + deployed↔draft) now lives
inside each comparison card; only the fork lifecycle actions remain
in the page header. -->
{#if isFork}
<Button
variant="default"
color="light"
@@ -127,15 +217,38 @@
>
Delete fork
</Button>
</div>
{/if}
{/if}
</div>
</PageHeader>
{#if currentWorkspaceId && parentWorkspaceId}
<CompareWorkspaces {currentWorkspaceId} {parentWorkspaceId} {comparison} />
{/if}
{#if !currentWorkspaceId}
No workspace selected
{:else if !parentWorkspaceId}
{:else if mode === 'draft'}
<CompareDrafts
{currentWorkspaceId}
draftItems={drafts.items}
draftsLoading={drafts.loading}
onChanged={refreshCounts}
{isFork}
parentWorkspaceId={parentWorkspaceId ?? undefined}
{deployCount}
{updateCount}
{draftCount}
onModeSelected={selectMode}
/>
{:else if parentWorkspaceId}
<CompareWorkspaces
{currentWorkspaceId}
{parentWorkspaceId}
{comparison}
initialMergeIntoParent={forkDirection === 'deploy_to'}
{deployCount}
{updateCount}
{draftCount}
{draftKeys}
onChanged={refreshCounts}
onModeSelected={selectMode}
/>
{:else}
workspace {currentWorkspaceId} has no parent workspace
{/if}
</CenteredPage>