Compare commits

...
Author SHA1 Message Date
Ruben FiszelandClaude Opus 5 1b95662f49 fix(frontend): keep a draft-only rename consistent with its storage key
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG
2026-09-04 02:01:43 +02:00
Ruben FiszelandClaude Opus 5 eb6e12f0d2 fix(frontend): keep autosaving a reopened draft-only item
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG
2026-09-04 01:45:06 +02:00
Ruben FiszelandClaude Opus 5 7512783fde refactor(frontend): let the draft helper own draft-only items too
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG
2026-09-04 01:25:21 +02:00
Ruben FiszelandClaude Opus 5 33063fbf25 fix(frontend): validate and settle draft keys around close and save
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG
2026-09-04 00:59:21 +02:00
Ruben FiszelandClaude Opus 5 ed1ccc05b9 fix(frontend): settle new-item drafts before the drawer closes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG
2026-09-04 00:53:52 +02:00
Ruben FiszelandClaude Fable 5.1 81a0773edf feat(frontend): autosave drafts for new resources and variables
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG
2026-09-04 00:53:52 +02:00
11 changed files with 1113 additions and 52 deletions
@@ -73,9 +73,14 @@
<Drawer
bind:this={drawer}
on:close={() => {
on:close={async () => {
// Flushed before the step reset (which retires the form the draft mirrors)
// and before `close`, whose list refetch would otherwise outrun the draft's
// own commit delay and the syncer's debounce, and miss the new row.
const flushed = appConnectInner?.flushDraft()
step = 1
handedOff = false
await flushed
dispatch('close')
}}
size="700px"
@@ -23,6 +23,7 @@
import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry'
import { createEventDispatcher, onDestroy, tick, untrack } from 'svelte'
import Path from './Path.svelte'
import { useNewItemDraftSync } from './useNewItemDraftSync.svelte'
import { Button, RadioCard, Skeleton } from './common'
import ApiConnectForm from './ApiConnectForm.svelte'
import SearchItems from './SearchItems.svelte'
@@ -329,6 +330,54 @@
let pathError = $state('')
async function resourcePathIsFree(p: string): Promise<boolean> {
try {
return !(await ResourceService.existsResource({ workspace: effectiveWorkspace, path: p }))
} catch {
// Fail closed: an unanswered check is not evidence the path is free.
return false
}
}
// Fields saved as linked secret variables never enter the draft: a resource
// draft is stored as-is, without the encryption those variables get.
function draftArgs(): Record<string, any> {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries($state.snapshot(args) ?? {})) {
out[k] = linkedSecrets.includes(k) ? '' : v
}
return out
}
// The manual step is a brand-new resource: mirror the form to a draft keyed
// by the typed path, so a closed drawer can be picked up from the resources
// list (draft-only row → editor). Filling an existing path is not a draft.
const newDraftSync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => step == 2 && manual && !fillPath,
workspace: () => effectiveWorkspace,
path: () => path,
pathError: () => pathError,
contentTouched: () =>
description !== '' ||
(labels?.length ?? 0) > 0 ||
wsSpecific ||
Object.entries(draftArgs()).some(
([k, v]) =>
v !== '' &&
v !== undefined &&
v !== (resourceTypeInfo?.schema as any)?.properties?.[k]?.default
),
value: () => ({
path,
description,
args: draftArgs(),
labels,
wsSpecific,
resource_type: resourceType
}),
pathIsFree: resourcePathIsFree
})
export async function open(rt?: string) {
if (!rt) {
loadResourceTypes()
@@ -952,6 +1001,9 @@
}
})
}
// Awaited: `refresh` refetches the list, and a debounced delete would
// leave the just-created resource still flagged as a draft.
await newDraftSync.finish()
dispatch('refresh', path)
dispatch('close')
sendUserToast(
@@ -963,7 +1015,20 @@
}
}
/** Settle the pending draft write before the drawer's close event, whose
* list refetch would otherwise outrun the debounced POST. */
export async function flushDraft(): Promise<void> {
await newDraftSync.flush()
}
export async function back() {
if (step == 2 && manual) {
// Back abandons this form; the draft it mirrored goes with it. Not
// awaited: the step change is the user's feedback and must not wait on
// a POST (`finish` captures what it deletes before returning).
void newDraftSync.finish()
newDraftSync.reset()
}
if (step == 4) {
step -= 2
} else if (step > 1) {
+128 -19
View File
@@ -14,6 +14,8 @@
import type { UserExt } from '$lib/stores'
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { useNewItemDraftSync } from './useNewItemDraftSync.svelte'
interface Props {
canSave?: boolean
@@ -47,12 +49,16 @@
onCanWriteChange
}: Props = $props()
// Persisted as the draft value: the list pages' draft-only rows and the
// global AI chat read this exact shape, `resource_type` included, since a
// draft with no deployed row has nowhere else to carry its type.
type ResourceState = {
path: string
description: string
args: Record<string, any>
labels: string[] | undefined
wsSpecific: boolean
resource_type?: string
}
const dispatch = createEventDispatcher()
@@ -198,6 +204,50 @@
})
)
let pathError = $state('')
async function resourcePathIsFree(ws: string, p: string): Promise<boolean> {
try {
return !(await ResourceService.existsResource({ workspace: ws, path: p }))
} catch {
// Fail closed: an unanswered check is not evidence the path is free.
return false
}
}
// Nothing deployed at this path: the item IS its draft. Its list row is keyed
// by the path inside that draft, so the key has to follow the form's path —
// which is what `useNewItemDraftSync` does and the `useMany` handle can't
// (it is pinned to the path this editor opened).
const draftOnly = $derived(!!selected && existedInitially[selected] === false)
// What the form was opened with: the empty seed for a new resource, the
// draft itself for a draft-only one. Divergence from it is the user's edit.
let openedWith: Record<string, ResourceState> = $state({})
const newDraftSync = useNewItemDraftSync<ResourceState>({
itemKind: 'resource',
enabled: () => draftOnly,
workspace: () => selected,
path: () => current?.path ?? '',
pathError: () => pathError,
contentTouched: () =>
!!current &&
!!selected &&
!!openedWith[selected] &&
!draftValuesEqual({ ...current, path: '' }, { ...openedWith[selected], path: '' }),
value: () => (current ? ($state.snapshot(current) as ResourceState) : undefined),
pathIsFree: (p) => (selected ? resourcePathIsFree(selected, p) : Promise.resolve(false)),
keyed: (v, p) => ({ ...v, path: p }),
onAbandonKey: (ws, p) => {
// The handle is pinned to the path this editor opened; once the draft
// has moved off it, its next write would recreate the row it left.
if (p === initialPath) UserDraft.stopSync('resource', p, { workspace: ws })
},
onResumeKey: (ws, p) => {
if (p === initialPath) UserDraft.restartSync('resource', p, { workspace: ws })
}
})
// New-resource bootstrap: seed empty state per workspace (edit mode
// is seeded by the lazy-fetch effect below).
$effect(() => {
@@ -210,10 +260,12 @@
description: '',
args: (defaultValues && Object.keys(defaultValues).length > 0 ? defaultValues : {}) as any,
labels: undefined,
wsSpecific: false
wsSpecific: false,
resource_type
}
ensureHandle(selected, s)
initialStates[selected] = structuredClone(s)
openedWith[selected] = structuredClone(s)
existedInitially[selected] = false
})
})
@@ -231,22 +283,44 @@
// `.draft` already holds the editor's `ResourceState` shape.
const savedDraftState = (r as any).draft as ResourceState | undefined
fetchedResources[ws] = r
// Draft-only paths (`no_deployed`) have no row — saving must
// CREATE, not update (update 404s).
const noDeployed = !!(r as any).no_deployed
// Deployed baseline as the dirty-check reference, so the banner
// compares draft-vs-deployed and fires immediately when a draft exists.
const deployedState: ResourceState = {
path: r.path,
description: r.description ?? '',
args: (r.value ?? {}) as any,
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false
// A draft-only item has no deployed side: everything in it is unsaved.
const deployedState: ResourceState = noDeployed
? {
path: '',
description: '',
args: {},
labels: undefined,
wsSpecific: false,
resource_type: r.resource_type
}
: {
path: r.path,
description: r.description ?? '',
args: (r.value ?? {}) as any,
labels: r.labels ?? undefined,
wsSpecific: r.ws_specific ?? false,
resource_type: r.resource_type
}
// A draft saved without `resource_type` compares against a baseline
// that has it; fill it in so the field alone can't read as a change.
if (savedDraftState && savedDraftState.resource_type === undefined) {
savedDraftState.resource_type = r.resource_type
}
// Open with the saved draft if present, else the deployed.
const s: ResourceState = savedDraftState ?? deployedState
ensureHandle(ws, s)
initialStates[ws] = structuredClone(deployedState)
// Draft-only paths (`no_deployed`) have no row — saving must
// CREATE, not update (update 404s).
existedInitially[ws] = !(r as any).no_deployed
openedWith[ws] = structuredClone(s)
existedInitially[ws] = !noDeployed
// The helper owns this draft's key from here (see `draftOnly`): the
// handle keeps autosaving it while the path is unchanged, and hands
// the key over the moment a rename moves it.
if (noDeployed) newDraftSync.adopt(ws, initialPath, structuredClone(s))
perWsUser[ws] = user
// Keep resource_type in sync for the base workspace (controls the schema)
if (ws === effectiveWorkspace) {
@@ -268,7 +342,7 @@
})
$effect(() => {
canSave = anyDirty && dirtyValid && dirtyCanWrite
canSave = anyDirty && dirtyValid && dirtyCanWrite && pathError === ''
})
// Drive the parent drawer's "unsaved changes" banner. The drawer chrome
@@ -287,11 +361,34 @@
export function localDraftCurrent(): ResourceState | undefined {
return current
}
export function discardLocalDraft(): void {
if (!selected) return
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
workspace: selected
/** Settle every pending draft write. The drawer awaits this before its
* `onClose`, whose list refetch would otherwise outrun the debounced POST. */
export async function flushDraft(): Promise<void> {
await newDraftSync.flush()
}
/** Returns true when the item was draft-only: discarding deleted it
* outright, so there is nothing left for the editor to show. */
export async function discardLocalDraft(): Promise<boolean> {
if (!selected) return false
if (existedInitially[selected]) {
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
workspace: selected
})
return false
}
// Draft-only: the item is the draft, so discarding deletes it. `finish`
// drops it wherever the helper keyed it and settles that before the
// caller's list refetch; `remove` then blanks the cell so the form
// unmounts, and clears the key this editor opened if the two differ.
await newDraftSync.finish()
UserDraft.remove('resource', initialPath ?? '', { workspace: selected })
await UserDraftDbSyncer.flush({
workspace: selected,
itemKind: 'resource',
path: initialPath ?? ''
})
return true
}
$effect(() => {
@@ -326,7 +423,8 @@
for (const ws of dirty) {
const s = states[ws].draft!
const ini = initialStates[ws]
if (existedInitially[ws]) {
const wasDeployed = existedInitially[ws]
if (wasDeployed) {
await ResourceService.updateResource({
workspace: ws,
path: ini.path,
@@ -355,11 +453,21 @@
}
})
}
// Reset the handle to the new deployed baseline via `discard`, not
// `remove`. See VariableEditor for the full rationale.
initialStates[ws] = $state.snapshot(s) as ResourceState
openedWith[ws] = $state.snapshot(s) as ResourceState
existedInitially[ws] = true
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
// Both awaited: the caller refetches the list right after, and a
// debounced delete would bring the just-deployed item back as a draft.
if (wasDeployed) {
// Reset the handle to the new deployed baseline via `discard`, not
// `remove`. See VariableEditor for the full rationale.
UserDraft.discard('resource', initialPath, s, { workspace: ws })
await UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'resource', path: initialPath })
} else {
// The helper held this draft (new or draft-only), wherever it keyed it.
await newDraftSync.finish()
if (initialPath) UserDraft.restartSync('resource', initialPath, { workspace: ws })
}
// Path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(ws)
@@ -386,6 +494,7 @@
{#key current}
<ResourceForm
bind:path={() => current!.path, setPath}
bind:pathError
bind:labels={current.labels}
bind:description={current.description}
bind:args={current.args}
@@ -23,7 +23,8 @@
workspace = undefined,
disableChatOffset = false,
onRestored = undefined,
onSaved = undefined
onSaved = undefined,
onClose = undefined
}: {
workspace?: string
disableChatOffset?: boolean
@@ -31,6 +32,9 @@
/** Fires after Save has written, for a caller showing state derived from the
* resource — `onRestored` only covers restoring an old version. */
onSaved?: () => void
/** Fires whenever the drawer closes, saved or not: a new resource left
* unsaved persists as a draft-only row, which a list only sees on refetch. */
onClose?: () => void
} = $props()
let drawer: Drawer | undefined = $state()
@@ -44,7 +48,8 @@
save: () => void
localDraftDeployed: () => unknown
localDraftCurrent: () => unknown
discardLocalDraft: () => void
discardLocalDraft: () => Promise<boolean>
flushDraft: () => Promise<void>
}
| undefined = $state(undefined)
let hasLocalDraft = $state(false)
@@ -97,7 +102,13 @@
bind:this={drawer}
size="50rem"
{disableChatOffset}
on:close={() => clearPageDrawerAnchor(RESOURCES_PATH)}
on:close={async () => {
clearPageDrawerAnchor(RESOURCES_PATH)
// Before `onClose`: its list refetch would otherwise outrun the draft's
// own commit delay and the syncer's debounce, and miss the new row.
await resourceEditor?.flushDraft()
onClose?.()
}}
>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
@@ -131,7 +142,10 @@
reserveSpace={mode == 'edit'}
getDeployed={() => resourceEditor?.localDraftDeployed()}
getCurrent={() => resourceEditor?.localDraftCurrent()}
onDiscard={() => resourceEditor?.discardLocalDraft()}
onDiscard={async () => {
// A draft-only resource is gone once discarded; nothing is left to edit.
if (await resourceEditor?.discardLocalDraft()) drawer?.closeDrawer()
}}
disabled={!canWriteSelected}
/>
{/snippet}
@@ -29,6 +29,8 @@
path: string
initialPath: string
hidePath?: boolean
/** `Path`'s validation error (`''` when valid). */
pathError: string
labels: string[] | undefined
description: string
args: Record<string, any>
@@ -53,6 +55,7 @@
path = $bindable(),
initialPath,
hidePath = false,
pathError = $bindable(),
labels = $bindable(),
description = $bindable(),
args = $bindable(),
@@ -160,6 +163,7 @@
<Path
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
bind:path
bind:error={pathError}
{initialPath}
namePlaceholder="resource"
kind="resource"
+123 -19
View File
@@ -26,6 +26,8 @@
import LocalDraftBanner from './LocalDraftBanner.svelte'
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { useNewItemDraftSync } from './useNewItemDraftSync.svelte'
const dispatch = createEventDispatcher()
@@ -56,6 +58,18 @@
let perWsUser: Record<string, UserExt | undefined> = $state({})
let selected: string | undefined = $state(undefined)
let pathError = $state('')
// What the form was opened with: the empty seed for a new variable, the
// draft itself for a draft-only one. Divergence from it is the user's edit.
let openedWith: Record<string, VariableState> = $state({})
async function variablePathIsFree(ws: string, p: string): Promise<boolean> {
try {
return !(await VariableService.existsVariable({ workspace: ws, path: p }))
} catch {
// Fail closed: an unanswered check is not evidence the path is free.
return false
}
}
const handlesArray = UserDraft.useMany<VariableState>(() =>
workspaceSpecs.map((s) => ({
@@ -116,6 +130,36 @@
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
)
// Nothing deployed at this path: the item IS its draft. Its list row is keyed
// by the path inside that draft, so the key has to follow the form's path —
// which is what `useNewItemDraftSync` does and the `useMany` handle can't
// (it is pinned to the path this editor opened).
const draftOnly = $derived(!!selected && existedInitially[selected] === false)
const newDraftSync = useNewItemDraftSync<VariableState>({
itemKind: 'variable',
enabled: () => draftOnly,
workspace: () => selected,
path: () => current?.path ?? '',
pathError: () => pathError,
contentTouched: () =>
!!current &&
!!selected &&
!!openedWith[selected] &&
!draftValuesEqual({ ...current, path: '' }, { ...openedWith[selected], path: '' }),
value: () => (current ? ($state.snapshot(current) as VariableState) : undefined),
pathIsFree: (p) => (selected ? variablePathIsFree(selected, p) : Promise.resolve(false)),
keyed: (v, p) => ({ ...v, path: p }),
onAbandonKey: (ws, p) => {
// The handle is pinned to the path this editor opened; once the draft
// has moved off it, its next write would recreate the row it left.
if (p === editPath) UserDraft.stopSync('variable', p, { workspace: ws })
},
onResumeKey: (ws, p) => {
if (p === editPath) UserDraft.restartSync('variable', p, { 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
@@ -176,31 +220,51 @@
]).then(([v, user]) => {
// `.draft` already holds the editor's `VariableState` shape.
const savedDraftState = (v as any).draft as VariableState | undefined
// Draft-only paths (`no_deployed`) have no row — saving must
// CREATE, not update (update 404s).
const noDeployed = !!(v as any).no_deployed
// Deployed baseline as the dirty-check reference, so the banner
// compares draft-vs-deployed and fires immediately when a draft exists.
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
}
// A draft-only item has no deployed side: everything in it is unsaved.
const deployedState: VariableState = noDeployed
? {
path: '',
variable: { value: '', is_secret: true, description: '' },
labels: undefined,
wsSpecific: false
}
: {
path: v.path,
variable: {
value: v.value ?? '',
is_secret: v.is_secret,
description: v.description ?? ''
},
labels: v.labels ?? undefined,
wsSpecific: v.ws_specific ?? false
}
// Open with the saved draft if present, else the deployed.
const s: VariableState = savedDraftState ?? deployedState
ensureHandle(ws, s)
initialStates[ws] = structuredClone(deployedState)
// Draft-only paths (`no_deployed`) have no row — saving must
// CREATE, not update (update 404s).
existedInitially[ws] = !(v as any).no_deployed
openedWith[ws] = structuredClone(s)
existedInitially[ws] = !noDeployed
// The helper owns this draft's key from here (see `draftOnly`): the
// handle keeps autosaving it while the path is unchanged, and hands
// the key over the moment a rename moves it.
if (noDeployed) newDraftSync.adopt(ws, p, structuredClone(s))
extraPerms[ws] = v.extra_perms ?? {}
perWsUser[ws] = user
})
})
})
/** Settle every pending draft write before the drawer's close event, whose
* list refetch would otherwise outrun the debounced POST. */
async function flushDraft(): Promise<void> {
await newDraftSync.flush()
}
function reset() {
// Clearing workspaceSpecs triggers useMany's reconcile to release
// every acquired entry. The $derived `states` then collapses to {}.
@@ -210,6 +274,8 @@
extraPerms = {}
perWsUser = {}
pathError = ''
openedWith = {}
newDraftSync.reset()
}
export function initNew(): void {
@@ -224,6 +290,7 @@
}
ensureHandle(ws, s)
initialStates[ws] = structuredClone(s)
openedWith[ws] = structuredClone(s)
existedInitially[ws] = false
selected = ws
drawer?.openDrawer()
@@ -238,7 +305,8 @@
}
async function loadSecret(): Promise<void> {
if (!editPath || !selected) return
// A draft-only variable has no deployed secret to load.
if (!editPath || !selected || !existedInitially[selected]) return
const getV = await VariableService.getVariable({
workspace: selected,
path: editPath,
@@ -257,6 +325,7 @@
for (const ws of dirty) {
const s = states[ws].draft!
const ini = initialStates[ws]
const wasDeployed = existedInitially[ws]
if (existedInitially[ws]) {
await VariableService.updateVariable({
workspace: ws,
@@ -292,8 +361,18 @@
// `undefined` reads as dirty). The `value: null` POST also deletes
// the server draft row so `is_draft` clears on refetch.
initialStates[ws] = $state.snapshot(s) as VariableState
openedWith[ws] = $state.snapshot(s) as VariableState
existedInitially[ws] = true
UserDraft.discard('variable', editPath ?? '', s, { workspace: ws })
// Both awaited: the caller refetches the list right after, and a
// debounced delete would bring the just-deployed item back as a draft.
if (wasDeployed) {
UserDraft.discard('variable', editPath!, s, { workspace: ws })
await UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'variable', path: editPath! })
} else {
// The helper held this draft (new or draft-only), wherever it keyed it.
await newDraftSync.finish()
if (editPath) UserDraft.restartSync('variable', editPath, { workspace: ws })
}
// Path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(ws)
@@ -307,7 +386,17 @@
}
</script>
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
<Drawer
bind:this={drawer}
size="50rem"
on:close={async () => {
clearPageDrawerAnchor(VARIABLES_PATH)
// A new variable left unsaved persists as a draft-only row, which a list
// only sees on refetch — settle the write before asking for that refetch.
await flushDraft()
dispatch('close')
}}
>
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
bannerReserved={edit}
@@ -319,11 +408,26 @@
reserveSpace={edit}
getDeployed={() => (selected ? initialStates[selected] : undefined)}
getCurrent={() => current}
onDiscard={() => {
onDiscard={async () => {
if (!selected) return
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
workspace: selected
if (existedInitially[selected]) {
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
workspace: selected
})
return
}
// Draft-only: the item is the draft, so discarding deletes it. `finish`
// drops it wherever the helper keyed it and settles that before the
// caller's list refetch; `remove` then blanks the cell so the form
// unmounts, and clears the key this editor opened if the two differ.
await newDraftSync.finish()
UserDraft.remove('variable', editPath ?? '', { workspace: selected })
await UserDraftDbSyncer.flush({
workspace: selected,
itemKind: 'variable',
path: editPath ?? ''
})
drawer?.closeDrawer()
}}
disabled={!can_write}
/>
@@ -0,0 +1,447 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushSync } from 'svelte'
const save = vi.fn()
const remove = vi.fn()
const discard = vi.fn()
const forcePersist = vi.fn(async () => {})
const flush = vi.fn(async () => {})
vi.mock('$lib/userDraft.svelte', () => ({
UserDraft: {
save: (...a: unknown[]) => save(...a),
remove: (...a: unknown[]) => remove(...a),
discard: (...a: unknown[]) => discard(...a),
forcePersist: (...a: unknown[]) => forcePersist(...a)
}
}))
vi.mock('$lib/userDraftDbSyncer.svelte', () => ({
UserDraftDbSyncer: { flush: (...a: unknown[]) => flush(...(a as [])) }
}))
import { useNewItemDraftSync } from './useNewItemDraftSync.svelte'
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
/** Drives the helper through a new-item drawer session and pins the writes it
* must and must not make: nothing for an untouched form (the path field
* auto-fills a name on mount), a move that deletes the key it left, a delete
* once the path fails validation, and no delete at all on teardown — a draft
* left behind by closing the drawer is the feature. */
describe('useNewItemDraftSync', () => {
it('writes only a touched form, follows the path, and leaves the draft on teardown', () => {
const form = $state({ path: 'u/me/auto_name', pathError: '', touched: false, n: 1 })
let draftPath = ''
const cleanup = $effect.root(() => {
const sync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => form.pathError,
contentTouched: () => form.touched,
value: () => ({ n: form.n })
})
$effect(() => {
draftPath = sync.draftPath
})
})
flushSync()
vi.advanceTimersByTime(2000)
flushSync()
expect(save).not.toHaveBeenCalled()
form.touched = true
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(save).toHaveBeenCalledWith('resource', 'u/me/auto_name', { n: 1 }, { workspace: 'w' })
expect(draftPath).toBe('u/me/auto_name')
form.n = 2
flushSync()
expect(save).toHaveBeenLastCalledWith(
'resource',
'u/me/auto_name',
{ n: 2 },
{ workspace: 'w' }
)
form.path = 'u/me/renamed'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(discard.mock.calls[0].slice(0, 2)).toEqual(['resource', 'u/me/auto_name'])
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' })
form.pathError = 'path already used'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['resource', 'u/me/renamed'])
expect(draftPath).toBe('')
form.pathError = ''
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' })
cleanup()
expect(discard).toHaveBeenCalledTimes(2)
})
/** The commit is delayed so the key can't land on a half-typed path, and the
* editor is destroyed the moment its drawer closes. Closing right after the
* first edit therefore tears down mid-delay, and the draft must survive it. */
it('persists a commit still pending when the editor is torn down', async () => {
const form = $state({ path: 'u/me/quick', touched: false, n: 1 })
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
const cleanup = $effect.root(() => {
sync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => '',
contentTouched: () => form.touched,
value: () => ({ n: form.n })
})
})
flushSync()
form.touched = true
flushSync()
// Torn down half-way through the commit delay.
vi.advanceTimersByTime(500)
cleanup()
vi.advanceTimersByTime(500)
expect(save).toHaveBeenCalledWith('resource', 'u/me/quick', { n: 1 }, { workspace: 'w' })
// The drawer's close handler awaits this, so the list refetch behind it
// sees the row rather than racing the syncer's debounce.
await sync!.flush()
expect(flush).toHaveBeenCalledWith({
workspace: 'w',
itemKind: 'resource',
path: 'u/me/quick'
})
})
/** A close cuts the commit delay short, so `Path`'s debounced existence
* check may not have run. Keying a draft on an occupied path would hand it
* to the item already there, and saving from that item would overwrite it. */
it('refuses to commit a forced flush onto an occupied path', async () => {
const form = $state({ path: 'u/me/taken', touched: false, n: 1 })
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
const cleanup = $effect.root(() => {
sync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
// Still clear: the check that would set it has not run yet.
pathError: () => '',
contentTouched: () => form.touched,
value: () => ({ n: form.n }),
pathIsFree: async () => false
})
})
flushSync()
form.touched = true
flushSync()
await sync!.flush()
expect(save).not.toHaveBeenCalled()
cleanup()
})
/** A move deletes the key it left on the same debounce as the write, so the
* close-time flush has to settle both or the list refetch renders a ghost
* row at the old path. */
it('settles the key a move deleted, not just the one it wrote', async () => {
const form = $state({ path: 'u/me/first', touched: true, n: 1 })
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
const cleanup = $effect.root(() => {
sync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => '',
contentTouched: () => form.touched,
value: () => ({ n: form.n })
})
})
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
form.path = 'u/me/second'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
await sync!.flush()
const flushed = flush.mock.calls.map((c: any[]) => c[0].path)
expect(flushed).toContain('u/me/first')
expect(flushed).toContain('u/me/second')
cleanup()
})
/** A draft-only item arrives with a draft already stored under the path the
* editor opened. Renaming it has to MOVE that row, so the helper must be
* told which key it inherited or it would leave a second one behind. */
it('moves an adopted key on rename instead of leaving it behind', async () => {
const form = $state({ path: 'u/me/adopted', n: 1 })
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
const cleanup = $effect.root(() => {
sync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => '',
contentTouched: () => false,
value: () => ({ n: form.n })
})
sync.adopt('w', 'u/me/adopted', { n: 1 })
})
flushSync()
form.path = 'u/me/moved'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
await sync!.flush()
expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['resource', 'u/me/adopted'])
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/moved', { n: 1 }, { workspace: 'w' })
cleanup()
})
/** The list synthesizes a draft-only row from the path INSIDE the draft while
* get and delete address the key, so a stored draft has to describe its own
* key. A rename the draft can't follow yet must not smuggle the new path
* into the row it is still living in. */
it('stores a draft describing the key it lives under, not an unusable path', async () => {
const form = $state({ path: 'u/me/home', pathError: '', n: 1 })
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
const cleanup = $effect.root(() => {
sync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => form.pathError,
contentTouched: () => false,
value: () => ({ path: form.path, n: form.n }),
keyed: (v, p) => ({ ...v, path: p })
})
sync.adopt('w', 'u/me/home', { path: 'u/me/home', n: 1 })
})
flushSync()
// Renamed to a path the draft cannot move to, then edited.
form.path = 'u/me/taken'
form.pathError = 'path already used'
form.n = 2
flushSync()
vi.advanceTimersByTime(2000)
flushSync()
await sync!.flush()
expect(save).toHaveBeenLastCalledWith(
'resource',
'u/me/home',
{ path: 'u/me/home', n: 2 },
{ workspace: 'w' }
)
})
/** Renaming away suspends the handle pinned to the original key; renaming
* back has to resume it, or the draft is deleted at both keys and written
* to neither. */
it('resumes a key it returns to after abandoning it', async () => {
const form = $state({ path: 'u/me/there', n: 1 })
const events: string[] = []
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
const cleanup = $effect.root(() => {
sync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => '',
contentTouched: () => false,
value: () => ({ n: form.n }),
onAbandonKey: (_ws, p) => events.push(`abandon:${p}`),
onResumeKey: (_ws, p) => events.push(`resume:${p}`)
})
sync.adopt('w', 'u/me/there', { n: 1 })
})
flushSync()
form.path = 'u/me/away'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
form.path = 'u/me/there'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
await sync!.flush()
expect(events).toEqual(['abandon:u/me/there', 'abandon:u/me/away', 'resume:u/me/there'])
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/there', { n: 1 }, { workspace: 'w' })
// The resumed handle's own change detection can no-op this write away.
expect(forcePersist).toHaveBeenCalledWith('resource', 'u/me/there', { workspace: 'w' })
cleanup()
})
/** An editor's own autosave handle stays pinned to the path it opened, so
* once the draft moves the helper has to hand that key back for suspension —
* otherwise the handle's next write recreates the row just deleted. */
it('reports the key it abandons so a pinned handle can be suspended', async () => {
const form = $state({ path: 'u/me/pinned', n: 1 })
const abandoned: string[] = []
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
const cleanup = $effect.root(() => {
sync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => '',
contentTouched: () => false,
value: () => ({ n: form.n }),
onAbandonKey: (_ws, p) => abandoned.push(p)
})
sync.adopt('w', 'u/me/pinned', { n: 1 })
})
flushSync()
// Untouched: the key is still in use, nothing handed back.
vi.advanceTimersByTime(2000)
flushSync()
expect(abandoned).toEqual([])
form.path = 'u/me/elsewhere'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
await sync!.flush()
expect(abandoned).toEqual(['u/me/pinned'])
cleanup()
})
/** An adopted draft exists whether or not the user edits it, so the
* touched gate that keeps an untouched NEW item from leaving a row must not
* apply — deleting here would wipe the item the editor is showing. An
* invalid path likewise leaves it where it is rather than dropping it. */
it('keeps an adopted draft through an untouched open and an invalid path', async () => {
const form = $state({ path: 'u/me/kept', pathError: '', n: 1 })
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
const cleanup = $effect.root(() => {
sync = useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => form.pathError,
contentTouched: () => false,
value: () => ({ n: form.n })
})
sync.adopt('w', 'u/me/kept', { n: 1 })
})
flushSync()
vi.advanceTimersByTime(2000)
flushSync()
expect(discard).not.toHaveBeenCalled()
form.pathError = 'path already used'
flushSync()
vi.advanceTimersByTime(2000)
flushSync()
await sync!.flush()
expect(discard).not.toHaveBeenCalled()
expect(sync!.draftPath).toBe('u/me/kept')
cleanup()
})
/** `Path` auto-fills a unique name on mount and flips its own `dirty` on any
* keyup, tabbing included. Only a departure from that name counts, or an
* untouched drawer would leave a phantom row behind. */
it('treats the auto-filled path as untouched but a typed one as an edit', () => {
const form = $state({ path: '', n: 1 })
const cleanup = $effect.root(() => {
useNewItemDraftSync({
itemKind: 'resource',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => '',
contentTouched: () => false,
value: () => ({ n: form.n })
})
})
flushSync()
// `Path` fills its generated name in after mount.
form.path = 'u/me/lucky_resource'
flushSync()
vi.advanceTimersByTime(2000)
flushSync()
expect(save).not.toHaveBeenCalled()
form.path = 'u/me/typed_by_hand'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(save).toHaveBeenCalledWith(
'resource',
'u/me/typed_by_hand',
{ n: 1 },
{ workspace: 'w' }
)
cleanup()
})
it('finish deletes the persisted key and stops mirroring until reset', async () => {
const form = $state({ path: 'u/me/item', touched: true, n: 1 })
let sync: ReturnType<typeof useNewItemDraftSync> | undefined
const cleanup = $effect.root(() => {
sync = useNewItemDraftSync({
itemKind: 'variable',
enabled: () => true,
workspace: () => 'w',
path: () => form.path,
pathError: () => '',
contentTouched: () => form.touched,
value: () => ({ n: form.n })
})
})
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(save).toHaveBeenCalledTimes(1)
await sync!.finish()
expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['variable', 'u/me/item'])
form.n = 2
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(save).toHaveBeenCalledTimes(1)
sync!.reset()
form.n = 3
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(save).toHaveBeenLastCalledWith('variable', 'u/me/item', { n: 3 }, { workspace: 'w' })
expect(discard).toHaveBeenCalledTimes(1)
cleanup()
})
})
@@ -0,0 +1,267 @@
import { untrack } from 'svelte'
import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
/** Longer than `Path`'s 500ms debounced existence check, so the key never
* lands on a half-typed path and the check's verdict is in before a commit. */
const COMMIT_DELAY_MS = 1000
export interface NewItemDraftSyncOptions<V> {
itemKind: UserDraftItemKind
/** Reactive: false leaves the helper inert (edit mode — the handle syncs). */
enabled: () => boolean
/** Reactive workspace the draft is stored in. */
workspace: () => string | undefined
/** Reactive path field (`''` while none). */
path: () => string
/** Reactive `Path` validation error (`''` when valid). */
pathError: () => string
/** Reactive: the user edited the form's content. The path is not part of
* this — `Path` auto-fills a name on mount, and this helper tracks a
* departure from that name itself. */
contentTouched: () => boolean
/** Reactive deep read of the value to persist (`$state.snapshot` of the
* form state); `undefined` while there is nothing to persist. */
value: () => V | undefined
/** Whether nothing is deployed at `path` yet. Consulted before every commit:
* `Path`'s own check is debounced and may not have answered. */
pathIsFree?: (path: string) => Promise<boolean>
/** Called for a key this helper has stopped writing to, after its row is
* deleted. An editor whose own autosave handle is pinned to that key MUST
* suspend it here, or the handle's next write would recreate the row. */
onAbandonKey?: (workspace: string, path: string) => void
/** Called before writing to a key previously passed to `onAbandonKey`, so
* the editor can resume the handle it suspended there. */
onResumeKey?: (workspace: string, path: string) => void
/** Return `value` with its own path set to `path`. A stored draft has to
* describe the key it lives under: the list synthesizes a draft-only row
* from the path INSIDE the draft, while get and delete address the key, so
* letting the two diverge makes the row unreachable. Divergence is normal
* while the form holds a path the draft cannot move to yet. */
keyed?: (value: V, path: string) => V
}
export interface NewItemDraftSync<V> {
/** Storage path of the persisted draft, `''` when none. */
readonly draftPath: string
/** Take ownership of a draft that already exists at `path` — a draft-only
* item the editor loaded. Without this the helper believes it has written
* nothing, and a rename would add a second row instead of moving this one. */
adopt(workspace: string, path: string, value: V): void
/** Commit anything still pending and settle it server-side. Callers MUST
* await this before a list refetch (both the commit delay and the syncer's
* own debounce outlive a closing drawer, so a refetch would miss the row). */
flush(): Promise<void>
/** Delete the persisted draft and stop mirroring, once the item is created. */
finish(): Promise<void>
/** Re-arm for the next drawer session (an editor instance that outlives
* its drawer). Forgets the previous session's key without deleting it: a
* draft left behind by closing the drawer is the point. */
reset(): void
}
/**
* Server-side autosave for a drawer editor's brand-new item. Those editors
* key their `useMany` handle on the path they were opened with, which is
* empty for a new item, so the handle is detached and never POSTs. This
* mirrors the form into a draft keyed by the typed path instead — the key
* the list pages' draft-only rows and the get-by-path draft overlay resolve —
* and moves it (delete the old key, write the new) as the path changes.
*/
export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewItemDraftSync<V> {
let draftPath = $state('')
let finished = $state(false)
// The key last written, workspace included: a move or a delete has to target
// the row actually left behind, not wherever the form points now.
let written: { workspace: string; path: string } | undefined
let writtenValue: string | undefined
// Every key this session has touched and not yet settled — the deletes a
// move leaves behind included, since those POST on the same debounce as the
// write and would otherwise still be pending when the list refetches.
let unsettled: { workspace: string; path: string }[] = []
// The commit the timer will make, snapshotted at schedule time so it still
// lands once the editor is gone: closing a drawer a keystroke after the
// first edit must keep the draft, so a pending commit is never cancelled by
// teardown — only superseded by a newer one, or consumed by `flush`.
let pending:
| {
timer: ReturnType<typeof setTimeout>
workspace: string | undefined
key: string
value: V | undefined
}
| undefined
// `Path` auto-fills a unique name on mount, so a non-empty path is no
// evidence the user did anything. Only a departure from the name it settled
// on counts (`Path.dirty` can't: it flips on any keyup, tabbing included).
let autoPath: string | undefined
// An adopted draft already exists: it is kept regardless of whether the user
// edits anything, and an invalid path leaves it where it is rather than
// deleting it. Only a brand-new item's draft is gated on being touched.
let adopted = false
// Keys handed to `onAbandonKey`, so a return to one can resume it.
const abandoned = new Set<string>()
function markUnsettled(workspace: string, path: string): void {
if (!unsettled.some((k) => k.workspace === workspace && k.path === path)) {
unsettled.push({ workspace, path })
}
}
function touched(): boolean {
const p = opts.path()
if (autoPath === undefined && p !== '') autoPath = p
return opts.contentTouched() || (p !== '' && p !== autoPath)
}
function write(workspace: string | undefined, path: string, value: V | undefined): void {
if (written && (written.path !== path || written.workspace !== workspace)) {
// `discard`, not `remove`: an adopted key is the editor's own handle key,
// and `remove` blanks that live cell — the form would lose its state
// mid-rename. The fallback leaves the cell holding what the form holds.
UserDraft.discard(opts.itemKind, written.path, value, { workspace: written.workspace })
opts.onAbandonKey?.(written.workspace, written.path)
abandoned.add(`${written.workspace}/${written.path}`)
markUnsettled(written.workspace, written.path)
written = undefined
writtenValue = undefined
}
if (!workspace || !path || value === undefined) return
// Stored describing its own key: while the form holds a path the draft
// cannot move to yet, the row must still name where it actually lives.
const stored = opts.keyed ? opts.keyed(value, path) : value
const serialized = JSON.stringify(stored)
if (written && serialized === writtenValue) return
const resumed = abandoned.delete(`${workspace}/${path}`)
if (resumed) opts.onResumeKey?.(workspace, path)
UserDraft.save(opts.itemKind, path, stored, { workspace })
if (resumed) {
// A live handle at this key mirrors CHANGES, and its baseline advanced
// while it was suspended — the value we just restored can equal it, so
// nothing would be sent and the row we deleted on the way out would
// never come back. Safe here: only a never-deployed draft is keyed this
// way, so there is no baseline this could overwrite.
void UserDraft.forcePersist(opts.itemKind, path, { workspace })
}
markUnsettled(workspace, path)
written = { workspace, path }
writtenValue = serialized
}
function dropPending(): void {
if (!pending) return
clearTimeout(pending.timer)
pending = undefined
}
/** Validate the pending key, then take it. The timer is cleared first and
* the payload kept, so the validation below can't race a second commit of
* the same transition; a newer transition scheduled meanwhile wins. */
async function commitPending(): Promise<void> {
const p = pending
if (!p) return
clearTimeout(p.timer)
if (p.key) {
// `Path` debounces its own existence check and may not have answered
// yet, so the key is verified here rather than trusted: keying a draft
// on a path that already holds an item would take it as an edit of
// that item, and saving from there would overwrite its value.
const free =
opts.pathError() === '' && (opts.pathIsFree ? await opts.pathIsFree(p.key) : true)
if (pending !== p) return
if (!free) {
pending = undefined
return
}
}
pending = undefined
draftPath = p.key
write(p.workspace, p.key, p.value)
}
$effect(() => {
if (!opts.enabled() || finished) return
const p = opts.path()
const usable = p !== '' && opts.pathError() === ''
const key = usable && (adopted || touched()) ? p : adopted ? untrack(() => draftPath) : ''
const workspace = opts.workspace()
const value = opts.value()
if (key === untrack(() => draftPath)) {
// Back on the committed key: a transition away from it is stale.
untrack(dropPending)
return
}
untrack(() => {
dropPending()
pending = {
timer: setTimeout(() => void commitPending(), COMMIT_DELAY_MS),
workspace,
key,
value
}
})
})
$effect(() => {
if (!opts.enabled() || finished) return
const workspace = opts.workspace()
const key = draftPath
const value = opts.value()
untrack(() => {
if (key) write(workspace, key, value)
})
})
async function settle(): Promise<void> {
const keys = unsettled
unsettled = []
await Promise.all(
keys.map((k) =>
UserDraftDbSyncer.flush({ workspace: k.workspace, itemKind: opts.itemKind, path: k.path })
)
)
}
return {
get draftPath() {
return draftPath
},
adopt(workspace: string, path: string, value: V) {
written = { workspace, path }
writtenValue = JSON.stringify(value)
autoPath = path
draftPath = path
adopted = true
},
async flush() {
await commitPending()
await settle()
},
async finish() {
finished = true
dropPending()
const w = written
written = undefined
writtenValue = undefined
draftPath = ''
if (w) {
// See `write`: an adopted key is a live handle key, so keep its cell.
UserDraft.discard(opts.itemKind, w.path, opts.value(), { workspace: w.workspace })
opts.onAbandonKey?.(w.workspace, w.path)
markUnsettled(w.workspace, w.path)
}
await settle()
},
reset() {
finished = false
adopted = false
abandoned.clear()
dropPending()
written = undefined
writtenValue = undefined
unsettled = []
autoPath = undefined
draftPath = ''
}
}
}
@@ -1,5 +1,7 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { page } from '$app/state'
import AppConnect from '$lib/components/AppConnectDrawer.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
@@ -314,7 +316,25 @@
loading.types = false
}
async function deleteResource(path: string, account?: number): Promise<void> {
async function deleteResource(
path: string,
account?: number,
draftOnly?: boolean
): Promise<void> {
if (draftOnly) {
// The row is the user's own draft and nothing else: the resource delete
// would 404 on the missing deployed row. `UserDraft.remove` rather than a
// bare POST so the in-memory cache is evicted too — the global AI chat
// lists drafts from it and would keep offering this one.
UserDraft.remove('resource', path, { workspace: $workspaceStore! })
await UserDraftDbSyncer.flush({
workspace: $workspaceStore!,
itemKind: 'resource',
path
})
reload()
return
}
if (account) {
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
}
@@ -1313,12 +1333,12 @@
// TODO
// @ts-ignore
if (event?.shiftKey) {
deleteResource(path, account)
deleteResource(path, account, draft_only)
} else {
deleteIsLinked = is_linked ?? false
deletePath = path
deleteConfirmedCallback = () => {
deleteResource(path, account)
deleteResource(path, account, draft_only)
}
}
}
@@ -1477,13 +1497,14 @@
{/if}
<SupabaseConnect bind:this={supabaseConnect} on:refresh={loadResources} />
<AppConnect bind:this={appConnect} on:refresh={loadResources} />
<AppConnect bind:this={appConnect} on:refresh={loadResources} on:close={loadResources} />
<AgentEvalModal agentPath={evalsAgentPath} bind:open={evalsOpen} />
<ResourceEditorDrawer
bind:this={resourceEditor}
on:refresh={loadResources}
onRestored={loadResources}
onClose={loadResources}
/>
<ShareModal
@@ -1,5 +1,7 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import {
Alert,
@@ -217,7 +219,26 @@
loading.contextual = false
}
async function deleteVariable(path: string, account?: number): Promise<void> {
async function deleteVariable(
path: string,
account?: number,
draftOnly?: boolean
): Promise<void> {
if (draftOnly) {
// The row is the user's own draft and nothing else: the variable delete
// would 404 on the missing deployed row. `UserDraft.remove` rather than a
// bare POST so the in-memory cache is evicted too — the global AI chat
// lists drafts from it and would keep offering this one.
UserDraft.remove('variable', path, { workspace: $workspaceStore! })
await UserDraftDbSyncer.flush({
workspace: $workspaceStore!,
itemKind: 'variable',
path
})
loadVariables()
sendUserToast(`Draft ${path} was deleted`)
return
}
if (account) {
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
}
@@ -316,7 +337,7 @@
</PageHeader>
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
<VariableEditor bind:this={variableEditor} on:create={loadVariables} />
<VariableEditor bind:this={variableEditor} on:create={loadVariables} on:close={loadVariables} />
<ContextualVariableEditor
bind:this={contextualVariableEditor}
on:update={loadContextualVariables}
@@ -563,11 +584,11 @@
type: 'delete',
action: (event) => {
if (event['shiftKey']) {
deleteVariable(path, account)
deleteVariable(path, account, draft_only)
} else {
deleteIsLinked = is_linked ?? false
deleteConfirmedCallback = () => {
deleteVariable(path, account)
deleteVariable(path, account, draft_only)
}
}
},
+5 -1
View File
@@ -240,10 +240,14 @@ const config = {
// replay sanitization) whose contracts can only be asserted against a
// real document.
extends: './vite.config.js',
// Node resolution picks svelte's server entry, where `$effect` is inert;
// `*.svelte.dom.test.ts` rune modules need the browser one to run effects.
resolve: { conditions: ['browser'] },
test: {
name: 'dom',
environment: 'jsdom',
include: ['src/**/*.dom.{test,spec}.{js,ts}']
include: ['src/**/*.dom.{test,spec}.{js,ts}'],
server: { deps: { inline: ['svelte'] } }
}
}
]