From 3cfd858e363c5dced28cda3426f99bf2f4665e2c Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 26 May 2026 18:09:55 +0200 Subject: [PATCH] feat(sessions): surface local-storage drafts in fork diff & compare page Augments the backend fork-vs-parent comparison with browser-local (UserDraft) drafts so a session's uncommitted AI/user changes are visible in the Fork Diff Viewer and the /forks/compare page. Adds forkDraftDiff.ts (augmentForkComparisonWithLocalDrafts + getForkItemValue), a 'local changes detected' / new-draft warning surface (checkbox-slot warning icon, no-op-baseline filtering, dedup), a 'Local draft <> fork' tab in DiffDrawer, and selectTooltip/nonSelectableTooltip plumbing in Row/WorkspaceDeployLayout. Co-Authored-By: Claude Opus 4.7 --- .../lib/components/CompareWorkspaces.svelte | 129 +++++++-- frontend/src/lib/components/DiffDrawer.svelte | 78 +++--- .../components/WorkspaceDeployLayout.svelte | 10 +- .../lib/components/common/table/Row.svelte | 11 +- .../components/sessions/ForkDiffDrawer.svelte | 53 +++- .../lib/components/sessions/forkDraftDiff.ts | 245 ++++++++++++++++++ .../sessions/sessionRuntime.svelte.ts | 19 +- 7 files changed, 471 insertions(+), 74 deletions(-) create mode 100644 frontend/src/lib/components/sessions/forkDraftDiff.ts diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 95d36c4551..24a17b4a2c 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -42,6 +42,12 @@ type DeployResult } from '$lib/utils_workspace_deploy' import { isTriggerOrScheduleKind } from 'windmill-utils-internal' + import { + augmentForkComparisonWithLocalDrafts, + getForkItemValue, + type AugmentedWorkspaceComparison, + type AugmentedWorkspaceItemDiff + } from './sessions/forkDraftDiff' import Tooltip from './Tooltip.svelte' import OnBehalfOfSelector, { needsOnBehalfOfSelection, @@ -64,7 +70,29 @@ comparison: WorkspaceComparison | undefined } - let { currentWorkspaceId, parentWorkspaceId, comparison }: Props = $props() + let { currentWorkspaceId, parentWorkspaceId, comparison: comparisonProp }: Props = $props() + + // Local (localStorage) drafts aren't in the fork's backend DB, so the + // backend `compareWorkspaces` can't see them. Augment the comparison with + // them for display. `comparisonProp` stays as the raw backend result for + // deploy-equality checks (isComparisonUpToDate); `comparison` is the + // augmented view everything renders from. Draft-only items are flagged + // `draftOnly` and excluded from the deployable set below. + let comparison = $state(undefined) + $effect(() => { + const backend = comparisonProp + if (!backend) { + comparison = undefined + return + } + let cancelled = false + void augmentForkComparisonWithLocalDrafts(backend, currentWorkspaceId).then((augmented) => { + if (!cancelled) comparison = augmented + }) + return () => { + cancelled = true + } + }) let currentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == currentWorkspaceId)) let parentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == parentWorkspaceId)) @@ -79,6 +107,9 @@ let selectableDiffs = $derived( comparison?.diffs.filter((diff) => { + // Local-draft-only rows aren't backend fork-vs-parent diffs — they + // live in localStorage, not the fork DB, so they can't be deployed. + if (diff.localOnly) return false if (mergeIntoParent) { return diff.ahead > 0 } else { @@ -252,23 +283,42 @@ let diffDrawer: DiffDrawer | undefined = $state(undefined) let isFlow = $state(true) - async function showDiff(kind: Kind, path: string) { + async function showDiff(kind: Kind, path: string, hasLocalChanges = false) { + if (!diffDrawer) return + isFlow = kind == 'flow' + diffDrawer.openDrawer() const workspaceTo = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId const workspaceFrom = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId - if (diffDrawer) { - isFlow = kind == 'flow' - diffDrawer?.openDrawer() - let values = await Promise.all([ - getItemValue(kind, path, workspaceTo), - getItemValue(kind, path, workspaceFrom) + // Tab 1 — fork vs parent: deployed values on both sides. + const [toVal, fromVal] = await Promise.all([ + getItemValue(kind, path, workspaceTo).catch(() => ({})), + getItemValue(kind, path, workspaceFrom).catch(() => ({})) + ]) + // Tab 2 — local draft vs fork: the uncommitted local changes a deploy + // would drop. Only when the item carries a local draft. + let secondary: { original: any; current: any; title: string } | undefined + if (hasLocalChanges) { + const [forkDeployed, forkDraft] = await Promise.all([ + getItemValue(kind, path, currentWorkspaceId).catch(() => ({})), + getForkItemValue(kind, path, currentWorkspaceId) ]) - diffDrawer?.setDiff({ - mode: 'simple', - original: values?.[0] as any, - current: values?.[1] as any, - title: `${workspaceFrom} <> ${workspaceTo}` - }) + secondary = { + original: forkDeployed as any, + current: forkDraft as any, + title: 'Local draft <> fork' + } } + diffDrawer.setDiff({ + mode: 'simple', + original: toVal as any, + current: fromVal as any, + title: `${workspaceFrom} <> ${workspaceTo}`, + secondary + }) + } + + function kindLabel(kind: string): string { + return KIND_DISPLAY_NAMES[kind] ?? (kind === 'raw_app' ? 'app' : kind) } // All *diff* items selected. Trigger items are opt-in and don't count @@ -340,7 +390,7 @@ let allowBehindChangesOverride = $state(false) async function isComparisonUpToDate(): Promise { - if (!comparison) { + if (!comparisonProp) { return false } @@ -350,7 +400,10 @@ targetWorkspaceId: currentWorkspaceId }) - const nonDeployedChanges = comparison.diffs.filter( + // Compare against the raw backend diff (not the local-draft-augmented + // view) — local drafts are never deployed here and would otherwise + // spuriously trip the "new changes detected" guard. + const nonDeployedChanges = comparisonProp.diffs.filter( (e) => !(deploymentStatus[getItemKey(e)]?.status == 'deployed') ) @@ -605,6 +658,17 @@ {selectedItems} {deploymentStatus} selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)} + nonSelectableTooltip={(item) => { + const d = item.diff as AugmentedWorkspaceItemDiff + // Local-draft rows show a warning icon in the checkbox slot and stay + // full opacity. Both cases point to the same fix: deploy the item + // inside the fork first so it becomes a fork↔parent change here. + if (d?.newLocalDraft) + return `This ${kindLabel(d.kind)} only exists as a draft in your browser. Deploy it inside the fork first to be able to deploy it here.` + if (d?.localChanges) + return `This ${kindLabel(d.kind)} has changes saved only in your browser. Deploy it inside the fork first to be able to deploy them here.` + return undefined + }} {allSelected} onToggleItem={(item) => toggleKey(item.key)} onSelectAll={selectAll} @@ -668,7 +732,7 @@ {/if}
- {comparison.summary.total_diffs} total items + {comparison?.summary.total_diffs ?? 0} total items {selectableDiffs.length} @@ -757,17 +821,17 @@ {/if} - {#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible} + {#if !comparison?.all_ahead_items_visible || !comparison?.all_behind_items_visible} - {#if !comparison.all_ahead_items_visible && !comparison.all_behind_items_visible} + {#if !comparison?.all_ahead_items_visible && !comparison?.all_behind_items_visible} This fork is ahead and behind its parent - {:else if !comparison.all_behind_items_visible} + {:else if !comparison?.all_behind_items_visible} This fork is behind of its parent - {:else if !comparison.all_ahead_items_visible} + {:else if !comparison?.all_ahead_items_visible} This fork is ahead of its parent {/if} and some of the changes are not visible by you. Only a user with access to the whole context @@ -833,6 +897,20 @@ customValue={customOnBehalfOf[key]?.permissionedAs} /> {/if} + {#if (diff as AugmentedWorkspaceItemDiff).localChanges} + + + local changes detected + + {/if} {#if diff.kind === 'raw_app'} Raw {/if} @@ -900,7 +978,12 @@
diff --git a/frontend/src/lib/components/sessions/forkDraftDiff.ts b/frontend/src/lib/components/sessions/forkDraftDiff.ts new file mode 100644 index 0000000000..53b70ddaaf --- /dev/null +++ b/frontend/src/lib/components/sessions/forkDraftDiff.ts @@ -0,0 +1,245 @@ +import { deepEqual } from 'fast-equals' +import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte' +import { getItemValue } from '$lib/utils_workspace_deploy' +import type { Kind } from '$lib/utils_deployable' +import type { WorkspaceComparison, WorkspaceItemDiff } from '$lib/gen' + +// The backend `compareWorkspaces` API diffs the fork's *committed* (deployed) +// state against its parent. It cannot see local drafts, which live only in +// the browser's localStorage (the `UserDraft` store, keyed by the fork +// workspace id). This module augments a `WorkspaceComparison` with those +// local drafts so the Fork Diff Viewer / compare page can surface +// uncommitted session changes for review. +// +// Local-draft items are flagged `localDraft: true`. They are NOT in the +// fork's backend DB, so they must be shown read-only and excluded from the +// compare page's deployable set. + +export type ForkDiffKind = WorkspaceItemDiff['kind'] + +export type AugmentedWorkspaceItemDiff = WorkspaceItemDiff & { + /** Case 1: the item exists on the fork server (deployed) AND its local + * (localStorage) draft differs from that server value. Deploying drops the + * local changes — rendered with a warning + "show local changes" diff. */ + localChanges?: boolean + /** Case 2: the item exists ONLY as a local draft (not on the fork server). + * It cannot be deployed until saved in the fork — rendered dimmed. */ + newLocalDraft?: boolean + /** Row was synthesized from a local draft (no corresponding entry in the + * backend fork-vs-parent diff) → not deployable from the compare page. */ + localOnly?: boolean +} +export type AugmentedWorkspaceComparison = Omit & { + diffs: AugmentedWorkspaceItemDiff[] +} + +// UserDraft kind → compare-API kind. Kinds without a `WorkspaceItemDiff` +// equivalent (trigger_poll / cli / nextcloud / google / github) are omitted +// and skipped during augmentation. +const DRAFT_KIND_TO_FORK_KIND: Partial> = { + script: 'script', + flow: 'flow', + app: 'app', + raw_app: 'raw_app', + resource: 'resource', + variable: 'variable', + trigger_schedule: 'schedule', + trigger_http: 'http_trigger', + trigger_websocket: 'websocket_trigger', + trigger_kafka: 'kafka_trigger', + trigger_nats: 'nats_trigger', + trigger_postgres: 'postgres_trigger', + trigger_mqtt: 'mqtt_trigger', + trigger_sqs: 'sqs_trigger', + trigger_gcp: 'gcp_trigger', + trigger_azure: 'azure_trigger', + trigger_email: 'email_trigger', + trigger_default_email: 'email_trigger' +} + +// Reverse map (first draft kind wins for shared targets like email_trigger). +const FORK_KIND_TO_DRAFT_KIND: Partial> = (() => { + const out: Partial> = {} + for (const [draftKind, forkKind] of Object.entries(DRAFT_KIND_TO_FORK_KIND)) { + if (forkKind && !(forkKind in out)) out[forkKind] = draftKind as UserDraftItemKind + } + return out +})() + +function diffKey(kind: string, path: string): string { + return `${kind}/${path}` +} + +// Project a draft value and a deployed item value to the same comparable +// shape so a draft that merely mirrors the deployed item (e.g. from opening +// an item in the session preview without editing) is recognised as +// "no change". Best-effort for raw_app (deployed apps nest content under +// `value`, drafts keep it flat). +function comparableProjection(kind: ForkDiffKind, v: any): unknown { + if (v == null) return v + if (kind === 'script') { + return { content: v.content, language: v.language, summary: v.summary, schema: v.schema } + } + if (kind === 'flow') { + return { value: v.value, schema: v.schema, summary: v.summary } + } + if (kind === 'raw_app') { + const value = v.value ?? v + return { + files: value?.files ?? v.files, + runnables: value?.runnables ?? v.runnables, + summary: v.summary + } + } + return v +} + +// JSON round-trip both sides (drops `undefined`-valued keys, normalizes) then +// deep-compare — mirrors `normalizeForCompare` in userDraft.svelte.ts. +function normalize(v: unknown): unknown { + if (v === undefined) return undefined + try { + return JSON.parse(JSON.stringify(v)) + } catch { + return v + } +} + +// `getItemValue` resolves to an empty object `{}` (rather than throwing or +// returning null) when the item doesn't exist on the server. Treat that — and +// null/undefined — as "absent". +function isPresent(v: unknown): boolean { + if (v == null) return false + if (typeof v === 'object') return Object.keys(v as object).length > 0 + return true +} + +function draftDiffersFromDeployed( + kind: ForkDiffKind, + draftValue: unknown, + deployedValue: unknown +): boolean { + return !deepEqual( + normalize(comparableProjection(kind, draftValue)), + normalize(comparableProjection(kind, deployedValue)) + ) +} + +/** + * Merge local drafts (browser localStorage, scoped to `forkWorkspaceId`) into + * a backend `WorkspaceComparison`: + * - a draft matching an existing diff flags that diff `localDraft` (its fork + * side should be read from the draft); + * - a draft with no matching diff is added as a synthetic `localDraft` entry, + * after filtering no-op baseline drafts (draft identical to the deployed + * item) for the loader-seeded kinds. + * + * Async: it fetches the fork's deployed value for draft-only candidates to + * decide whether the draft is a real change. The number of such fetches is + * bounded by how many items the session touched. + */ +export async function augmentForkComparisonWithLocalDrafts( + comparison: WorkspaceComparison, + forkWorkspaceId: string +): Promise { + const diffs: AugmentedWorkspaceItemDiff[] = comparison.diffs.map((d) => ({ ...d })) + const byKey = new Map() + for (const d of diffs) byKey.set(diffKey(d.kind, d.path), d) + + const drafts = UserDraft.list({ workspace: forkWorkspaceId }) + + for (const entry of drafts) { + const forkKind = DRAFT_KIND_TO_FORK_KIND[entry.itemKind] + if (!forkKind) continue + // Skip "new item" scaffold drafts stored at an empty path — they aren't + // real workspace items yet and would otherwise render as a pathless, + // summary-less "local draft" row (duplicating the real, named entry). + if (!entry.path || !entry.path.trim()) continue + const key = diffKey(forkKind, entry.path) + + // The fork's server (deployed) value, used to (a) tell a real local edit + // from a no-op baseline draft (the session loaders seed a draft equal to + // the loaded value on open) and (b) decide whether the item is on the + // fork server at all (Case 1 vs Case 2). + let serverValue: unknown + try { + serverValue = await getItemValue(forkKind as Kind, entry.path, forkWorkspaceId) + } catch { + serverValue = undefined + } + const onServer = isPresent(serverValue) + const differs = !onServer || draftDiffersFromDeployed(forkKind, entry.value, serverValue) + + // Draft equals the server value → no local change. Leave any existing + // backend diff untouched and add no synthetic row. + if (onServer && !differs) continue + + const existing = byKey.get(key) + if (existing) { + // Case 1: a backend fork-vs-parent diff that also carries a divergent + // local draft. Stays deployable (deploys the server value); the local + // changes would be dropped — flagged for a warning. + existing.localChanges = true + continue + } + + if (onServer) { + // Case 1 with no fork-vs-parent delta (server == parent): review-only. + const synthetic: AugmentedWorkspaceItemDiff = { + kind: forkKind, + path: entry.path, + ahead: 1, + behind: 0, + has_changes: true, + exists_in_source: true, + exists_in_fork: true, + localChanges: true, + localOnly: true + } + diffs.push(synthetic) + byKey.set(key, synthetic) + } else { + // Case 2: brand-new local item, not on the fork server → cannot deploy. + const synthetic: AugmentedWorkspaceItemDiff = { + kind: forkKind, + path: entry.path, + ahead: 1, + behind: 0, + has_changes: true, + exists_in_source: false, + exists_in_fork: true, + newLocalDraft: true, + localOnly: true + } + diffs.push(synthetic) + byKey.set(key, synthetic) + } + } + + const added = diffs.length - comparison.diffs.length + const summary = { + ...comparison.summary, + total_diffs: comparison.summary.total_diffs + added, + total_ahead: comparison.summary.total_ahead + added + } + + return { ...comparison, diffs, summary } +} + +/** + * Fork-side value for a diff item: the local draft when one exists, else the + * deployed value from the backend. Use this (instead of `getItemValue` with + * the fork workspace) so the per-item diff shows pending local-draft content. + */ +export async function getForkItemValue( + kind: Kind, + path: string, + forkWorkspaceId: string +): Promise { + const draftKind = FORK_KIND_TO_DRAFT_KIND[kind as ForkDiffKind] + if (draftKind) { + const draft = UserDraft.get(draftKind, path, { workspace: forkWorkspaceId }) + if (draft != null) return draft + } + return getItemValue(kind, path, forkWorkspaceId) +} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index d85fec4669..8891f2b000 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -10,8 +10,7 @@ import { type AppWithLastVersion, type Flow, type NewScript, - type NewScriptWithDraft, - type WorkspaceComparison + type NewScriptWithDraft } from '$lib/gen' import type { App as AppValue, HiddenRunnable } from '$lib/components/apps/types' import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils' @@ -34,6 +33,10 @@ import { setGetPreviewStatusHandler, setOpenPreviewHandler } from '$lib/components/copilot/chat/global/core' +import { + augmentForkComparisonWithLocalDrafts, + type AugmentedWorkspaceComparison +} from './forkDraftDiff' export interface SessionRuntime { readonly sessionId: string @@ -111,7 +114,7 @@ export interface SessionRuntime { // and any future consumer that needs the parent ↔ fork diff list. Keyed // implicitly by the (parent, fork) pair last passed to ensureForkComparison; // invalidateForkComparison() forces a refresh after a known-mutating action. - readonly forkComparison: { val: WorkspaceComparison | undefined } + readonly forkComparison: { val: AugmentedWorkspaceComparison | undefined } readonly loadingForkComparison: boolean ensureForkComparison(parent: string, fork: string): Promise invalidateForkComparison(): void @@ -184,7 +187,9 @@ function createRuntime(session: Session): SessionRuntime { let notFoundRawApp = $state(false) let loadedRawAppPath = $state(undefined) - const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined }) + const forkComparison: { val: AugmentedWorkspaceComparison | undefined } = $state({ + val: undefined + }) let loadingForkComparison = $state(false) let forkComparisonKey: string | undefined = undefined @@ -491,10 +496,11 @@ function createRuntime(session: Session): SessionRuntime { forkComparisonKey = key loadingForkComparison = true try { - forkComparison.val = await WorkspaceService.compareWorkspaces({ + const backend = await WorkspaceService.compareWorkspaces({ workspace: parent, targetWorkspaceId: fork }) + forkComparison.val = await augmentForkComparisonWithLocalDrafts(backend, fork) } catch (e) { console.error('SessionRuntime: forkComparison fetch failed', e) forkComparison.val = undefined @@ -525,10 +531,11 @@ function createRuntime(session: Session): SessionRuntime { if (loadingForkComparison) return loadingForkComparison = true try { - forkComparison.val = await WorkspaceService.compareWorkspaces({ + const backend = await WorkspaceService.compareWorkspaces({ workspace: parent, targetWorkspaceId: fork }) + forkComparison.val = await augmentForkComparisonWithLocalDrafts(backend, fork) } catch (e) { console.error('SessionRuntime: forkComparison refresh failed', e) } finally {