Files
windmill/frontend/src/lib/components/otherUserDraftLoad.svelte.ts
T
Diego ImbertandClaude Opus 4.8 5508f1da9c feat(frontend): View Diff and in-place Load for other users' drafts (#9621)
* feat(frontend): replace other-user draft "View JSON" with "View Diff"

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

* feat(frontend): replace other-user draft "Fork" with in-place "Load"

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

* refactor(frontend): detect first overlay edit by value divergence, not a timer

Replaces the 700ms arming timer (which leaked across sessions and silently
swallowed sub-window edits) with a deterministic check: a blocked save opens
the overwrite prompt only once the cell value diverges from the loaded value.

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

* fix(frontend): overlay leak on revisit, diff z-index, home-popover edit affordances

- Clear a stale "editing another user's draft" overlay when its editor is
  reloaded without a fresh Load, so returning to the item edits our own draft.
- Open View Diff above the others-drafts modal (close it first) instead of
  rendering the drawer behind it.
- Add an Edit button to our own row in the home draft popover; use a pencil
  icon (not a download) for Load.

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

* feat: admin "Migrate" action for legacy drafts (delete / assign to self)

Adds an admin-gated `POST /drafts/migrate_legacy/{kind}/{path}` endpoint to
resolve pre-migration workspace-level drafts (email NULL): delete the row, or
move its value onto the admin's own row. Surfaces a "Migrate" button on legacy
rows in the home-page draft popover and the in-editor others-drafts modal
(workspace admins / superadmins only), opening a modal with Delete and
Assign to self.

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

* fix(frontend): close home draft popover before opening View Diff / Migrate

The hover popover sits above the diff drawer and migrate modal (z-index), so
it covered them. Close it first so they render on top.

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

* fix(frontend): remount the flow builder on "Reset to draft" from an overlay

FlowBuilder captures the flow at mount, so reloading the value alone left the
foreign graph on screen — reset appeared to do nothing. Force a remount
(renderEditor=false → loadFlow) like navigation does. Scripts (imperative
setCode) and apps (redraw++) already remount, so only flows needed this.

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

* fix(frontend): refresh the home row after migrating a legacy draft

invalidateAll() didn't refetch the home list (it loads items client-side), so
the legacy badge entry lingered after delete / assign-to-self. Bubble an
onMigrated callback up to the row's `change` event, reusing the same reload
chain (Item → ItemsList loadScripts/Flows/Apps) as delete/archive.

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

* nit

* nit

* fix(frontend): match app overlay baseline to the migrated value

AppEditor migrateApp()s the app on mount, so the draft cell settles to the
migrated value. The overlay used the raw loaded value as the divergence
baseline, so a post-mount mirror write could trip "Overwrite your current
draft?" before any edit. Migrate the baseline too (like the deployed-baseline
and raw_app bundle do) so it matches the settled cell.

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

* fix(frontend): address review on legacy-draft migrate + overlay

- Legacy "Assign to self" now confirms before replacing an existing own draft
  (MigrateLegacyDraftModal gains an `ownDraftExists` step, threaded from the
  home badge and the in-editor others-drafts modal).
- Gate overlay mode on a per-response `hasOwnDraft` instead of the sticky
  `loadedFromDraft`, so navigating to a no-own-draft item in the same editor
  route can't wrongly enter overlay. Fixed in all 4 editor routes.
- Raw-app "View Diff" now projects the deployed app into the flat draft-bundle
  shape (via a shared `extractDataConfig`) instead of diffing `.value` against
  the bundle, so the drawer shows a real diff.

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-18 08:44:46 +02:00

177 lines
6.2 KiB
TypeScript

import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { goto } from '$lib/navigation'
import { base } from '$app/paths'
import { UserDraft, draftValuesEqual, type UserDraftItemKind } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
/**
* Coordinates "Load another user's draft into the editor as if it were ours".
*
* Two cases, decided by the route once it knows whether WE already have a draft
* at this path (`is_draft` for us):
* - No own draft → the loaded value just becomes our draft (normal autosave).
* - Own draft → "overlay" mode: the loaded value is shown but NEVER saved
* (so our own draft on the server is untouched). The first edit prompts
* "overwrite your current draft?". Confirm persists the edited value as our
* draft; Reset restores our own draft.
*
* The save block is a hard per-key lock in the syncer (see `lockSync`) — it
* covers the reactive mirror AND the navigation / tab-death flush paths, so the
* foreign value can't leak onto the server through any route.
*/
function keyOf(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `${workspace}/${itemKind}/${path}`
}
export type PendingOtherUserDraftLoad = {
workspace: string
itemKind: UserDraftItemKind
path: string
value: unknown
ownerLabel: string
}
type ActiveSession = {
workspace: string
itemKind: UserDraftItemKind
path: string
ownerLabel: string
/** The loaded value the editor shows. A blocked save whose value still
* equals this is a programmatic load-cascade write, not an edit — so the
* overwrite prompt fires only once the value actually diverges. */
loadedValue: unknown
/** Reload our own draft into the editor (AutosaveIndicator's "Reset to draft"). */
onResetToOwnDraft: () => void | Promise<void>
}
// One-shot handoff: set by the Load action, consumed by the editor's loader.
const pending = new SvelteMap<string, PendingOtherUserDraftLoad>()
// Live overlay sessions, keyed by (workspace, itemKind, path).
const active = new SvelteMap<string, ActiveSession>()
// Keys whose overwrite-confirmation modal is currently open.
const overwriteOpen = new SvelteSet<string>()
export function editRouteFor(itemKind: UserDraftItemKind, path: string): string {
switch (itemKind) {
case 'script':
return `${base}/scripts/edit/${path}`
case 'flow':
return `${base}/flows/edit/${path}`
case 'app':
return `${base}/apps/edit/${path}`
case 'raw_app':
return `${base}/apps_raw/edit/${path}`
default:
throw new Error(`Cannot load drafts of kind ${itemKind}`)
}
}
export const OtherUserDraftLoad = {
/**
* Stage a load. The editor's loader picks it up via `takePending`. When
* `navigate`, route to the item's edit page (home-page entry point); the
* in-editor entry point reloads in place instead.
*/
stage(
workspace: string,
itemKind: UserDraftItemKind,
value: unknown,
path: string,
ownerLabel: string,
opts: { navigate: boolean }
): void {
pending.set(keyOf(workspace, itemKind, path), { workspace, itemKind, path, value, ownerLabel })
if (opts.navigate) goto(editRouteFor(itemKind, path))
},
/** Consume the staged load for this key (returns + removes it). */
takePending(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): PendingOtherUserDraftLoad | undefined {
const k = keyOf(workspace, itemKind, path)
const v = pending.get(k)
if (v) pending.delete(k)
return v
},
/**
* Enter overlay mode: lock all server saves for this key and remember how
* to restore our own draft. A blocked save opens the overwrite modal ONLY
* once the value diverges from `loadedValue` — so the programmatic load
* cascade (which writes back the same value) never trips the prompt, while
* the user's very first real edit does, with no timing window.
*/
beginOverlay(session: ActiveSession): void {
const k = keyOf(session.workspace, session.itemKind, session.path)
active.set(k, session)
UserDraftDbSyncer.lockSync(
{ workspace: session.workspace, itemKind: session.itemKind, path: session.path },
() => {
const current = UserDraft.get(session.itemKind, session.path, {
workspace: session.workspace
})
if (current !== undefined && !draftValuesEqual(current, session.loadedValue)) {
this.requestOverwriteModal(session.workspace, session.itemKind, session.path)
}
}
)
},
isActive(workspace: string, itemKind: UserDraftItemKind, path: string): boolean {
return active.has(keyOf(workspace, itemKind, path))
},
getSession(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): ActiveSession | undefined {
return active.get(keyOf(workspace, itemKind, path))
},
requestOverwriteModal(workspace: string, itemKind: UserDraftItemKind, path: string): void {
const k = keyOf(workspace, itemKind, path)
if (active.has(k)) overwriteOpen.add(k)
},
isOverwriteModalOpen(workspace: string, itemKind: UserDraftItemKind, path: string): boolean {
return overwriteOpen.has(keyOf(workspace, itemKind, path))
},
/** Cancel: keep editing the loaded value, stay paused; re-prompt on the next edit. */
dismissOverwriteModal(workspace: string, itemKind: UserDraftItemKind, path: string): void {
overwriteOpen.delete(keyOf(workspace, itemKind, path))
},
/** Confirm: adopt the current (edited) value as our own draft and resume saving. */
confirmOverwrite(workspace: string, itemKind: UserDraftItemKind, path: string): void {
const current = UserDraft.get(itemKind, path, { workspace })
this.clear(workspace, itemKind, path)
if (current !== undefined) {
void UserDraftDbSyncer.save({ workspace, itemKind, path, value: current, immediate: true })
}
},
/** Discard the loaded view and restore our own draft. */
async resetToOwnDraft(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): Promise<void> {
const session = active.get(keyOf(workspace, itemKind, path))
this.clear(workspace, itemKind, path)
await session?.onResetToOwnDraft()
},
/** Exit overlay mode: unlock saves, drop the session, close the modal. */
clear(workspace: string, itemKind: UserDraftItemKind, path: string): void {
const k = keyOf(workspace, itemKind, path)
active.delete(k)
overwriteOpen.delete(k)
UserDraftDbSyncer.unlockSync({ workspace, itemKind, path })
}
}