Files
windmill/frontend/src/lib/components/DiffDrawer.svelte
T
Diego ImbertandClaude Opus 4.8 4e4b2247ef fix: db-backed draft fixes — review-page UX, legacy drafts, session restore (#9600)
* fix(frontend): session-pane draft seeding + restore actions

Seed per-tab last_sync from the server draft's draft_saved_at in the
loadFlow/loadScript "no local draft" branches (mirroring loadRawApp) so the
seeding save attaches a matching last_sync and the server no longer clobbers
an existing server draft with a fresh created_at.

Replace the no-op loadFlow/loadRawApp-based diff-drawer restore handlers with
proper restoreDeployed/restoreDraft that reset the live UserDraft cell (the
inbound sync then updates the preview) and delete the per-user server draft,
mirroring ScriptEditorView. Add rawAppValueToDraft to project a deployed
raw-app value into the draft shape.

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

* fix(api): move UserDraftOverlay/UserDraftItemKind out of openflow inline block

These two schemas were defined between the python-client's
"# -- INLINE START/END --" markers, whose contents build.sh replaces with the
openflow legacy wildcard $ref. That deleted both definitions during bundling
while ~19 path responses still referenced them, failing the python-client
build. Relocated them after the marker block.

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

* feat(frontend): explain legacy drafts in the draft badge popover

The home-page draft badge lists each draft owner; a workspace-level row from
before the per-user drafts migration shows as "Legacy workspace draft". Add an
info tooltip next to it explaining that a legacy draft isn't tied to any user
(email NULL) so everyone with access to the path sees it.

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

* feat(compare): show friendly draft path on the review & deploy page

list_drafts now surfaces the draft JSON's `draft_path` (when set and different
from the storage path) alongside summary, mirroring the home-page list
endpoints. CompareDrafts displays it instead of the `u/{user}/draft_{uuid}`
storage path, while all fetch/deploy/discard calls keep using the storage path
(the draft's server-side key).

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

* fix(compare): delete the storage-path draft when deploying a renamed draft

Deploying a draft from the review page replays the editor's create/update at
the draft's friendly path, which deletes the draft server-side only at that
path. A never-deployed item parked at `u/{user}/draft_{uuid}` therefore left
its storage-path draft behind on deploy and kept listing. Delete the
storage-path draft for every kind after a successful deploy, mirroring the
editors' discardDraftAfterDeploy.

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

* feat(compare): badge legacy drafts on the review & deploy page

list_drafts now reports `legacy_draft` (true when the listed row is a
workspace-level NULL-email draft and no per-user row exists at the path).
CompareDrafts shows a "Legacy draft" badge with a hover tooltip explaining
these predate the per-user drafts migration and aren't tied to a user.

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

* fix(compare): allow discarding a legacy draft from the review page

Legacy drafts (workspace-level, email NULL) aren't owned by the authed user,
so the email-scoped draft delete in update_draft never matched them and the
discard was a silent no-op. Add a delete-only `legacy` flag that retargets the
DELETE (and the conflict re-read) to the NULL-email row, and route the review
page's discard of a legacy draft through it.

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

* chore(backend): prune orphaned sqlx offline cache entries

Re-ran the canonical update_sqlx.sh after rebasing windmill-ee-private onto
origin/main and re-running substitute_ee_code.sh. Compiling the full workspace
with all features recorded every live query and pruned 55 stale cache entries
no longer produced by any query (22 are the removed `draft_only`-on-app
lookups dropped by the db-backed user drafts work; the rest pre-existing
orphans). Orphan entries don't break offline builds — this is cleanup only.

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

* fix(drafts): stop migrated draft-only items flooding the home list

20260609165313_remove_draft_only inserted the legacy (email IS NULL) draft
stubs without an explicit created_at, so every row defaulted to the migration's
now() (transaction_timestamp, constant for the whole transaction) and they all
bunched at the migration instant — flooding the top of the newest-first home
list.

Add a corrective migration that resets those rows' created_at to the epoch so
they sort to the bottom (their real per-item timestamps are unrecoverable —
the source rows were deleted and the draft value carries no timestamp; editing
one bumps created_at to now() and floats it back up). The rows are identified
exactly via _sqlx_migrations.installed_on, which sqlx writes in the same
transaction as the migration so it is byte-identical to the inserted rows'
created_at; rows edited since no longer match and are left alone. Leaving
remove_draft_only intact (rather than neutralizing it) keeps its essential
schema work running everywhere; this migration runs right after and corrects
the timestamps.

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

* docs(migration): note both timestamps are timestamptz in draft created_at repair

Pre-empt a misread: draft.created_at became TIMESTAMPTZ in
20260514233244, so `created_at = installed_on` is an exact instant comparison,
not a tz-sensitive timestamp/timestamptz cast.

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

* fix(compare): resolve friendly draft path per kind + strip email from u/ path

list_drafts read the friendly path only from value->>'draft_path', which is
empty for scripts — the script editor binds the Path widget to script.path, so
the typed path round-trips through the draft JSON's own `path` (flows/apps/raw
-apps use draft_path). Read the right field per kind, matching the home-page
list endpoints, so renamed never-deployed scripts show their friendly name.

Also truncate the user segment at `@` when displaying a `u/{user}/…` path:
auto-generated draft slots are `u/{user}/draft_{uuid}`, and in the admins
workspace (or email-as-username setups) `{user}` is the full email
(`u/admin@windmill.dev/…` → `u/admin/…`). Display only — the path/key used for
fetch/deploy/discard is unchanged.

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

* fix(raw-app): make diff-drawer "restore to deployed" reset like the autosave indicator

The diff drawer's restoreDeployed ran the same runResetToDeployed as the
AutosaveIndicator's "Reset to deployed", but its onResetToDeployed callback
also did `redraw++`, remounting RawAppEditor mid-reset (inside the stopSync
bracket); the fresh mount's draft write resurrected the draft, so the restore
appeared to do nothing. Extract a single `reloadDeployed` callback (drop the
draft handle + reload without the draft overlay) and use it for the diff
drawer, the conflict modal, and the AutosaveIndicator so all three reset the
same way.

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

* fix(compare): don't show auto-generated draft path as the bold title

A never-named draft lives at a synthetic `u/{user}/draft_{uuid}` slot. When it
had no summary and no friendly draft path, that uuid showed as the row's bold
title. Return '' from displayPath for auto-generated paths so they aren't
bolded — the row still shows the storage path in its secondary (grey) line.

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

* fix(diff-drawer): remove obsolete draft-vs-current tab selector

The "Latest saved draft <> Current" comparison is obsolete. Remove the whole
diff-type tab selector; normal-mode diffs now always show deployed-vs-current,
simple-mode shows its single custom diff. Drop the now-unreachable
restore-to-draft button and the `restoreDraft` prop (plus the dead handlers in
the session editor views). The content/metadata selector is unchanged.

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

* fix(compare): make "Reset to deployed" work from the diff drawer

Route the raw-app session preview and the low-code app editor diff-drawer
restore through the same reset-to-deployed callback the AutosaveIndicator uses.

- Raw-app session: add a deployedOnly path to loadRawApp that bypasses the
  draft (cell + server overlay) and reloads the deployed value; the diff
  drawer's restore now runs it via runResetToDeployed instead of rebuilding the
  draft shape in place (which hung and never reset). Also wires the in-session
  AutosaveIndicator reset.
- Low-code app editor: drop the goto in the diff-drawer restoreDeployed that
  re-ran the page load with the draft overlay on and resurrected the draft;
  share one reloadDeployed across the diff drawer, AutosaveIndicator and the
  load-latest-deploy modal.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:46:53 +02:00

241 lines
6.4 KiB
Svelte

<script lang="ts">
import { Alert, Button, Drawer, DrawerContent } from './common'
import { Loader2 } from 'lucide-svelte'
import { scriptLangToEditorLang } from '$lib/scripts'
import Tabs from './common/tabs/Tabs.svelte'
import Tab from './common/tabs/Tab.svelte'
import {
cleanValueProperties,
orderedJsonStringify,
orderedYamlStringify,
replaceFalseWithUndefined,
type Value
} from '$lib/utils'
import type { Script } from '$lib/gen'
type DiffData = {
lang?: string
content?: string
metadata: string
}
let diffType: 'draft' | 'deployed' | 'custom' | undefined = $state(undefined)
let contentType = $derived.by(() => {
if (!data || !diffType) return undefined
const dataType = diffType === 'custom' ? 'original' : diffType
return data[dataType]?.content !== data.current.content
? 'content'
: data[dataType]?.metadata !== data.current.metadata
? 'metadata'
: undefined
})
let diffViewer: Drawer | undefined = $state(undefined)
interface Props {
restoreDeployed?: () => Promise<void>
isFlow?: boolean
}
let { restoreDeployed = undefined, isFlow = false }: Props = $props()
let data:
| {
mode: 'normal'
deployed: DiffData | undefined
draft: DiffData | undefined
current: DiffData
path?: string
button?: { text: string; onClick: () => void }
}
| {
mode: 'simple'
title: string
original: DiffData | undefined
current: DiffData
button?: { text: string; onClick: () => void }
}
| undefined = $state(undefined)
export function openDrawer() {
data = undefined
diffType = undefined
diffViewer?.openDrawer()
}
export function closeDrawer() {
diffViewer?.closeDrawer()
}
function prepareDiff(data: Value) {
const metadata = structuredClone(cleanValueProperties(replaceFalseWithUndefined(data)))
const content = metadata['content']
if (metadata['content'] !== undefined) {
metadata['content'] = 'check content diff'
}
return {
lang: data.language ? scriptLangToEditorLang(data.language as Script['language']) : undefined,
content,
metadata: orderedYamlStringify(metadata)
}
}
export function setDiff(
diff:
| {
mode: 'normal'
deployed: Value
draft?: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
button?: { text: string; onClick: () => void }
}
| {
mode: 'simple'
original: Value
current: Value
title: string
button?: { text: string; onClick: () => void }
}
) {
if (diff.mode === 'normal') {
const { deployed, draft, current, button } = diff
data = {
mode: 'normal',
deployed: !deployed.draft_only ? prepareDiff(deployed) : undefined,
draft: draft ? prepareDiff(draft) : undefined,
current: prepareDiff(current),
path: draft?.path || deployed?.path,
button
}
// The draft-vs-current view (and its tab) is obsolete — always show the
// deployed-vs-current diff.
diffType = 'deployed'
} else {
const { original, current, title, button } = diff
data = {
title,
mode: 'simple',
original: prepareDiff(original),
current: prepareDiff(current),
button
}
diffType = 'custom'
}
}
</script>
<Drawer bind:this={diffViewer} size="1200px" on:close>
<DrawerContent title="Diff" on:close={diffViewer.closeDrawer}>
<div class="flex flex-col gap-4 h-full">
{#if data?.mode === 'normal'}
<Button
unifiedSize="md"
variant="default"
wrapperClasses="self-start"
onClick={restoreDeployed}
disabled={!data.draft &&
orderedJsonStringify(data.deployed) === orderedJsonStringify(data.current)}
>
Restore to deployed{data.draft ? ' and discard draft' : ''}
</Button>
{/if}
{#if data}
{#if contentType}
{@const content =
data.mode === 'normal' ? data.deployed?.content : data.original?.content}
{@const metadata =
data.mode === 'normal' ? data.deployed?.metadata : data.original?.metadata}
{@const lang = data.mode === 'normal' ? data.deployed?.lang : data.original?.lang}
<div class="flex flex-col h-full gap-4">
{#if data.current.content !== undefined}
<Tabs bind:selected={contentType}>
<Tab
value="content"
disabled={content === data.current.content}
label={`Content${content === data.current.content ? ' (no changes)' : ''}`}
/>
<Tab
value="metadata"
disabled={metadata === data.current.metadata}
label={`Metadata${metadata === data.current.metadata ? ' (no changes)' : ''}`}
/>
</Tabs>
{/if}
<div class="flex-1">
{#key diffType}
{#if contentType === 'content'}
{#await import('$lib/components/DiffEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang={lang}
defaultModifiedLang={data.current.lang}
defaultOriginal={content}
defaultModified={data.current.content}
readOnly
/>
{/await}
{:else if contentType === 'metadata'}
{#if isFlow}
{#await import('$lib/components/FlowDiffViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
beforeYaml={metadata ?? ''}
afterYaml={data.current.metadata}
/>
{/await}
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={metadata}
defaultModified={data.current.metadata}
readOnly
/>
{/await}
{/if}
{/if}
{/key}
</div>
</div>
{:else}
<Alert title="No changes detected">
{#if diffType === 'deployed'}
There are no differences between deployed and current
{:else}
There are no differences
{/if}
</Alert>
{/if}
{:else}
<Loader2 class="animate-spin" />
{/if}
</div>
{#snippet actions()}
{#if data?.button}
<Button
variant="subtle"
onClick={() => {
if (data?.button) {
data.button.onClick()
diffViewer?.closeDrawer()
}
}}>{data.button.text}</Button
>
{/if}
{/snippet}
</DrawerContent>
</Drawer>