mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 00:00:46 +00:00
feat: move draft-only items and warn editors when an item moves
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,20 @@
|
||||
import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager'
|
||||
import Label from './Label.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import { FlowService, ScriptService, type TriggersCount } from '$lib/gen'
|
||||
import { DraftService, FlowService, ScriptService, type TriggersCount } from '$lib/gen'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app'
|
||||
|
||||
/** Where a draft-only item's draft row actually lives. It is parked at a
|
||||
* generated path while the drawer edits the name the user sees, so the two
|
||||
* can't be the same string. Empty for a deployed item, which is addressed by
|
||||
* `initialPath` throughout. */
|
||||
let storagePath = $state('')
|
||||
let rawApp = $state(false)
|
||||
let draftOnly = $derived(storagePath !== '')
|
||||
|
||||
let kind = $state<Kind>('flow')
|
||||
let initialPath = $state('')
|
||||
let initialSummary = $state('')
|
||||
@@ -66,21 +74,31 @@
|
||||
})
|
||||
let attachedTotal = $derived(attachedSummary.reduce((s, { count }) => s + count, 0))
|
||||
|
||||
/** `draft` marks an item that exists only as the caller's draft: pass the
|
||||
* generated path its draft row sits at, and `initialPath_l` is then the name
|
||||
* the user sees. Nothing is deployed, so there are no triggers to cascade
|
||||
* and no on-behalf-of identity to warn about. */
|
||||
export async function openDrawer(
|
||||
initialPath_l: string,
|
||||
summary_l: string | undefined,
|
||||
kind_l: Kind
|
||||
kind_l: Kind,
|
||||
draft?: { storagePath: string; rawApp?: boolean }
|
||||
) {
|
||||
kind = kind_l
|
||||
path = undefined
|
||||
dirtyPath = false
|
||||
onBehalfOfEmail = undefined
|
||||
attachedTriggers = undefined
|
||||
storagePath = draft?.storagePath ?? ''
|
||||
rawApp = draft?.rawApp ?? false
|
||||
initialPath = initialPath_l
|
||||
initialSummary = summary_l ?? ''
|
||||
summary = summary_l
|
||||
loadOwner()
|
||||
drawer.openDrawer()
|
||||
if (draftOnly) {
|
||||
return
|
||||
}
|
||||
if (kind === 'flow') {
|
||||
onBehalfOfEmail = await checkFlowOnBehalfOf($workspaceStore!, initialPath_l)
|
||||
}
|
||||
@@ -103,11 +121,18 @@
|
||||
}
|
||||
|
||||
function loadOwner() {
|
||||
own = isOwner(initialPath, $userStore!, $workspaceStore!)
|
||||
own = isOwner(draftOnly ? storagePath : initialPath, $userStore!, $workspaceStore!)
|
||||
}
|
||||
|
||||
async function updatePath() {
|
||||
if (kind === 'flow' || kind === 'script' || kind === 'app') {
|
||||
if (draftOnly && (kind === 'flow' || kind === 'script' || kind === 'app')) {
|
||||
await DraftService.moveDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: kind === 'app' && rawApp ? 'raw_app' : kind,
|
||||
path: storagePath,
|
||||
requestBody: { new_path: path ?? '', summary: summary ?? '' }
|
||||
})
|
||||
} else if (kind === 'flow' || kind === 'script' || kind === 'app') {
|
||||
await updateItemPathAndSummary({
|
||||
workspace: $workspaceStore!,
|
||||
kind,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/**
|
||||
* The draft modals every editor route mounts at its trailer:
|
||||
* - DraftSyncConflictModal: surfaces a 409 from the autosave pipeline.
|
||||
* - DraftMovedModal: the item was moved away from this path mid-edit.
|
||||
* - OtherUsersDraftsModal: other users' drafts at this path, for forking.
|
||||
* - StaleDraftModal: prompts when the user's draft predates the latest
|
||||
* deploy; open-state is computed here from the route's timestamps.
|
||||
@@ -12,6 +13,7 @@
|
||||
*/
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import DraftSyncConflictModal from './DraftSyncConflictModal.svelte'
|
||||
import DraftMovedModal from './DraftMovedModal.svelte'
|
||||
import OtherUsersDraftsModal, { type OtherDraftUser } from './OtherUsersDraftsModal.svelte'
|
||||
import StaleDraftModal from './StaleDraftModal.svelte'
|
||||
import ConfirmationModal from './ConfirmationModal.svelte'
|
||||
@@ -38,13 +40,14 @@
|
||||
draftSavedAt?: string | undefined
|
||||
/** ISO timestamp of the latest deploy at this path. */
|
||||
deployedAt?: string | undefined
|
||||
/** Precise staleness inputs (flows/apps): the deployed version the draft was
|
||||
* forked from, and the current deployed head. When both are set they drive
|
||||
* `isStale` and the dedup key instead of the timestamps — exact, and stable
|
||||
* across autosaves (the timestamp drifts past `deployedAt` as you keep
|
||||
* editing). Absent (pre-feature drafts, scripts) ⇒ timestamp fallback. */
|
||||
draftBaseVersion?: number | undefined
|
||||
deployedHeadVersion?: number | undefined
|
||||
/** Precise staleness inputs: the deployed version the draft was forked from,
|
||||
* and the current deployed head. When both are set they drive `isStale` and
|
||||
* the dedup key instead of the timestamps — exact, and stable across
|
||||
* autosaves (the timestamp drifts past `deployedAt` as you keep editing).
|
||||
* Flows/apps pass version ids; scripts pass hashes, which are strings.
|
||||
* Absent (pre-feature drafts) ⇒ timestamp fallback. */
|
||||
draftBaseVersion?: number | string | undefined
|
||||
deployedHeadVersion?: number | string | undefined
|
||||
/** Discard the draft and reload deployed (same as "Reset to deployed"). */
|
||||
onLoadLatestDeploy?: () => void | Promise<void>
|
||||
/** Defaults to true; set to false to suppress all modals. */
|
||||
@@ -113,6 +116,7 @@
|
||||
{onLoadFromServer}
|
||||
{getLocalDraft}
|
||||
/>
|
||||
<DraftMovedModal query={{ workspace, itemKind, path }} {getLocalDraft} />
|
||||
{#if otherDraftsUsers.length > 0}
|
||||
{#key path}
|
||||
<OtherUsersDraftsModal
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Surfaces the "moved" verdict left by `UserDraftDbSyncer.postSave`: someone
|
||||
* moved this item while the editor was open, so the server refused the
|
||||
* autosave rather than plant a phantom draft-only item at the path the item
|
||||
* has left.
|
||||
*
|
||||
* Continuing pushes the current in-memory draft to the new path (force, since
|
||||
* the draft carried over by the move is older) and follows it there, so edits
|
||||
* made after the move aren't lost to the relocation.
|
||||
*/
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$app/navigation'
|
||||
import { UserDraftDbSyncer, type UserDraftLastSyncQuery } from '$lib/userDraftDbSyncer.svelte'
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { FolderInput } from 'lucide-svelte'
|
||||
|
||||
type Props = {
|
||||
query: UserDraftLastSyncQuery
|
||||
/** Current local draft value, re-pointed at the new path before it is
|
||||
* pushed there. */
|
||||
getLocalDraft: () => unknown
|
||||
}
|
||||
|
||||
let { query, getLocalDraft }: Props = $props()
|
||||
|
||||
const moveHandle = $derived(UserDraftDbSyncer.getMove(query))
|
||||
let isOpen = $derived(moveHandle.move !== undefined)
|
||||
let busy = $state(false)
|
||||
|
||||
const EDITOR_SEGMENT: Partial<Record<string, string>> = {
|
||||
script: 'scripts/edit',
|
||||
flow: 'flows/edit',
|
||||
app: 'apps/edit',
|
||||
raw_app: 'apps_raw/edit'
|
||||
}
|
||||
|
||||
/** Mirrors the backend's `UserDraftItemKind::typed_path_field`: a script
|
||||
* draft round-trips its own `path`, every other kind writes `draft_path`. */
|
||||
function repointed(value: unknown, newPath: string): unknown {
|
||||
if (value == undefined || typeof value !== 'object') return value
|
||||
const field = query.itemKind === 'script' ? 'path' : 'draft_path'
|
||||
return { ...(value as Record<string, unknown>), [field]: newPath }
|
||||
}
|
||||
|
||||
async function continueThere() {
|
||||
const move = moveHandle.move
|
||||
if (!move) return
|
||||
busy = true
|
||||
try {
|
||||
const local = getLocalDraft()
|
||||
if (local != undefined) {
|
||||
await UserDraftDbSyncer.overwrite({
|
||||
workspace: query.workspace,
|
||||
itemKind: query.itemKind,
|
||||
path: move.movedTo,
|
||||
value: repointed(local, move.movedTo)
|
||||
})
|
||||
}
|
||||
UserDraftDbSyncer.clearMove(query)
|
||||
const seg = EDITOR_SEGMENT[query.itemKind]
|
||||
if (seg) await goto(`${base}/${seg}/${move.movedTo}`)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal2 bind:isOpen title="This item was moved" fixedWidth="sm" fixedHeight="adaptive">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex gap-3 items-start flex-1">
|
||||
<FolderInput size={20} class="text-blue-500 shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-primary flex flex-col gap-1">
|
||||
<p>
|
||||
{#if moveHandle.move?.movedBy}
|
||||
<span class="font-semibold">{moveHandle.move.movedBy}</span> moved this to
|
||||
{:else}
|
||||
This was moved to
|
||||
{/if}
|
||||
<span class="font-mono text-xs">{moveHandle.move?.movedTo}</span>. Your draft moved with
|
||||
it, so nothing was saved here.
|
||||
</p>
|
||||
<p class="text-xs text-secondary">
|
||||
Continuing takes your current edits to the new path. Staying here leaves them unsaved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
on:click={() => UserDraftDbSyncer.clearMove(query)}
|
||||
>
|
||||
Stay here
|
||||
</Button>
|
||||
<Button variant="accent" size="sm" loading={busy} on:click={continueThere}>
|
||||
Continue at the new path
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal2>
|
||||
@@ -189,6 +189,20 @@
|
||||
if (draft_only) {
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer((app as any).draft_path ?? path, summary, 'app', {
|
||||
storagePath: path,
|
||||
rawApp: !!app.raw_app
|
||||
})
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
|
||||
@@ -211,6 +211,19 @@
|
||||
if (draft_only) {
|
||||
return [
|
||||
...selectMenuItems(rowSelection),
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', {
|
||||
storagePath: path
|
||||
})
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
|
||||
@@ -274,6 +274,19 @@
|
||||
showCode(script.path, script.summary)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderOpen,
|
||||
action: () => {
|
||||
// Addressed by the generated path its draft row sits at, but
|
||||
// named by the path typed in the editor.
|
||||
moveDrawer.openDrawer(script.draft_path ?? script.path, script.summary, 'script', {
|
||||
storagePath: script.path
|
||||
})
|
||||
},
|
||||
disabled: !showEditButton,
|
||||
hide: $userStore?.operator
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
eligible,
|
||||
movedPath,
|
||||
runBulk,
|
||||
sourcePath,
|
||||
type BulkAction,
|
||||
type BulkContext,
|
||||
type BulkOutcome
|
||||
@@ -266,7 +267,7 @@
|
||||
{@const target = moveTarget}
|
||||
{@render pathList(
|
||||
'Will be moved to',
|
||||
pendingItems.map((i) => `${i.path} → ${movedPath(i, target)}`)
|
||||
pendingItems.map((i) => `${sourcePath(i)} → ${movedPath(i, target)}`)
|
||||
)}
|
||||
{/if}
|
||||
{:else if pending === 'discard'}
|
||||
|
||||
@@ -56,10 +56,12 @@ describe('blockedReason', () => {
|
||||
expect(blockedReason('unarchive', item({ archived: false }), admin)).toBeDefined()
|
||||
})
|
||||
|
||||
it('routes a draft-only row to discard, never to move/archive/delete', () => {
|
||||
it('lets a draft-only row move or discard, never archive/delete', () => {
|
||||
const draftOnly = item({ draftOnly: true, isDraft: true })
|
||||
expect(blockedReason('discard', draftOnly, admin)).toBeUndefined()
|
||||
expect(blockedReason('move', draftOnly, admin)).toBeDefined()
|
||||
// Moving one rewrites its own draft row (DraftService.moveDraft) — there is
|
||||
// no deployed path, but there is somewhere for it to go.
|
||||
expect(blockedReason('move', draftOnly, admin)).toBeUndefined()
|
||||
expect(blockedReason('archive', draftOnly, admin)).toBeDefined()
|
||||
expect(blockedReason('delete', draftOnly, admin)).toBeDefined()
|
||||
})
|
||||
@@ -75,4 +77,13 @@ describe('movedPath', () => {
|
||||
expect(movedPath(item({ path: 'f/alpha/sub/x' }), 'f/beta')).toBe('f/beta/sub/x')
|
||||
expect(movedPath(item({ path: 'u/ana/x' }), 'f/beta')).toBe('f/beta/x')
|
||||
})
|
||||
|
||||
it('names a draft-only row by what it displays, not the path it is parked at', () => {
|
||||
const parked = item({
|
||||
draftOnly: true,
|
||||
path: 'u/ana/draft_9f3c',
|
||||
displayPath: 'u/ana/my_script'
|
||||
})
|
||||
expect(movedPath(parked, 'f/beta')).toBe('f/beta/my_script')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,11 +8,17 @@
|
||||
* addresses the deployed row. A draft-only item is therefore not deletable —
|
||||
* there is nothing deployed at its path.
|
||||
*/
|
||||
import { AppService, FlowService, ScriptService } from '$lib/gen'
|
||||
import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import { updateItemPathAndSummary } from '$lib/components/moveRenameManager'
|
||||
import { discardDraft } from '$lib/utils_draft_deploy'
|
||||
import type { BulkItem } from './homeSelection.svelte'
|
||||
|
||||
/** The draft overlay is the one place a raw app is its own kind. */
|
||||
function draftKind(item: BulkItem): UserDraftItemKind {
|
||||
return item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind
|
||||
}
|
||||
|
||||
export type BulkAction = 'move' | 'archive' | 'unarchive' | 'delete' | 'discard'
|
||||
|
||||
export type BulkContext = {
|
||||
@@ -32,7 +38,6 @@ export function blockedReason(
|
||||
const notOwner = 'you are not an owner of this path'
|
||||
switch (action) {
|
||||
case 'move':
|
||||
if (item.draftOnly) return 'a draft-only item has no deployed path to move'
|
||||
if (item.archived) return 'archived items cannot be moved'
|
||||
if (!item.owner) return notOwner
|
||||
if (!item.canWrite) return 'you do not have write permission on this path'
|
||||
@@ -64,17 +69,35 @@ export function eligible(action: BulkAction, items: BulkItem[], ctx: BulkContext
|
||||
return items.filter((i) => blockedReason(action, i, ctx) == undefined)
|
||||
}
|
||||
|
||||
/** The path a move reads from. A draft-only item is parked at a generated
|
||||
* storage path but named by what was typed in the editor, and it is that name
|
||||
* the user expects to find under the target. */
|
||||
export function sourcePath(item: BulkItem): string {
|
||||
return item.draftOnly ? item.displayPath : item.path
|
||||
}
|
||||
|
||||
/** Where an item lands under `target` (`f/<folder>` or `u/<user>`): everything
|
||||
* below its own owner prefix is preserved, so nested paths keep their shape. */
|
||||
export function movedPath(item: BulkItem, target: string): string {
|
||||
const rest = item.path.split('/').slice(2).join('/')
|
||||
const rest = sourcePath(item).split('/').slice(2).join('/')
|
||||
return `${target}/${rest}`
|
||||
}
|
||||
|
||||
async function moveItem(ctx: BulkContext, item: BulkItem, target: string): Promise<void> {
|
||||
const newPath = movedPath(item, target)
|
||||
// Re-saving a script at its current path would mint a pointless new version.
|
||||
if (newPath === item.path) return
|
||||
if (newPath === sourcePath(item)) return
|
||||
if (item.draftOnly) {
|
||||
// Nothing is deployed at this path, so there is no deploy to re-run: the
|
||||
// item IS its draft row, and moving it rewrites that row.
|
||||
await DraftService.moveDraft({
|
||||
workspace: ctx.workspace,
|
||||
kind: draftKind(item),
|
||||
path: item.path,
|
||||
requestBody: { new_path: newPath }
|
||||
})
|
||||
return
|
||||
}
|
||||
await updateItemPathAndSummary({
|
||||
workspace: ctx.workspace,
|
||||
kind: item.kind,
|
||||
@@ -117,10 +140,15 @@ async function deleteItem(ctx: BulkContext, item: BulkItem): Promise<void> {
|
||||
}
|
||||
|
||||
async function discardItemDraft(ctx: BulkContext, item: BulkItem): Promise<void> {
|
||||
// The draft overlay is the one place a raw app is its own kind.
|
||||
const kind = item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind
|
||||
// invalidate=false: the caller refreshes the draft list once for the batch.
|
||||
const res = await discardDraft(kind, item.path, ctx.workspace, item.draftOnly, false, false)
|
||||
const res = await discardDraft(
|
||||
draftKind(item),
|
||||
item.path,
|
||||
ctx.workspace,
|
||||
item.draftOnly,
|
||||
false,
|
||||
false
|
||||
)
|
||||
if (!res.success) throw new Error(res.error ?? 'discard failed')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ export async function checkFlowOnBehalfOf(
|
||||
*
|
||||
* Note: on_behalf_of_email is intentionally omitted from flow updates for security
|
||||
* reasons — the backend will redeploy the flow on behalf of the current user.
|
||||
*
|
||||
* `skip_draft_deletion` on every call: this re-deploys the DEPLOYED content at a
|
||||
* new path, so the caller's draft is unrelated work, not the thing being
|
||||
* deployed. Without the flag the backend would delete it. The backend carries
|
||||
* every remaining draft at the old path over to the new one.
|
||||
*/
|
||||
export async function updateItemPathAndSummary(opts: {
|
||||
workspace: string
|
||||
@@ -47,7 +52,8 @@ export async function updateItemPathAndSummary(opts: {
|
||||
dedicated_worker: flow.dedicated_worker,
|
||||
ws_error_handler_muted: flow.ws_error_handler_muted,
|
||||
visible_to_runner_only: flow.visible_to_runner_only,
|
||||
labels
|
||||
labels,
|
||||
skip_draft_deletion: true
|
||||
}
|
||||
})
|
||||
} else if (kind === 'script') {
|
||||
@@ -61,7 +67,8 @@ export async function updateItemPathAndSummary(opts: {
|
||||
lock: script.lock,
|
||||
parent_hash: script.hash,
|
||||
path: newPath,
|
||||
labels
|
||||
labels,
|
||||
skip_draft_deletion: true
|
||||
}
|
||||
})
|
||||
} else if (kind === 'app') {
|
||||
@@ -71,7 +78,8 @@ export async function updateItemPathAndSummary(opts: {
|
||||
requestBody: {
|
||||
path: newPath !== initialPath ? newPath : undefined,
|
||||
summary: newSummary,
|
||||
labels
|
||||
labels,
|
||||
skip_draft_deletion: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,6 +129,12 @@ export type DraftConflictInfo = {
|
||||
localLastSync: string | null
|
||||
}
|
||||
|
||||
/** Where an item went after someone moved it, as reported by a refused save. */
|
||||
export type DraftMovedInfo = {
|
||||
movedTo: string
|
||||
movedBy: string | undefined
|
||||
}
|
||||
|
||||
export type UserDraftLastSyncQuery = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
@@ -214,6 +220,14 @@ const syncLocked = new Map<string, (() => void) | undefined>()
|
||||
*/
|
||||
const conflicts = new SvelteMap<string, DraftConflictInfo>()
|
||||
|
||||
/**
|
||||
* Keys whose item was MOVED out from under an editor still bound to the old
|
||||
* path. The server refuses the write (saving would plant a phantom draft-only
|
||||
* item where the item no longer is) and answers with where it went; read via
|
||||
* `getMove(query)` to prompt the user over there.
|
||||
*/
|
||||
const moves = new SvelteMap<string, DraftMovedInfo>()
|
||||
|
||||
/**
|
||||
* Draft keys whose last save threw (network / 5xx) → extracted error
|
||||
* message. Cleared on the next success. Drives the AutosaveIndicator's
|
||||
@@ -294,6 +308,14 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
force: opts.force ?? false
|
||||
}
|
||||
})
|
||||
if (resp.status === 'moved') {
|
||||
// Nothing was written. Like a conflict, `lastSync` stays put so the
|
||||
// state survives every retry until the user acts on it.
|
||||
if (resp.moved_to) {
|
||||
moves.set(key, { movedTo: resp.moved_to, movedBy: resp.moved_by })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (resp.status === 'conflict') {
|
||||
// Someone advanced the row past our `last_sync`. Park the
|
||||
// snapshot for the UI; do NOT touch `lastSync` — the next save
|
||||
@@ -317,6 +339,7 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
// free instead of maintaining a separate source of truth.
|
||||
setLocalDraftHint(opts.workspace, opts.itemKind, opts.path, opts.value !== null)
|
||||
conflicts.delete(key)
|
||||
moves.delete(key)
|
||||
failures.delete(key)
|
||||
// Clear pending only if it's still the opts we just saved — a
|
||||
// newer `save()` that arrived during the POST replaces the entry
|
||||
@@ -537,6 +560,7 @@ export const UserDraftDbSyncer = {
|
||||
}
|
||||
// Back in sync with the server: clear any conflict / failure.
|
||||
conflicts.delete(key)
|
||||
moves.delete(key)
|
||||
failures.delete(key)
|
||||
},
|
||||
|
||||
@@ -614,6 +638,22 @@ export const UserDraftDbSyncer = {
|
||||
conflicts.delete(draftKey(query.workspace, query.itemKind, query.path))
|
||||
},
|
||||
|
||||
/** Reactive "the item moved away from this path" snapshot, if any. */
|
||||
getMove(query: UserDraftLastSyncQuery): {
|
||||
readonly move: DraftMovedInfo | undefined
|
||||
} {
|
||||
const key = draftKey(query.workspace, query.itemKind, query.path)
|
||||
return {
|
||||
get move() {
|
||||
return moves.get(key)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearMove(query: UserDraftLastSyncQuery): void {
|
||||
moves.delete(draftKey(query.workspace, query.itemKind, query.path))
|
||||
},
|
||||
|
||||
/**
|
||||
* Force-save: bypass the `last_sync` check and overwrite the server row
|
||||
* (conflict modal's "Overwrite the remote"). Resolves once the key's save
|
||||
|
||||
@@ -106,6 +106,10 @@
|
||||
* (our draft is behind the latest deploy). Cleared between loads to re-fire. */
|
||||
let draftSavedAt = $state<string | undefined>(undefined)
|
||||
let deployedAt = $state<string | undefined>(undefined)
|
||||
/** Hash the draft forked from, and the deployed head — the script equivalent
|
||||
* of the flow/app version pair. */
|
||||
let draftBaseHash = $state<string | undefined>(undefined)
|
||||
let deployedHeadHash = $state<string | undefined>(undefined)
|
||||
|
||||
// Remounts ScriptBuilder on nav: false while a reload runs, true once data is
|
||||
// ready. A synchronous `{#key}` swap instead races Monaco's init against the
|
||||
@@ -150,6 +154,8 @@
|
||||
loadedFromDraft = false
|
||||
draftSavedAt = undefined
|
||||
deployedAt = undefined
|
||||
draftBaseHash = undefined
|
||||
deployedHeadHash = undefined
|
||||
// Brand-new script: no deployed baseline, so never discard-on-equal.
|
||||
deployedBaseline = undefined
|
||||
const templatePath = page.url.searchParams.get('template')
|
||||
@@ -323,6 +329,11 @@
|
||||
// field level: the draft supplies editor state (content, summary, …),
|
||||
// the deployed supplies metadata it lacks (hash, version markers).
|
||||
const { draft: draftFromBackend, ...deployedScript } = backendScript as any
|
||||
// Exact staleness, preferred over the timestamps: a draft carried across
|
||||
// a move keeps its old save time while the move mints a fresh deploy, so
|
||||
// the timestamps alone would call every carried draft stale.
|
||||
draftBaseHash = draftFromBackend?.parent_hash as string | undefined
|
||||
deployedHeadHash = backendScript.hash as string | undefined
|
||||
const effectiveScript: EditableScript = draftFromBackend
|
||||
? { ...deployedScript, ...draftFromBackend }
|
||||
: (deployedScript as EditableScript)
|
||||
@@ -464,6 +475,8 @@
|
||||
bind:othersModalOpen
|
||||
{draftSavedAt}
|
||||
{deployedAt}
|
||||
draftBaseVersion={draftBaseHash}
|
||||
deployedHeadVersion={deployedHeadHash}
|
||||
onLoadLatestDeploy={async () => {
|
||||
// stopSync-bracketed; see restoreDeployed for the race.
|
||||
if (!$workspaceStore) return
|
||||
|
||||
Reference in New Issue
Block a user