mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 16:02:59 +00:00
feat(sessions): host schedule, resource and variable editors in process
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
co-authored by
Claude Opus 5
parent
c37f59e22a
commit
cc8d55eb57
@@ -23,7 +23,8 @@
|
||||
workspace = undefined,
|
||||
disableChatOffset = false,
|
||||
onRestored = undefined,
|
||||
onSaved = undefined
|
||||
onSaved = undefined,
|
||||
useDrawer = true
|
||||
}: {
|
||||
workspace?: string
|
||||
disableChatOffset?: boolean
|
||||
@@ -31,6 +32,13 @@
|
||||
/** Fires after Save has written, for a caller showing state derived from the
|
||||
* resource — `onRestored` only covers restoring an old version. */
|
||||
onSaved?: () => void
|
||||
/**
|
||||
* False renders the editor in place instead of in a drawer, for a host that
|
||||
* gives it a pane of its own (a session's resource tab). Same convention as
|
||||
* the trigger editors. `initEdit` still selects what is shown; there is no
|
||||
* drawer to open, so it simply takes effect.
|
||||
*/
|
||||
useDrawer?: boolean
|
||||
} = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
@@ -118,93 +126,126 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
size="50rem"
|
||||
{disableChatOffset}
|
||||
on:close={() => {
|
||||
if (keepAnchorOnClose) {
|
||||
keepAnchorOnClose = false
|
||||
return
|
||||
}
|
||||
clearPageDrawerAnchor(RESOURCES_PATH)
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
|
||||
bannerReserved={mode == 'edit'}
|
||||
on:close={drawer?.closeDrawer}
|
||||
{#snippet editorBody()}
|
||||
{#await import('./ResourceEditor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{path}
|
||||
{resource_type}
|
||||
{defaultValues}
|
||||
{workspace}
|
||||
on:refresh
|
||||
bind:this={resourceEditor}
|
||||
bind:canSave
|
||||
bind:selected
|
||||
bind:viewJsonSchema
|
||||
onDraftStateChange={(v) => (hasLocalDraft = v)}
|
||||
onCanWriteChange={(v) => (canWriteSelected = v)}
|
||||
/>
|
||||
{/await}
|
||||
{/snippet}
|
||||
|
||||
{#snippet draftBanner()}
|
||||
<LocalDraftBanner
|
||||
show={hasLocalDraft}
|
||||
reserveSpace={mode == 'edit'}
|
||||
getDeployed={() => resourceEditor?.localDraftDeployed()}
|
||||
getCurrent={() => resourceEditor?.localDraftCurrent()}
|
||||
onDiscard={() => resourceEditor?.discardLocalDraft()}
|
||||
disabled={!canWriteSelected}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet editorActions()}
|
||||
<!-- Only the drawer offers the hand-off: rendered inline the editor is
|
||||
already inside the session it would open. -->
|
||||
{#if useDrawer}
|
||||
<OpenInSessionButton source={sessionSource} />
|
||||
{/if}
|
||||
{#if mode == 'edit' && path && effectiveWorkspace}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: History }}
|
||||
on:click={() => historyDrawer?.openDrawer()}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
<WsSpecificVersions
|
||||
kind="resource"
|
||||
workspaceId={effectiveWorkspace}
|
||||
initialPath={path}
|
||||
bind:selected
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Save }}
|
||||
on:click={async () => {
|
||||
// Closed before the write is awaited, the way it always was: `save()` toasts its
|
||||
// own failures and never rejects, so waiting would only add visible lag to every
|
||||
// caller of this drawer. `onSaved` still fires after the write lands.
|
||||
const saved = resourceEditor?.save()
|
||||
drawer?.closeDrawer()
|
||||
await saved
|
||||
onSaved?.()
|
||||
}}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if mode == 'new' && resource_type}
|
||||
<IconedResourceType name={resource_type} silent width="20px" height="20px" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#await import('./ResourceEditor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{path}
|
||||
{resource_type}
|
||||
{defaultValues}
|
||||
{workspace}
|
||||
on:refresh
|
||||
bind:this={resourceEditor}
|
||||
bind:canSave
|
||||
bind:selected
|
||||
bind:viewJsonSchema
|
||||
onDraftStateChange={(v) => (hasLocalDraft = v)}
|
||||
onCanWriteChange={(v) => (canWriteSelected = v)}
|
||||
/>
|
||||
{/await}
|
||||
{#snippet banner()}
|
||||
<LocalDraftBanner
|
||||
show={hasLocalDraft}
|
||||
reserveSpace={mode == 'edit'}
|
||||
getDeployed={() => resourceEditor?.localDraftDeployed()}
|
||||
getCurrent={() => resourceEditor?.localDraftCurrent()}
|
||||
onDiscard={() => resourceEditor?.discardLocalDraft()}
|
||||
disabled={!canWriteSelected}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<OpenInSessionButton source={sessionSource} />
|
||||
{#if mode == 'edit' && path && effectiveWorkspace}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: History }}
|
||||
on:click={() => historyDrawer?.openDrawer()}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
<WsSpecificVersions
|
||||
kind="resource"
|
||||
workspaceId={effectiveWorkspace}
|
||||
initialPath={path}
|
||||
bind:selected
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Save }}
|
||||
on:click={async () => {
|
||||
// Closed before the write is awaited, the way it always was: `save()` toasts its
|
||||
// own failures and never rejects, so waiting would only add visible lag to every
|
||||
// caller of this drawer. `onSaved` still fires after the write lands.
|
||||
const saved = resourceEditor?.save()
|
||||
drawer?.closeDrawer()
|
||||
await saved
|
||||
onSaved?.()
|
||||
}}
|
||||
disabled={!canSave}
|
||||
Save
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#if useDrawer}
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
size="50rem"
|
||||
{disableChatOffset}
|
||||
on:close={() => {
|
||||
if (keepAnchorOnClose) {
|
||||
keepAnchorOnClose = false
|
||||
return
|
||||
}
|
||||
clearPageDrawerAnchor(RESOURCES_PATH)
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
title={mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}
|
||||
bannerReserved={mode == 'edit'}
|
||||
on:close={drawer?.closeDrawer}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if mode == 'new' && resource_type}
|
||||
<IconedResourceType name={resource_type} silent width="20px" height="20px" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{@render editorBody()}
|
||||
{#snippet banner()}
|
||||
{@render draftBanner()}
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{@render editorActions()}
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{:else}
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<div class="flex flex-row items-center gap-2 justify-between px-4 py-2 border-b">
|
||||
<span class="text-sm font-semibold truncate"
|
||||
>{mode == 'edit' ? 'Edit ' + path : addResourceTitle(resource_type)}</span
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
{@render editorActions()}
|
||||
</div>
|
||||
</div>
|
||||
{@render draftBanner()}
|
||||
<div class="flex-1 min-h-0 overflow-auto p-4">
|
||||
{@render editorBody()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Drawer bind:this={historyDrawer} size="1200px">
|
||||
<DrawerContent title="Versions History" on:close={historyDrawer?.closeDrawer} noPadding>
|
||||
|
||||
@@ -39,7 +39,19 @@
|
||||
// The "current" workspace this editor defaults New/Edit actions to. Session
|
||||
// editors pass their acting workspace so secrets are created/updated there
|
||||
// rather than in the navigation workspace. Defaults to $workspaceStore.
|
||||
let { workspace = undefined }: { workspace?: string } = $props()
|
||||
let {
|
||||
workspace = undefined,
|
||||
useDrawer = true
|
||||
}: {
|
||||
workspace?: string
|
||||
/**
|
||||
* False renders the editor in place instead of in a drawer, for a host that
|
||||
* gives it a pane of its own (a session's variable tab). Same convention as
|
||||
* the trigger editors. `editVariable` still selects what is shown; there is
|
||||
* no drawer to open, so it simply takes effect.
|
||||
*/
|
||||
useDrawer?: boolean
|
||||
} = $props()
|
||||
let curWs = $derived(workspace ?? $workspaceStore)
|
||||
|
||||
let editPath: string | undefined = $state(undefined)
|
||||
@@ -307,73 +319,106 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
|
||||
<DrawerContent
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
bannerReserved={edit}
|
||||
on:close={drawer?.closeDrawer}
|
||||
{#snippet draftBanner()}
|
||||
<LocalDraftBanner
|
||||
show={edit && selectedDirty}
|
||||
reserveSpace={edit}
|
||||
getDeployed={() => (selected ? initialStates[selected] : undefined)}
|
||||
getCurrent={() => current}
|
||||
onDiscard={() => {
|
||||
if (!selected) return
|
||||
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
})
|
||||
}}
|
||||
disabled={!can_write}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet editorActions()}
|
||||
<!-- Only the drawer offers the hand-off: rendered inline the editor is
|
||||
already inside the session it would open. -->
|
||||
{#if useDrawer}
|
||||
<OpenInSessionButton source={sessionSource} />
|
||||
{/if}
|
||||
{#if edit && curWs}
|
||||
<WsSpecificVersions kind="variable" workspaceId={curWs} {initialPath} bind:selected />
|
||||
{/if}
|
||||
<Button
|
||||
on:click={save}
|
||||
disabled={!anyDirty || !dirtyValid || !dirtyCanWrite || pathError != ''}
|
||||
startIcon={{ icon: Save }}
|
||||
variant="accent"
|
||||
size="sm"
|
||||
>
|
||||
{#snippet banner()}
|
||||
<LocalDraftBanner
|
||||
show={edit && selectedDirty}
|
||||
reserveSpace={edit}
|
||||
getDeployed={() => (selected ? initialStates[selected] : undefined)}
|
||||
getCurrent={() => current}
|
||||
onDiscard={() => {
|
||||
if (!selected) return
|
||||
UserDraft.discard('variable', editPath ?? '', initialStates[selected], {
|
||||
workspace: selected
|
||||
})
|
||||
}}
|
||||
disabled={!can_write}
|
||||
/>
|
||||
{/snippet}
|
||||
<div class="flex flex-col gap-8 pb-2">
|
||||
{#if !can_write}
|
||||
<Alert type="warning" title="Only read access">
|
||||
You only have read access to this resource and cannot edit it
|
||||
</Alert>
|
||||
{/if}
|
||||
{edit ? 'Update' : 'Save'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#if otherDirty.length > 0}
|
||||
<Alert type="warning" title="Editing multiple workspaces">
|
||||
You are going to edit the value in: {otherDirty.join(', ')}
|
||||
</Alert>
|
||||
{/if}
|
||||
{#snippet editorBody()}
|
||||
<div class="flex flex-col gap-8 pb-2">
|
||||
{#if !can_write}
|
||||
<Alert type="warning" title="Only read access">
|
||||
You only have read access to this resource and cannot edit it
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if current}
|
||||
{#key current}
|
||||
<VariableForm
|
||||
bind:this={form}
|
||||
bind:path={current.path}
|
||||
bind:pathError
|
||||
bind:variable={current.variable}
|
||||
bind:labels={current.labels}
|
||||
bind:wsSpecific={current.wsSpecific}
|
||||
{initialPath}
|
||||
deployTo={deployTo.current}
|
||||
{can_write}
|
||||
{edit}
|
||||
onLoadSecret={loadSecret}
|
||||
{workspace}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<OpenInSessionButton source={sessionSource} />
|
||||
{#if edit && curWs}
|
||||
<WsSpecificVersions kind="variable" workspaceId={curWs} {initialPath} bind:selected />
|
||||
{/if}
|
||||
<Button
|
||||
on:click={save}
|
||||
disabled={!anyDirty || !dirtyValid || !dirtyCanWrite || pathError != ''}
|
||||
startIcon={{ icon: Save }}
|
||||
variant="accent"
|
||||
size="sm"
|
||||
{#if otherDirty.length > 0}
|
||||
<Alert type="warning" title="Editing multiple workspaces">
|
||||
You are going to edit the value in: {otherDirty.join(', ')}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if current}
|
||||
{#key current}
|
||||
<VariableForm
|
||||
bind:this={form}
|
||||
bind:path={current.path}
|
||||
bind:pathError
|
||||
bind:variable={current.variable}
|
||||
bind:labels={current.labels}
|
||||
bind:wsSpecific={current.wsSpecific}
|
||||
{initialPath}
|
||||
deployTo={deployTo.current}
|
||||
{can_write}
|
||||
{edit}
|
||||
onLoadSecret={loadSecret}
|
||||
{workspace}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if useDrawer}
|
||||
<Drawer bind:this={drawer} size="50rem" on:close={() => clearPageDrawerAnchor(VARIABLES_PATH)}>
|
||||
<DrawerContent
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
bannerReserved={edit}
|
||||
on:close={drawer?.closeDrawer}
|
||||
>
|
||||
{#snippet banner()}
|
||||
{@render draftBanner()}
|
||||
{/snippet}
|
||||
{@render editorBody()}
|
||||
{#snippet actions()}
|
||||
{@render editorActions()}
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{:else}
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<div class="flex flex-row items-center gap-2 justify-between px-4 py-2 border-b">
|
||||
<span class="text-sm font-semibold truncate"
|
||||
>{edit ? `Update variable at ${initialPath}` : 'Add a variable'}</span
|
||||
>
|
||||
{edit ? 'Update' : 'Save'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
{@render editorActions()}
|
||||
</div>
|
||||
</div>
|
||||
{@render draftBanner()}
|
||||
<div class="flex-1 min-h-0 overflow-auto p-4">
|
||||
{@render editorBody()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -106,8 +106,10 @@
|
||||
// A live editor shares the runtime store the chat mutates, so generic chat
|
||||
// edits are already reflected — no reload needed. Deploys refresh it via
|
||||
// each editor view's onDeploy → runtime.syncPreviewWithDeployed. So only the
|
||||
// iframe fallback (a separate page) has to be told to refresh.
|
||||
if (slot.kind === 'editor') return
|
||||
// iframe fallback (a separate page) has to be told to refresh. An entity
|
||||
// editor is in-realm too: it holds a live UserDraft handle on the cell the
|
||||
// chat's write seeds, and reloading would discard the user's edits with it.
|
||||
if (slot.kind === 'editor' || slot.kind === 'entity') return
|
||||
try {
|
||||
const win = frame?.contentWindow
|
||||
if (!win) return
|
||||
@@ -310,6 +312,34 @@
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if slot.kind === 'entity' && mounted}
|
||||
<div
|
||||
bind:this={overlayHostEl}
|
||||
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
|
||||
aria-hidden={!active}
|
||||
>
|
||||
<!-- Dynamic import for the same reason as the editors above: these pull in
|
||||
the runnable pickers and the resource-type schema forms. -->
|
||||
{#if slot.entityKind === 'trigger_schedule'}
|
||||
{#await import('./ScheduleEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default path={slot.path} {workspaceId} />
|
||||
{/await}
|
||||
{:else if slot.entityKind === 'resource'}
|
||||
{#await import('./ResourceEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default path={slot.path} {workspaceId} />
|
||||
{/await}
|
||||
{:else}
|
||||
{#await import('./VariableEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default path={slot.path} {workspaceId} />
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if slot.kind === 'artifact' && mounted}
|
||||
<div
|
||||
bind:this={overlayHostEl}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
let {
|
||||
path,
|
||||
workspaceId
|
||||
}: {
|
||||
/** The resource this tab edits (the row its location deep-links). */
|
||||
path: string
|
||||
/** The session's acting workspace, which the editor operates on instead of
|
||||
* `$workspaceStore` (the nav workspace, which a session leaves put). */
|
||||
workspaceId: string
|
||||
} = $props()
|
||||
|
||||
let editor = $state<ResourceEditorDrawer | undefined>()
|
||||
|
||||
// Re-selects when the tab is pointed at another resource; the component keeps
|
||||
// its identity across that, as it does for the drawer's row-to-row switch.
|
||||
$effect(() => {
|
||||
const p = path
|
||||
const e = editor
|
||||
if (!p || !e) return
|
||||
untrack(() => void e.initEdit(p))
|
||||
})
|
||||
</script>
|
||||
|
||||
<ResourceEditorDrawer bind:this={editor} useDrawer={false} workspace={workspaceId} />
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import ScheduleEditorInner from '$lib/components/triggers/schedules/ScheduleEditorInner.svelte'
|
||||
import { setTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
let {
|
||||
path,
|
||||
workspaceId
|
||||
}: {
|
||||
/** The schedule this tab edits (the row its location deep-links). */
|
||||
path: string
|
||||
/** The session's acting workspace, which the whole trigger subtree reads
|
||||
* through the `triggerWorkspace` resolver instead of `$workspaceStore`. */
|
||||
workspaceId: string
|
||||
} = $props()
|
||||
|
||||
// Captured at init, so it must read the current prop rather than close over it.
|
||||
setTriggerWorkspace(() => workspaceId)
|
||||
|
||||
let editor = $state<ScheduleEditorInner | undefined>()
|
||||
|
||||
// Load whenever the tab is pointed at another schedule; the component keeps
|
||||
// its identity across that, as it does for the drawer's row-to-row switch.
|
||||
// `isFlow` is a first guess only — loadScheduleCfg sets it from the loaded
|
||||
// config — so the tab needs no knowledge of the target beyond the path.
|
||||
$effect(() => {
|
||||
const p = path
|
||||
const e = editor
|
||||
if (!p || !e) return
|
||||
untrack(() => void e.openEdit(p, false))
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="h-full w-full overflow-auto p-4">
|
||||
<!-- useDrawer=false renders the editor as a Section, the same inline form the
|
||||
script/flow trigger panel mounts (see SchedulePanel). -->
|
||||
<ScheduleEditorInner bind:this={editor} useDrawer={false} allowDraft showDraftBanner />
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
let {
|
||||
path,
|
||||
workspaceId
|
||||
}: {
|
||||
/** The variable this tab edits (the row its location deep-links). */
|
||||
path: string
|
||||
/** The session's acting workspace, which the editor operates on instead of
|
||||
* `$workspaceStore` (the nav workspace, which a session leaves put). */
|
||||
workspaceId: string
|
||||
} = $props()
|
||||
|
||||
let editor = $state<VariableEditor | undefined>()
|
||||
|
||||
// Re-selects when the tab is pointed at another variable; the component keeps
|
||||
// its identity across that, as it does for the drawer's row-to-row switch.
|
||||
$effect(() => {
|
||||
const p = path
|
||||
const e = editor
|
||||
if (!p || !e) return
|
||||
untrack(() => e.editVariable(p))
|
||||
})
|
||||
</script>
|
||||
|
||||
<VariableEditor bind:this={editor} useDrawer={false} workspace={workspaceId} />
|
||||
@@ -65,8 +65,11 @@ function triggerPages(kind: unknown): string[] {
|
||||
|
||||
// The open tabs a page-reload should refresh: those whose observed page path is
|
||||
// in `pages`. Item-editor and pipeline tab routes are never list pages, so they
|
||||
// never match (see the self-sync invariant above). Pure over a tab snapshot so
|
||||
// the sessions page can reload by id and this stays unit-testable.
|
||||
// never match (see the self-sync invariant above). An entity-editor tab does
|
||||
// match — its location is a list page with the row in the hash, which the path
|
||||
// comparison drops — but it is in-realm and self-syncing too, so the host's
|
||||
// `reload` ignores it. Pure over a tab snapshot so the sessions page can reload
|
||||
// by id and this stays unit-testable.
|
||||
export function tabsToReload(
|
||||
tabs: SessionPreviewTab[],
|
||||
pages: ReadonlySet<string>
|
||||
|
||||
@@ -291,6 +291,32 @@ describe('resolvePreviewTab', () => {
|
||||
expect(resolvePreviewTab('/apps/edit/f/a/b')).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
// The row in the hash is what separates "edit this entity" from "show me the
|
||||
// list": both are the same page path, and only the first can mount in process.
|
||||
it('routes an anchored row to its in-process entity editor', () => {
|
||||
expect(resolvePreviewTab('/schedules#u/me/daily')).toEqual({
|
||||
kind: 'entity',
|
||||
entityKind: 'trigger_schedule',
|
||||
path: 'u/me/daily'
|
||||
})
|
||||
expect(resolvePreviewTab('/resources#/resource/u/me/db')).toEqual({
|
||||
kind: 'entity',
|
||||
entityKind: 'resource',
|
||||
path: 'u/me/db'
|
||||
})
|
||||
expect(resolvePreviewTab('/variables?owner=u#u/me/token')).toEqual({
|
||||
kind: 'entity',
|
||||
entityKind: 'variable',
|
||||
path: 'u/me/token'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the bare list page, and an anchored page with no hosted editor, on the iframe', () => {
|
||||
expect(resolvePreviewTab('/schedules')).toEqual({ kind: 'iframe' })
|
||||
// Anchored like the three above, but no editor is mounted for it yet.
|
||||
expect(resolvePreviewTab('/kafka_triggers#f/team/ingest')).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('routes a pipeline folder to the pipeline editor kind', () => {
|
||||
expect(resolvePreviewTab('/pipeline/my_folder')).toEqual({
|
||||
kind: 'editor',
|
||||
|
||||
@@ -116,6 +116,36 @@ export function drawerAnchorFor(location: string): string | undefined {
|
||||
return location.slice(hashAt + 1).replace(/^\/resource\//, '') || undefined
|
||||
}
|
||||
|
||||
/** A workspace entity whose editor the preview hosts in process. The
|
||||
* `UserDraftItemKind` the entity's draft lives under, so the mounted editor and
|
||||
* the chat address the same cell. */
|
||||
export type EntityEditorKind = 'trigger_schedule' | 'resource' | 'variable'
|
||||
|
||||
// The workspace entities whose single-item editor the preview mounts in process,
|
||||
// by the list page that deep-links them. Mounting in process is what puts the
|
||||
// editor in the same realm as the chat: it then holds a live `UserDraft` handle
|
||||
// on the same cell the chat's writes seed, and reflects them without a reload
|
||||
// (an iframe has a `UserDraft` of its own, so its only route is `toolReloadEffect`).
|
||||
// A page absent here keeps loading its whole list in an iframe.
|
||||
const IN_REALM_ENTITY_PAGES: Partial<Record<string, EntityEditorKind>> = {
|
||||
[SCHEDULES_PATH]: 'trigger_schedule',
|
||||
[RESOURCES_PATH]: 'resource',
|
||||
[VARIABLES_PATH]: 'variable'
|
||||
}
|
||||
|
||||
/** The workspace entity a preview location opens the editor of — a list page
|
||||
* with a row deep-linked in its hash, for the pages whose editor mounts in
|
||||
* process. Undefined for the bare list page (an iframe of the list) and for
|
||||
* anchored pages whose editor is not hosted yet. */
|
||||
export function parseEntityEditorRoute(
|
||||
location: string
|
||||
): { entityKind: EntityEditorKind; path: string } | undefined {
|
||||
const entityKind = IN_REALM_ENTITY_PAGES[stripBase(location)]
|
||||
if (!entityKind) return undefined
|
||||
const path = drawerAnchorFor(location)
|
||||
return path ? { entityKind, path } : undefined
|
||||
}
|
||||
|
||||
// Query params the preview host injects into an iframe URL (`nomenubar` hides the nav,
|
||||
// `workspace` scopes the page). Never part of what a location means.
|
||||
const INJECTED_PARAMS = ['nomenubar', 'workspace'] as const
|
||||
@@ -467,6 +497,7 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
|
||||
// other route) stays an iframe.
|
||||
export type PreviewSlot =
|
||||
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
|
||||
| { kind: 'entity'; entityKind: EntityEditorKind; path: string }
|
||||
| { kind: 'artifact'; id: string; version?: number }
|
||||
| { kind: 'iframe' }
|
||||
|
||||
@@ -477,6 +508,10 @@ export function resolvePreviewTab(url: string): PreviewSlot {
|
||||
if (pipelineFolder) {
|
||||
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
|
||||
}
|
||||
// Before the item route: an entity location is a list page (never an item
|
||||
// route) with a row in its hash, so the parse below would call it an iframe.
|
||||
const entity = parseEntityEditorRoute(url)
|
||||
if (entity) return { kind: 'entity', ...entity }
|
||||
const route = parsePreviewItemRoute(url)
|
||||
if (!route) return { kind: 'iframe' }
|
||||
const editorKind: SessionTargetKind | undefined =
|
||||
|
||||
@@ -63,7 +63,12 @@
|
||||
onConfigChange = undefined,
|
||||
onDelete = undefined,
|
||||
onReset = undefined,
|
||||
trigger = undefined
|
||||
trigger = undefined,
|
||||
// The drawer carries the draft banner in its own slot; inline (`useDrawer`
|
||||
// false) it is the host's, since the trigger panel inside a script or flow
|
||||
// editor is covered by that editor's banner. A host that stands alone — a
|
||||
// session's schedule tab — opts in, or nothing says an edit is unsaved.
|
||||
showDraftBanner = false
|
||||
} = $props()
|
||||
|
||||
let optionTabSelected:
|
||||
@@ -1444,6 +1449,16 @@
|
||||
{@render saveButton()}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if showDraftBanner}
|
||||
<LocalDraftBanner
|
||||
show={draftSync.hasDraft}
|
||||
getDeployed={() => draftSync.deployed}
|
||||
reserveSpace={draftSync.hasBaseline}
|
||||
getCurrent={() => draftSync.current}
|
||||
onDiscard={() => draftSync.resetToDeployed(initialPath)}
|
||||
disabled={!can_write}
|
||||
/>
|
||||
{/if}
|
||||
{#if docDescription}
|
||||
{@render docDescription()}
|
||||
{/if}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
matchPreviewPage,
|
||||
pageKey,
|
||||
parseArtifactRoute,
|
||||
parseEntityEditorRoute,
|
||||
parsePreviewItemRoute,
|
||||
previewLocationLabel,
|
||||
type PreviewTarget
|
||||
@@ -726,10 +727,21 @@
|
||||
return (
|
||||
tab.friendlyLabel ??
|
||||
(listed && itemDisplayName(listed.path, listed.draftPath, listed.summary)) ??
|
||||
// An entity tab hosts one item's editor; `previewLocationLabel` would name
|
||||
// it after the list page its location shares a path with, so several open
|
||||
// at once would all read "Schedules". (Not moved into that function: the
|
||||
// chat's location context reports the page and the anchored row as
|
||||
// separate fields, and reads the page name from there.)
|
||||
entityTabLabel(tab.loc) ??
|
||||
previewLocationLabel(tab.loc)
|
||||
)
|
||||
}
|
||||
|
||||
function entityTabLabel(loc: string): string | undefined {
|
||||
const entity = parseEntityEditorRoute(loc)
|
||||
return entity ? (entity.path.split('/').pop() ?? entity.path) : undefined
|
||||
}
|
||||
|
||||
// Hover title for a tab. A summary label is free text the strip truncates, and
|
||||
// it hides the path entirely, so the tooltip carries both. The path shown is
|
||||
// the item's staged one when it has one — a draft's `…/draft_<uuid>` storage
|
||||
|
||||
Reference in New Issue
Block a user