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
This commit is contained in:
Ruben Fiszel
2026-09-04 00:53:52 +02:00
co-authored by Claude Fable 5.1
parent 6a7a6d9144
commit 81a0773edf
11 changed files with 513 additions and 50 deletions
@@ -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'
@@ -328,6 +329,46 @@
}
let pathError = $state('')
let pathDirty = $state(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,
touched: () =>
pathDirty ||
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
})
})
export async function open(rt?: string) {
if (!rt) {
@@ -952,6 +993,7 @@
}
})
}
newDraftSync.finish()
dispatch('refresh', path)
dispatch('close')
sendUserToast(
@@ -964,6 +1006,12 @@
}
export async function back() {
if (step == 2 && manual) {
// Back abandons this form; the draft it mirrored goes with it.
newDraftSync.finish()
newDraftSync.reset()
pathDirty = false
}
if (step == 4) {
step -= 2
} else if (step > 1) {
@@ -1295,6 +1343,7 @@
<ResourcePathHint />
<Path
bind:error={pathError}
bind:dirty={pathDirty}
bind:path
initialPath=""
namePlaceholder={resourceType}
@@ -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,26 @@
})
)
let pathError = $state('')
let pathDirty = $state(false)
// A new resource's handle is keyed on the empty `initialPath`, so it is
// detached and never POSTs; the form is mirrored under the typed path
// instead. Inert in edit mode.
const newDraftSync = useNewItemDraftSync<ResourceState>({
itemKind: 'resource',
enabled: () => !initialPath,
workspace: () => selected,
path: () => current?.path ?? '',
pathError: () => pathError,
touched: () =>
pathDirty ||
(!!current &&
!!selected &&
!draftValuesEqual({ ...current, path: '' }, { ...initialStates[selected], path: '' })),
value: () => (current ? ($state.snapshot(current) as ResourceState) : undefined)
})
// New-resource bootstrap: seed empty state per workspace (edit mode
// is seeded by the lazy-fetch effect below).
$effect(() => {
@@ -210,7 +236,8 @@
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)
@@ -231,22 +258,39 @@
// `.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
existedInitially[ws] = !noDeployed
perWsUser[ws] = user
// Keep resource_type in sync for the base workspace (controls the schema)
if (ws === effectiveWorkspace) {
@@ -268,7 +312,7 @@
})
$effect(() => {
canSave = anyDirty && dirtyValid && dirtyCanWrite
canSave = anyDirty && dirtyValid && dirtyCanWrite && pathError === ''
})
// Drive the parent drawer's "unsaved changes" banner. The drawer chrome
@@ -287,11 +331,27 @@
export function localDraftCurrent(): ResourceState | undefined {
return current
}
export function discardLocalDraft(): void {
if (!selected) return
UserDraft.discard('resource', initialPath ?? '', initialStates[selected], {
workspace: selected
/** 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: no baseline to fall back to. Blank the cell so the form
// unmounts — mounted on the empty state it re-fills the path from
// `initialPath`, and that autosave would displace the delete. Flushed so
// the list refetch on drawer close no longer finds the row.
UserDraft.remove('resource', initialPath ?? '', { workspace: selected })
await UserDraftDbSyncer.flush({
workspace: selected,
itemKind: 'resource',
path: initialPath ?? ''
})
return true
}
$effect(() => {
@@ -355,11 +415,15 @@
}
})
}
// 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
existedInitially[ws] = true
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
if (initialPath) {
// 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 })
} else {
newDraftSync.finish()
}
// Path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(ws)
@@ -386,6 +450,8 @@
{#key current}
<ResourceForm
bind:path={() => current!.path, setPath}
bind:pathError
bind:pathDirty
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,7 @@
save: () => void
localDraftDeployed: () => unknown
localDraftCurrent: () => unknown
discardLocalDraft: () => void
discardLocalDraft: () => Promise<boolean>
}
| undefined = $state(undefined)
let hasLocalDraft = $state(false)
@@ -97,7 +101,10 @@
bind:this={drawer}
size="50rem"
{disableChatOffset}
on:close={() => clearPageDrawerAnchor(RESOURCES_PATH)}
on:close={() => {
clearPageDrawerAnchor(RESOURCES_PATH)
onClose?.()
}}
>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
@@ -131,7 +138,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,10 @@
path: string
initialPath: string
hidePath?: boolean
/** `Path`'s validation error (`''` when valid). */
pathError?: string
/** Whether the user edited the path (as opposed to `Path`'s auto-filled name). */
pathDirty?: boolean
labels: string[] | undefined
description: string
args: Record<string, any>
@@ -53,6 +57,8 @@
path = $bindable(),
initialPath,
hidePath = false,
pathError = $bindable(''),
pathDirty = $bindable(false),
labels = $bindable(),
description = $bindable(),
args = $bindable(),
@@ -160,6 +166,8 @@
<Path
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
bind:path
bind:error={pathError}
bind:dirty={pathDirty}
{initialPath}
namePlaceholder="resource"
kind="resource"
@@ -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,7 @@
let perWsUser: Record<string, UserExt | undefined> = $state({})
let selected: string | undefined = $state(undefined)
let pathError = $state('')
let pathDirty = $state(false)
const handlesArray = UserDraft.useMany<VariableState>(() =>
workspaceSpecs.map((s) => ({
@@ -116,6 +119,23 @@
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
)
// A new variable's handle is keyed on the empty `editPath`, so it is
// detached and never POSTs; the form is mirrored under the typed path
// instead. Inert in edit mode.
const newDraftSync = useNewItemDraftSync<VariableState>({
itemKind: 'variable',
enabled: () => !edit,
workspace: () => selected,
path: () => current?.path ?? '',
pathError: () => pathError,
touched: () =>
pathDirty ||
(!!current &&
!!selected &&
!draftValuesEqual({ ...current, path: '' }, { ...initialStates[selected], path: '' })),
value: () => (current ? ($state.snapshot(current) as VariableState) : undefined)
})
// 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,25 +196,34 @@
]).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
existedInitially[ws] = !noDeployed
extraPerms[ws] = v.extra_perms ?? {}
perWsUser[ws] = user
})
@@ -210,6 +239,8 @@
extraPerms = {}
perWsUser = {}
pathError = ''
pathDirty = false
newDraftSync.reset()
}
export function initNew(): void {
@@ -238,7 +269,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,
@@ -293,7 +325,11 @@
// the server draft row so `is_draft` clears on refetch.
initialStates[ws] = $state.snapshot(s) as VariableState
existedInitially[ws] = true
UserDraft.discard('variable', editPath ?? '', s, { workspace: ws })
if (editPath) {
UserDraft.discard('variable', editPath, s, { workspace: ws })
} else {
newDraftSync.finish()
}
// Path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(ws)
@@ -307,7 +343,16 @@
}
</script>
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
<Drawer
bind:this={drawer}
size="50rem"
on:close={() => {
clearPageDrawerAnchor(VARIABLES_PATH)
// A new variable left unsaved persists as a draft-only row, which a
// list only sees on refetch.
dispatch('close')
}}
>
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
bannerReserved={edit}
@@ -319,11 +364,25 @@
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: no baseline to fall back to. Blank the cell so the form
// unmounts — mounted on the empty state it re-fills the path from
// `initialPath`, and that autosave would displace the delete. Flushed
// so the list refetch on drawer close no longer finds the row.
UserDraft.remove('variable', editPath ?? '', { workspace: selected })
await UserDraftDbSyncer.flush({
workspace: selected,
itemKind: 'variable',
path: editPath ?? ''
})
drawer?.closeDrawer()
}}
disabled={!can_write}
/>
@@ -347,6 +406,7 @@
bind:this={form}
bind:path={current.path}
bind:pathError
bind:pathDirty
bind:variable={current.variable}
bind:labels={current.labels}
bind:wsSpecific={current.wsSpecific}
@@ -25,6 +25,8 @@
path: string
initialPath: string
pathError: string
/** Whether the user edited the path (as opposed to `Path`'s auto-filled name). */
pathDirty?: boolean
variable: Variable
labels: string[] | undefined
wsSpecific: boolean
@@ -40,6 +42,7 @@
path = $bindable(),
initialPath,
pathError = $bindable(),
pathDirty = $bindable(false),
variable = $bindable(),
labels = $bindable(),
wsSpecific = $bindable(),
@@ -73,6 +76,7 @@
<Path
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
bind:error={pathError}
bind:dirty={pathDirty}
bind:path
{initialPath}
namePlaceholder="variable"
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushSync } from 'svelte'
const save = vi.fn()
const remove = vi.fn()
vi.mock('$lib/userDraft.svelte', () => ({
UserDraft: {
save: (...a: unknown[]) => save(...a),
remove: (...a: unknown[]) => remove(...a)
}
}))
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,
touched: () => 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(remove).toHaveBeenCalledWith('resource', 'u/me/auto_name', { workspace: 'w' })
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' })
form.pathError = 'path already used'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(remove).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { workspace: 'w' })
expect(draftPath).toBe('')
form.pathError = ''
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' })
cleanup()
expect(remove).toHaveBeenCalledTimes(2)
})
it('finish deletes the persisted key and stops mirroring until reset', () => {
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: () => '',
touched: () => form.touched,
value: () => ({ n: form.n })
})
})
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
expect(save).toHaveBeenCalledTimes(1)
sync!.finish()
flushSync()
expect(remove).toHaveBeenCalledWith('variable', 'u/me/item', { workspace: 'w' })
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(remove).toHaveBeenCalledTimes(1)
cleanup()
})
})
@@ -0,0 +1,96 @@
import { untrack } from 'svelte'
import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.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 name or the content. `Path` auto-fills a
* name on mount, so opening and closing an untouched drawer must not leave
* a draft behind. */
touched: () => 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
}
export interface NewItemDraftSync {
/** Storage path of the persisted draft, `''` when none. */
readonly draftPath: string
/** Delete the persisted draft and stop mirroring, once the item is created. */
finish(): 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 {
let draftPath = $state('')
let finished = $state(false)
// Last key actually written: a moved or finished draft deletes exactly the
// row it left behind, and component teardown deletes nothing.
let writtenPath = ''
$effect(() => {
if (!opts.enabled() || finished) return
const p = opts.path()
const target = p !== '' && opts.pathError() === '' && opts.touched() ? p : ''
if (target === untrack(() => draftPath)) return
const t = setTimeout(() => (draftPath = target), COMMIT_DELAY_MS)
return () => clearTimeout(t)
})
$effect(() => {
if (!opts.enabled() || finished) return
const ws = opts.workspace()
const p = draftPath
const v = opts.value()
untrack(() => {
if (!ws) return
if (writtenPath && writtenPath !== p) {
UserDraft.remove(opts.itemKind, writtenPath, { workspace: ws })
writtenPath = ''
}
if (!p || v === undefined) return
UserDraft.save(opts.itemKind, p, v, { workspace: ws })
writtenPath = p
})
})
return {
get draftPath() {
return draftPath
},
finish() {
finished = true
const ws = untrack(() => opts.workspace())
if (writtenPath && ws) UserDraft.remove(opts.itemKind, writtenPath, { workspace: ws })
writtenPath = ''
draftPath = ''
},
reset() {
finished = false
writtenPath = ''
draftPath = ''
}
}
}
@@ -1,5 +1,6 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.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 +315,24 @@
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.
await UserDraftDbSyncer.save({
workspace: $workspaceStore!,
itemKind: 'resource',
path,
value: null,
immediate: true
})
reload()
return
}
if (account) {
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
}
@@ -1313,12 +1331,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 +1495,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,6 @@
<script lang="ts">
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import {
Alert,
@@ -217,7 +218,25 @@
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.
await UserDraftDbSyncer.save({
workspace: $workspaceStore!,
itemKind: 'variable',
path,
value: null,
immediate: true
})
loadVariables()
sendUserToast(`Draft ${path} was deleted`)
return
}
if (account) {
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
}
@@ -316,7 +335,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 +582,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'] } }
}
}
]