Files
windmill/frontend/src/lib/components/MoveDrawer.svelte
T
GuilhemandClaude Opus 5 b10f83c763 feat(sessions): show an item's deployed page in the preview panel (#11458)
* feat(sessions): show an item's deployed page in the preview panel

The preview panel showed an item's editor and nothing else. It now shows the
deployed page too, as a tab of its own.

**Two sides, two tabs.** The editor and the deployed page are different things
to look at, so `isItemTabFor` keys on the side rather than the item: asking for
a side already open focuses it, asking for the other opens it alongside. Two
editors for one item still cannot coexist — they would race its single
(kind, path) cell — but an editor beside a viewer is safe, since the viewer
reads the deployed version over the API and holds no cell. The tab breadcrumb
keeps re-pointing in place; `Exit & see details` and the detail page's `Edit`
open the other side instead of consuming the one you are on. A tab's label
carries `(edit)` when it is the editor side.

**The detail pages moved out of their routes.** `/scripts/get` and `/flows/get`
are now thin wrappers over `ScriptDetail` and `FlowDetail`, which the panel
renders too. Everything they reach — drawers, triggers, saved inputs — is scoped
to the viewer's workspace through `setOperatingWorkspace`, and every navigation
they attempt is caught by `interceptNav` and turned into a move inside the
panel, so nothing takes the browser out of the session. A plain click is
intercepted; ⌘-click and middle-click still open a real tab, which is why the
pages keep their `href`s.

**The picker opens what exists.** A row opens the deployed page, or the editor
when nothing is deployed there, and carries a Draft / Draft only badge in the
review dock's words. `WorkspaceItem` gained `draftOnly` and `hasDraft` from the
listers, which already returned both; deploys now invalidate the picker cache
through `itemDeployed`, so a just-deployed item stops reading as a draft. Rows
also carry a hover Edit action, reachable from the keyboard with the pick
modifier.

`open_preview` gained a `mode`, narrowed out of the advertised schema for a
session that cannot write drafts and re-checked per path in the handler, where
a refusal reports "couldn't check" apart from "denied". The mode is resolved
before those checks, so a call that omits it is gated as the editor it will
become.

Alongside, the workspace a detail page acts on is now the one it is showing
rather than the one the browser is navigated to: `MoveDrawer`,
`toggleWorkspaceErrorHandler` and the pages' own permission gates read the
operating workspace and its acting user, and `RunForm` mints a password
argument's ephemeral variable there too — in a fork session all of these
previously answered for the parent. `InWorkspaceAppViewer` hands the app's
`ctx` user down as a prop instead of writing the global `userStore`, which from
a session tab was re-pointing every permission check on the page.

Fixes found on the way: `DetailPageLayout` claimed `h-screen` inside a panel
that is not the viewport; Edit on a historical script version dropped the
version from the intercepted click; the Run button stayed spinning after a run
that left the page mounted; and the window-level run shortcut fired from a
collapsed panel and from keys another handler had already claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sessions): historical edits, stale not-found, shared edit rights, tab labels

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): keep deployed-page links and workspace reads in the session

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): scope detail hrefs to the session, re-point the viewer's own tab

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): keep a previewed raw app's route out of the session URL

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): resolve edit-in-fork against the session workspace

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): read advanced run tags from the session workspace

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): fork from the session workspace, star only navigation items

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): mount only the side a preview tab shows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): address CI review round 1 findings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): route links in a deployed page's drawers through the panel

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): tell the chat which deployed page the panel shows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): only a 404 reads as an undeployed raw app

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): scope the unparseable-JSON run gate to the form

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): a flow deleted from the panel stays in its tab

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): the picker's edit shortcut works on the current row

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): load-error toasts say what failed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): keep an oversized invalid JSON editor in its form's run gate

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* revert: keep main's draft_only description in openapi.yaml

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:43:52 +02:00

206 lines
7.5 KiB
Svelte

<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { useOperatingWorkspace, useOperatingUser } from './operatingWorkspace.svelte'
import { Alert, Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Path from './Path.svelte'
import { isOwner } from '$lib/utils'
import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager'
import Label from './Label.svelte'
import TextInput from './text_input/TextInput.svelte'
import { DraftService, FlowService, ScriptService, type TriggersCount } from '$lib/gen'
const dispatch = createEventDispatcher()
// A move is a write, so it has to land in the workspace the host acts on: inside an AI
// session's editor or deployed view that is the session's (possibly forked) workspace,
// while the navigation store still names the parent.
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app'
/** The address the move endpoint takes: where a draft-only item's row lives, which
* `initialPath` (what the user sees and edits) need not equal. Empty for a deployed
* item, which is addressed by `initialPath` instead. */
let storagePath = $state('')
let rawApp = $state(false)
let draftOnly = $derived(storagePath !== '')
let kind = $state<Kind>('flow')
let initialPath = $state('')
let initialSummary = $state('')
let path = $state<string | undefined>(undefined)
let summary = $state<string | undefined>(undefined)
let dirtyPath = $state(false)
let drawer = $state<Drawer>() as Drawer
// Derived rather than read when the drawer opens: the acting user in another workspace is
// looked up asynchronously, so a snapshot taken at open time says "not owner" for a fork.
const ownerKnown = $derived(operatingUser.resolved($operatingWorkspace))
const own = $derived(
!!operatingUser.current &&
isOwner(draftOnly ? storagePath : initialPath, operatingUser.current, $operatingWorkspace!)
)
let onBehalfOfEmail = $state<string | undefined>(undefined)
// Counts of triggers/schedules/etc. that reference this script or flow.
// The backend cascades `script_path` on rename across all trigger tables
// (see `windmill_common::triggers::update_triggers_script_path` invoked
// from script/flow create), so the user just needs to know what will be
// moved along — not opt in per-row.
let attachedTriggers = $state<TriggersCount | undefined>(undefined)
let hasChanges = $derived((summary ?? '') !== initialSummary || dirtyPath)
// Flatten the count buckets into a uniform list for rendering. Order
// reflects user-facing prominence: schedules first (most common), then
// the seven native trigger kinds, then HTTP / webhook / websocket /
// email-default / cloud-service installations. Buckets with count 0
// are dropped so the panel only mentions triggers that actually exist.
let attachedSummary = $derived.by<Array<{ label: string; count: number }>>(() => {
const c = attachedTriggers
if (!c) return []
const out: Array<{ label: string; count: number }> = []
const push = (label: string, n: number | undefined) => {
if (typeof n === 'number' && n > 0) out.push({ label, count: n })
}
push('schedule', c.schedule_count)
push('kafka', c.kafka_count)
push('mqtt', c.mqtt_count)
push('amqp', c.amqp_count)
push('nats', c.nats_count)
push('postgres', c.postgres_count)
push('sqs', c.sqs_count)
push('gcp', c.gcp_count)
push('email', c.email_count)
push('http route', c.http_routes_count)
push('websocket', c.websocket_count)
push('webhook token', c.webhook_count)
push('default-email token', c.default_email_count)
push('nextcloud', c.nextcloud_count)
push('google', c.google_count)
push('github', c.github_count)
push('azure', c.azure_count)
return out
})
let attachedTotal = $derived(attachedSummary.reduce((s, { count }) => s + count, 0))
/** `draft` marks an item that exists only as the caller's draft: pass the
* generated path its draft row sits at, and `initialPath_l` is then the name
* the user sees. Nothing is deployed, so there are no triggers to cascade
* and no on-behalf-of identity to warn about. */
export async function openDrawer(
initialPath_l: string,
summary_l: string | undefined,
kind_l: Kind,
draft?: { storagePath: string; rawApp?: boolean }
) {
kind = kind_l
path = undefined
dirtyPath = false
onBehalfOfEmail = undefined
attachedTriggers = undefined
storagePath = draft?.storagePath ?? ''
rawApp = draft?.rawApp ?? false
initialPath = initialPath_l
initialSummary = summary_l ?? ''
summary = summary_l
drawer.openDrawer()
if (draftOnly) {
return
}
if (kind === 'flow') {
onBehalfOfEmail = await checkFlowOnBehalfOf($operatingWorkspace!, initialPath_l)
}
if (kind === 'script' || kind === 'flow') {
void loadAttachedTriggers()
}
}
async function loadAttachedTriggers() {
try {
const workspace = $operatingWorkspace!
attachedTriggers =
kind === 'flow'
? await FlowService.getTriggersCountOfFlow({ workspace, path: initialPath })
: await ScriptService.getTriggersCountOfScript({ workspace, path: initialPath })
} catch {
// Non-fatal: the rename still works without the summary panel.
attachedTriggers = undefined
}
}
async function updatePath() {
if (draftOnly && (kind === 'flow' || kind === 'script' || kind === 'app')) {
await DraftService.moveDraft({
workspace: $operatingWorkspace!,
kind: kind === 'app' && rawApp ? 'raw_app' : kind,
path: storagePath,
requestBody: { new_path: path ?? '', summary: summary ?? '' }
})
} else if (kind === 'flow' || kind === 'script' || kind === 'app') {
await updateItemPathAndSummary({
workspace: $operatingWorkspace!,
kind,
initialPath,
newPath: path ?? '',
newSummary: summary ?? ''
})
}
dispatch('update', path)
drawer.closeDrawer()
}
</script>
<Drawer bind:this={drawer}>
<DrawerContent title="Move/Rename {initialPath}" on:close={drawer.closeDrawer}>
<!-- Only once the acting user is known: while the lookup is in flight `own` is false,
and stating "you do not own this" then would be a guess. -->
{#if ownerKnown && !own}
<Alert type="warning" title="Not owner" class="mb-4">
Since you do not own this item, you cannot move this item (you can however fork it)
</Alert>
{/if}
{#if own && onBehalfOfEmail}
<Alert type="info" title="Run on behalf of" class="mb-4">
This flow will be redeployed on behalf of you ({operatingUser.current?.email}) instead of {onBehalfOfEmail}
</Alert>
{/if}
{#if (kind === 'script' || kind === 'flow') && attachedTotal > 0}
<Alert
type="info"
title={`Will also update ${attachedTotal} attached trigger${attachedTotal === 1 ? '' : 's'}`}
class="mb-4"
>
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
{#each attachedSummary as { label, count } (label)}
<span class="text-xs"
><span class="font-mono font-semibold">{count}</span>
{label}{count === 1 ? '' : 's'}</span
>
{/each}
</div>
</Alert>
{/if}
<Label label="Summary" class="mb-6">
<TextInput
inputProps={{
type: 'text',
placeholder: 'Short summary to be displayed when listed',
disabled: !own
}}
bind:value={summary}
/>
</Label>
<Label label="Path">
<Path disabled={!own} {kind} {initialPath} bind:path bind:dirty={dirtyPath} />
</Label>
{#snippet actions()}
<Button variant="accent" disabled={!own || !hasChanges} on:click={updatePath}
>Move/Rename</Button
>
{/snippet}
</DrawerContent>
</Drawer>