mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix: tell the user when a draft conflict has stopped their edits saving
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f00b2fcb1e
commit
79f61f49b5
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
|
||||
interface Props {
|
||||
/** Take the draft the server holds, replacing what is on screen. */
|
||||
onReload: () => void
|
||||
/** Write what is on screen over the server's draft. */
|
||||
onOverwrite: () => void
|
||||
}
|
||||
|
||||
let { onReload, onOverwrite }: Props = $props()
|
||||
</script>
|
||||
|
||||
<Alert type="warning" title="Your draft changed elsewhere">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div>
|
||||
It was saved from another tab or session since this one read it, so your changes here are no
|
||||
longer being saved.
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button unifiedSize="sm" variant="default" onClick={onReload}>Load the other version</Button>
|
||||
<Button unifiedSize="sm" variant="default" onClick={onOverwrite}>Keep mine</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import DraftConflictAlert from './DraftConflictAlert.svelte'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import { onUserInput } from '$lib/userDraftEditGate'
|
||||
|
||||
@@ -261,6 +262,54 @@
|
||||
)
|
||||
const anyDirty = $derived(dirtyWorkspaces.length > 0)
|
||||
|
||||
/** The server refused this tab's autosave because the row moved under it: another tab, or the
|
||||
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
|
||||
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
|
||||
* as a draft — so without this they are told their work is safe while it is being dropped. */
|
||||
const draftConflict = $derived(
|
||||
selected && initialPath
|
||||
? UserDraftDbSyncer.getConflict({
|
||||
workspace: selected,
|
||||
itemKind: 'resource',
|
||||
path: initialPath
|
||||
}).conflict
|
||||
: undefined
|
||||
)
|
||||
|
||||
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
|
||||
const ws = selected
|
||||
const p = initialPath
|
||||
if (!ws || !p) return
|
||||
const query = { workspace: ws, itemKind: 'resource' as const, path: p }
|
||||
if (keepMine) {
|
||||
// Forced, so it goes over the row that refused us, and its response reseeds
|
||||
// `last_sync` so the next ordinary save is conditional again.
|
||||
const mine = states[ws]?.draft
|
||||
if (mine) await UserDraftDbSyncer.overwrite({ ...query, value: $state.snapshot(mine) })
|
||||
return
|
||||
}
|
||||
// Taking theirs: drop the refused payload first so no later flush can send it, then read
|
||||
// what the server holds and seed that in, which is also what gives this tab a baseline.
|
||||
UserDraftDbSyncer.dropPending(query)
|
||||
UserDraftDbSyncer.clearConflict(query)
|
||||
const r = await ResourceService.getResource({ workspace: ws, path: p, getDraft: true })
|
||||
const deployedState: ResourceState = {
|
||||
path: r.path,
|
||||
args: (r.value ?? {}) as Record<string, any>,
|
||||
description: r.description ?? '',
|
||||
labels: r.labels ?? undefined,
|
||||
wsSpecific: r.ws_specific ?? false
|
||||
}
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
UserDraftDbSyncer.recordRemoteSync(query, (r as any).draft_saved_at)
|
||||
UserDraft.seed(
|
||||
'resource',
|
||||
p,
|
||||
((r as any).draft as ResourceState | undefined) ?? deployedState,
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
|
||||
// The syncer owns the list-page `*` hint; the editor only CLEARS it when a
|
||||
// workspace is at the deployed baseline (so a draft discarded elsewhere
|
||||
// vanishes on reopen). Never SET here. See VariableEditor for the full note.
|
||||
@@ -531,6 +580,13 @@
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col gap-6 pb-2">
|
||||
{#if draftConflict}
|
||||
<DraftConflictAlert
|
||||
onReload={() => resolveDraftConflict(false)}
|
||||
onOverwrite={() => resolveDraftConflict(true)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if otherDirty.length > 0}
|
||||
<Alert type="warning" title="Editing multiple workspaces">
|
||||
You are going to edit the value in: {otherDirty.join(', ')}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
import { useActingUser } from '$lib/actingUser.svelte'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
|
||||
import LocalDraftBanner from './LocalDraftBanner.svelte'
|
||||
import DraftConflictAlert from './DraftConflictAlert.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
|
||||
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
|
||||
@@ -123,6 +125,62 @@
|
||||
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
|
||||
)
|
||||
|
||||
/** The server refused this tab's autosave because the row moved under it: another tab, or the
|
||||
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
|
||||
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
|
||||
* as a draft — so without this they are told their work is safe while it is being dropped. */
|
||||
const draftConflict = $derived(
|
||||
edit && selected && editPath
|
||||
? UserDraftDbSyncer.getConflict({
|
||||
workspace: selected,
|
||||
itemKind: 'variable',
|
||||
path: editPath
|
||||
}).conflict
|
||||
: undefined
|
||||
)
|
||||
|
||||
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
|
||||
const ws = selected
|
||||
const p = editPath
|
||||
if (!ws || !p) return
|
||||
const query = { workspace: ws, itemKind: 'variable' as const, path: p }
|
||||
if (keepMine) {
|
||||
// Forced, so it goes over the row that refused us, and its response reseeds
|
||||
// `last_sync` so the next ordinary save is conditional again.
|
||||
const mine = states[ws]?.draft
|
||||
if (mine) await UserDraftDbSyncer.overwrite({ ...query, value: $state.snapshot(mine) })
|
||||
return
|
||||
}
|
||||
// Taking theirs: drop the refused payload first so no later flush can send it, then read
|
||||
// what the server holds and seed that in, which is also what gives this tab a baseline.
|
||||
UserDraftDbSyncer.dropPending(query)
|
||||
UserDraftDbSyncer.clearConflict(query)
|
||||
const v = await VariableService.getVariable({
|
||||
workspace: ws,
|
||||
path: p,
|
||||
decryptSecret: false,
|
||||
getDraft: true
|
||||
})
|
||||
const deployedState: VariableState = {
|
||||
path: v.path,
|
||||
variable: {
|
||||
value: v.value ?? '',
|
||||
is_secret: v.is_secret,
|
||||
description: v.description ?? ''
|
||||
},
|
||||
labels: v.labels ?? undefined,
|
||||
wsSpecific: v.ws_specific ?? false
|
||||
}
|
||||
initialStates[ws] = structuredClone(deployedState)
|
||||
UserDraftDbSyncer.recordRemoteSync(query, (v as any).draft_saved_at)
|
||||
UserDraft.seed(
|
||||
'variable',
|
||||
p,
|
||||
((v as any).draft as VariableState | undefined) ?? deployedState,
|
||||
{ workspace: ws }
|
||||
)
|
||||
}
|
||||
|
||||
// The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared
|
||||
// on delete). The editor only CLEARS it — a workspace at the deployed
|
||||
// baseline has no draft, so drop any stale hint (this is how a draft
|
||||
@@ -317,6 +375,12 @@
|
||||
on:close={drawer?.closeDrawer}
|
||||
>
|
||||
{#snippet banner()}
|
||||
{#if draftConflict}
|
||||
<DraftConflictAlert
|
||||
onReload={() => resolveDraftConflict(false)}
|
||||
onOverwrite={() => resolveDraftConflict(true)}
|
||||
/>
|
||||
{/if}
|
||||
<LocalDraftBanner
|
||||
show={edit && selectedDirty}
|
||||
reserveSpace={edit}
|
||||
|
||||
Reference in New Issue
Block a user