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:
Guilhem Lemouel
2026-08-07 18:51:02 +02:00
parent 4157795162
commit 6e4d8065b1
12 changed files with 298 additions and 24 deletions
+29 -4
View File
@@ -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