mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 00:06:06 +00:00
refactor: remove draft sync layer and conflict modal
This commit is contained in:
@@ -1,147 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Surfaces UserDraft sync conflicts one at a time. Mounted once near the
|
||||
* top of the tree (root layout) so any UserDraft.save call can enqueue
|
||||
* a conflict via `UserDraftConflictStore.enqueue` and have it shown to
|
||||
* the user without coordinating with a route component.
|
||||
*
|
||||
* Two conflict shapes:
|
||||
* • upsert rejected: `incoming_value` is non-null. The user tried to
|
||||
* save and lost the race. Offer "Overwrite server draft" / "Load
|
||||
* server draft".
|
||||
* • delete rejected: `incoming_value` is null. The user tried to
|
||||
* discard and lost the race. Offer "Delete anyway" / "Load server
|
||||
* draft".
|
||||
*/
|
||||
import { classNames } from '$lib/utils'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from '../button/Button.svelte'
|
||||
import { AlertTriangle, CornerDownLeft } from 'lucide-svelte'
|
||||
import { UserDraftConflictStore } from '$lib/userDraftConflictStore.svelte'
|
||||
import { UserDraftDbSyncer, syncDrafts } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
const conflict = $derived(UserDraftConflictStore.current)
|
||||
const open = $derived(conflict !== undefined)
|
||||
const isDeleteAttempt = $derived(
|
||||
conflict !== undefined &&
|
||||
(conflict.rejected.incoming_value === null || conflict.rejected.incoming_value === undefined)
|
||||
)
|
||||
|
||||
const lastSyncDate = $derived(UserDraftDbSyncer.getLastSync())
|
||||
|
||||
let busy = $state(false)
|
||||
|
||||
async function forceLocal() {
|
||||
if (!conflict) return
|
||||
busy = true
|
||||
try {
|
||||
await syncDrafts({
|
||||
workspace: conflict.workspace,
|
||||
drafts: [
|
||||
{
|
||||
itemKind: conflict.itemKind,
|
||||
path: conflict.rejected.path,
|
||||
value: isDeleteAttempt ? null : conflict.rejected.incoming_value,
|
||||
force: true
|
||||
}
|
||||
]
|
||||
})
|
||||
UserDraftConflictStore.dismiss()
|
||||
} catch (e) {
|
||||
sendUserToast(`Could not apply local change: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadServer() {
|
||||
if (!conflict) return
|
||||
UserDraft.save(conflict.itemKind, conflict.rejected.path, conflict.rejected.server_value, {
|
||||
workspace: conflict.workspace
|
||||
})
|
||||
UserDraftConflictStore.dismiss()
|
||||
}
|
||||
|
||||
function fadeFast(node: HTMLElement) {
|
||||
return fade(node, { duration: 100 })
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open && conflict}
|
||||
<div transition:fadeFast|local class="fixed top-0 bottom-0 left-0 right-0 z-[9999]" role="dialog">
|
||||
<div
|
||||
class={classNames(
|
||||
'fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity',
|
||||
'ease-out duration-300 opacity-100'
|
||||
)}
|
||||
></div>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
class="relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 ease-out duration-300 opacity-100 translate-y-0 sm:scale-100"
|
||||
>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-800/50"
|
||||
>
|
||||
<AlertTriangle class="text-amber-600 dark:text-amber-300" />
|
||||
</div>
|
||||
<div class="ml-4 flex-1 text-left">
|
||||
<h3 class="text-lg font-medium text-primary">
|
||||
{#if isDeleteAttempt}
|
||||
Couldn't discard your draft for <code>{conflict.rejected.path}</code>
|
||||
{:else}
|
||||
Your draft for <code>{conflict.rejected.path}</code> is out of date
|
||||
{/if}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-secondary">
|
||||
{#if isDeleteAttempt}
|
||||
Another session saved a newer draft for this {conflict.itemKind} since this tab last
|
||||
synced — discarding now would erase changes you haven't seen.
|
||||
{:else}
|
||||
Another session saved a newer draft for this {conflict.itemKind} since this tab last
|
||||
synced.
|
||||
{/if}
|
||||
</p>
|
||||
<dl
|
||||
class="mt-3 text-xs text-tertiary grid grid-cols-[max-content_1fr] gap-x-2 gap-y-1"
|
||||
>
|
||||
<dt>Last sync from this tab:</dt>
|
||||
<dd>
|
||||
{lastSyncDate ? new Date(lastSyncDate).toLocaleString() : 'never'}
|
||||
</dd>
|
||||
<dt>Server draft saved at:</dt>
|
||||
<dd>{new Date(conflict.rejected.server_created_at).toLocaleString()}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 mt-4 flex-wrap">
|
||||
<Button
|
||||
disabled={busy}
|
||||
on:click={loadServer}
|
||||
variant="default"
|
||||
size="sm"
|
||||
shortCut={{ key: 'Esc', withoutModifier: true }}
|
||||
>
|
||||
Load server draft
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
on:click={forceLocal}
|
||||
color="dark"
|
||||
size="sm"
|
||||
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
|
||||
variant="accent"
|
||||
>
|
||||
{isDeleteAttempt ? 'Discard anyway' : 'Overwrite server draft'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Module-level queue of UserDraft sync conflicts (drafts rejected by the
|
||||
* server because a newer version was saved since the client's last sync).
|
||||
*
|
||||
* Components anywhere in the app can call `enqueueConflicts()` to surface a
|
||||
* conflict; the single `UserDraftConflictModal` mounted in the root layout
|
||||
* consumes them one at a time so the user can decide per-conflict.
|
||||
*/
|
||||
import type { UserDraftItemKind } from './userDraft.svelte'
|
||||
import type { RejectedDraft } from './userDraftDbSyncer.svelte'
|
||||
|
||||
export type ConflictEntry = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
rejected: RejectedDraft
|
||||
}
|
||||
|
||||
let pending = $state<ConflictEntry[]>([])
|
||||
|
||||
export const UserDraftConflictStore = {
|
||||
get current(): ConflictEntry | undefined {
|
||||
return pending[0]
|
||||
},
|
||||
get hasAny(): boolean {
|
||||
return pending.length > 0
|
||||
},
|
||||
enqueue(entries: ConflictEntry[]): void {
|
||||
if (entries.length === 0) return
|
||||
pending.push(...entries)
|
||||
},
|
||||
dismiss(): void {
|
||||
pending.shift()
|
||||
},
|
||||
clear(): void {
|
||||
pending.length = 0
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Bi-directional sync layer between the local `UserDraft` autosave and the
|
||||
* server-side `draft` table. The transport is `DraftService.syncDrafts`,
|
||||
* which doubles as "what the server has for me that I haven't seen yet" and
|
||||
* "push these drafts up, rejecting any whose server copy moved forward
|
||||
* since my last sync".
|
||||
*/
|
||||
import type { UserDraftItemKind } from './userDraft.svelte'
|
||||
import { DraftService, type SyncDraftsResponse } from './gen'
|
||||
import { useLocalStorageValue } from './svelte5Utils.svelte'
|
||||
|
||||
const LAST_SYNC_KEY = 'userdraft/lastSync'
|
||||
|
||||
export type MissedDraft = SyncDraftsResponse['missed_drafts'][number]
|
||||
export type RejectedDraft = Extract<SyncDraftsResponse['statuses'][number], { status: 'rejected' }>
|
||||
|
||||
export type PendingDraft<V = unknown> = {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
/**
|
||||
* Draft content. `null` (or omitted) signals a delete — the server
|
||||
* removes the row at this path, applying the same conflict semantics
|
||||
* as an upsert.
|
||||
*/
|
||||
value: V | null
|
||||
/**
|
||||
* Skip the conflict check for this single entry and overwrite the
|
||||
* server copy. Used by the conflict-resolution modal's "Overwrite
|
||||
* server draft" / "Delete anyway" actions; routine autosaves leave
|
||||
* this `false`.
|
||||
*/
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export type MissedDraftCallback = (drafts: MissedDraft[]) => void
|
||||
export type RejectedDraftsCallback = (rejected: RejectedDraft[]) => void
|
||||
|
||||
export type SyncOptions<V = unknown> = {
|
||||
workspace: string
|
||||
drafts: PendingDraft<V>[]
|
||||
onMissedDrafts?: MissedDraftCallback
|
||||
onDraftsRejected?: RejectedDraftsCallback
|
||||
}
|
||||
|
||||
// Setter-only callers can use `useLocalStorageValue` at module scope by
|
||||
// disabling the nested-mutation `$effect`. The lastSync slot is a flat
|
||||
// string updated exclusively via `cell.val = ...`, so the effect is
|
||||
// unnecessary.
|
||||
const lastSyncCell = useLocalStorageValue<string | undefined>(LAST_SYNC_KEY, undefined, 'string', {
|
||||
saveInitialValue: false
|
||||
})
|
||||
|
||||
function getLastSync(): string | undefined {
|
||||
return lastSyncCell.val
|
||||
}
|
||||
|
||||
function bumpLastSync(serverTimestamp: string): void {
|
||||
const previous = lastSyncCell.val
|
||||
if (!previous || new Date(serverTimestamp).getTime() > new Date(previous).getTime()) {
|
||||
lastSyncCell.val = serverTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediate sync. Caller is responsible for handling the missed/rejected
|
||||
* lists via the callbacks.
|
||||
*/
|
||||
export async function syncDrafts<V = unknown>(opts: SyncOptions<V>): Promise<void> {
|
||||
const lastSync = getLastSync()
|
||||
const payloadDrafts = opts.drafts.map((d) => ({
|
||||
path: d.path,
|
||||
typ: d.itemKind,
|
||||
value: d.value as any,
|
||||
force: d.force ?? false
|
||||
}))
|
||||
const result = await DraftService.syncDrafts({
|
||||
workspace: opts.workspace,
|
||||
requestBody: {
|
||||
last_sync: lastSync,
|
||||
drafts: payloadDrafts
|
||||
}
|
||||
})
|
||||
bumpLastSync(result.current_timestamp as unknown as string)
|
||||
|
||||
if (result.missed_drafts.length > 0 && opts.onMissedDrafts) {
|
||||
opts.onMissedDrafts(result.missed_drafts)
|
||||
}
|
||||
|
||||
const rejected = result.statuses.filter((s): s is RejectedDraft => s.status === 'rejected')
|
||||
if (rejected.length > 0 && opts.onDraftsRejected) {
|
||||
opts.onDraftsRejected(rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-workspace single-flight serializer. Pushes are merged into
|
||||
// `pendingPushReq` so concurrent calls coalesce instead of fan-out; the
|
||||
// leader (the call that found no flush in progress) drains the queue.
|
||||
type WorkspaceState = {
|
||||
isFlushing: boolean
|
||||
pendingPushReq: SyncOptions | undefined
|
||||
}
|
||||
|
||||
const workspaceStates = new Map<string, WorkspaceState>()
|
||||
|
||||
/**
|
||||
* Merge two `SyncOptions` into one. Drafts are keyed by `(itemKind, path)`
|
||||
* with later wins, so a sequence like push([X₁]), push([X₂, Y₂]),
|
||||
* push([Y₃]) ends up syncing [X₂, Y₃] — keys only in `prev` survive even
|
||||
* when `next` doesn't repeat them. Callbacks fall back to `prev` when
|
||||
* `next` doesn't provide one, so a caller that doesn't pass callbacks
|
||||
* never silently disarms an earlier caller that did.
|
||||
*/
|
||||
function mergeSyncOptions(prev: SyncOptions, next: SyncOptions): SyncOptions {
|
||||
const merged = new Map<string, PendingDraft>()
|
||||
for (const d of prev.drafts) merged.set(`${d.itemKind}|${d.path}`, d)
|
||||
for (const d of next.drafts) merged.set(`${d.itemKind}|${d.path}`, d)
|
||||
return {
|
||||
workspace: next.workspace,
|
||||
drafts: [...merged.values()],
|
||||
onMissedDrafts: next.onMissedDrafts ?? prev.onMissedDrafts,
|
||||
onDraftsRejected: next.onDraftsRejected ?? prev.onDraftsRejected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a push. At most one `syncDrafts` is in flight per workspace; any
|
||||
* pushes that arrive during a flight are merged via `mergeSyncOptions` and
|
||||
* sent as a single follow-up request when the in-flight call resolves.
|
||||
*
|
||||
* If `syncDrafts` throws while newer work is already queued, the error is
|
||||
* dropped — the next request supersedes it. Otherwise the error propagates
|
||||
* out of the leader's `pushDrafts` call.
|
||||
*/
|
||||
async function pushDrafts<V = unknown>(opts: SyncOptions<V>): Promise<void> {
|
||||
let state = workspaceStates.get(opts.workspace)
|
||||
if (!state) {
|
||||
state = { isFlushing: false, pendingPushReq: undefined }
|
||||
workspaceStates.set(opts.workspace, state)
|
||||
}
|
||||
state.pendingPushReq =
|
||||
state.pendingPushReq === undefined
|
||||
? (opts as SyncOptions)
|
||||
: mergeSyncOptions(state.pendingPushReq, opts as SyncOptions)
|
||||
if (state.isFlushing) return
|
||||
state.isFlushing = true
|
||||
try {
|
||||
while (state.pendingPushReq !== undefined) {
|
||||
const next = state.pendingPushReq
|
||||
state.pendingPushReq = undefined
|
||||
try {
|
||||
await syncDrafts(next)
|
||||
} catch (e) {
|
||||
if (state.pendingPushReq === undefined) throw e
|
||||
// Else: newer pushes arrived during the failed sync — drop
|
||||
// the error and let the loop send the merged follow-up.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.isFlushing = false
|
||||
}
|
||||
}
|
||||
|
||||
export const UserDraftDbSyncer = {
|
||||
getLastSync,
|
||||
pushDrafts
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
|
||||
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
|
||||
import ForkConflictModal from '$lib/components/ForkConflictModal.svelte'
|
||||
import UserDraftConflictModal from '$lib/components/common/confirmationModal/UserDraftConflictModal.svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
isPremiumStore,
|
||||
@@ -857,8 +856,6 @@
|
||||
|
||||
<ForkConflictModal />
|
||||
|
||||
<UserDraftConflictModal />
|
||||
|
||||
<Modal2
|
||||
title="Forking {$workspaceStore}"
|
||||
target="#content"
|
||||
|
||||
Reference in New Issue
Block a user