Files
windmill/frontend/src/lib/components/sessions/ScriptEditorView.svelte
T
Guilhem Lemouel 00586e1fbf Merge remote-tracking branch 'origin/main' into gl/layout-ai
Conflicts: WorkspaceItemDrillPicker, RawAppEditorHeader, copilot/global/core.

Main rewrote the global chat draft layer to live entirely on `UserDraft`
(see #9291 — `globalDraftStore` deleted, replaced by `userDraftAdapter`
which surfaces drafts as `WorkspaceItem`s for the chat). The session
runtime and the three session editor views were the only remaining
consumers of `globalDraftStore`, so this merge folds them onto the same
single-source-of-truth.

Session ↔ chat plumbing (the user's stated requirement):
- `FlowEditorView`, `ScriptEditorView`, `RawAppEditorView` register
  themselves as the live editor for `(workspace, itemKind)` via
  `UserDraft.setLiveEditorDraft({...})` on mount and clear on unmount —
  same registration the `/scripts/edit`, `/flows/edit`,
  `/apps_raw/edit` route pages now do. The session's chat sees the
  open path through the `isLiveDraft` hint and can target it with
  `discord_local_draft` or `read_workspace_item`.
- Bidirectional editor ↔ chat sync now goes through `UserDraft.get/save`
  directly. The session's `workspace_id` (a fork) is passed as
  `opts.workspace` so reads/writes target the fork's UserDraft scope —
  the session's chat operates in the same scope, so they share content
  without an extra bridge.
- The slim `FlowDraftValue`/`AppDraftValue` shapes the old store kept
  are gone; `UserDraft<Flow>` and `UserDraft<RawAppDraft>` store the
  full editor types (matching how the chat tools write them in
  `core.ts`). `flowDraftCodec` deleted; `appDraftCodec` renamed
  `applyDraftValueToRawApp` → `applyDraftToRuntimeRawApp` to reflect
  that the draft type *is* the editor shape now.
- `WorkspaceItemDrillPicker.aiDraftsForKind` reads via
  `listGlobalDrafts(workspace)` (the new userDraftAdapter export)
  instead of `globalDraftStore.listDrafts`.

The session retry-loop fixes, the not-found EditorHeader, and the
drill-picker $effect→callback refactor from earlier on this branch all
ride along unchanged.
2026-05-25 17:03:53 +02:00

133 lines
4.4 KiB
Svelte

<script lang="ts">
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { untrack } from 'svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import type { NewScript } from '$lib/gen'
import { UserDraft } from '$lib/userDraft.svelte'
import SessionItemNotFound from './SessionItemNotFound.svelte'
let {
runtime,
path,
workspaceId,
onNavigate,
initialTestPanelCollapsed = false
}: {
runtime: SessionRuntime
path: string
workspaceId: string
onNavigate?: (item: WorkspaceItem) => void
initialTestPanelCollapsed?: boolean
} = $props()
let diffDrawer: DiffDrawer | undefined = $state()
$effect(() => {
if (workspaceId && path) {
untrack(() => runtime.loadScript(workspaceId, path))
}
})
async function restoreFromCurrentTarget() {
diffDrawer?.closeDrawer()
await runtime.loadScript(workspaceId, path)
}
// Mark this editor as the live editor draft for the session's workspace
// so the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve
// to this path — same registration the regular /scripts/edit page does.
$effect(() => {
if (!workspaceId || !path) return
UserDraft.setLiveEditorDraft({
workspace: workspaceId,
itemKind: 'script',
storagePath: path,
effectivePath: runtime.scriptStore.val?.path ?? path
})
return () =>
UserDraft.clearLiveEditorDraft('script', { workspace: workspaceId, storagePath: path })
})
// Bidirectional sync between this preview and `UserDraft<NewScript>`.
// The same path under the same workspace is shared with the session's
// chat (read_workspace_item / write_script / edit_script) and any other
// open editor on the same workspace.
//
// One-way-reactive discipline: inbound tracks ONLY UserDraft.get
// (and unwraps `script.content` via untrack); outbound tracks ONLY
// `script.content` (and unwraps UserDraft via untrack). Without that
// asymmetry, a user keystroke would re-fire the inbound effect with
// the pre-keystroke stored value and revert the edit.
let lastInboundContent: string | undefined = $state(undefined)
// Store → editor. Re-runs on UserDraft changes (chat write, other
// session edit, …). `script.content` is read inside untrack so user
// keystrokes don't refire this effect.
$effect(() => {
if (!workspaceId || !path) return
const draft = UserDraft.get<NewScript>('script', path, { workspace: workspaceId })
if (!draft || typeof draft.content !== 'string') return
const incoming = draft.content
untrack(() => {
if (runtime.loadedScriptPath !== path) return
const script = runtime.scriptStore.val
if (!script) return
if (incoming === script.content) return
lastInboundContent = incoming
script.content = incoming
if (draft.language) script.language = draft.language
if (draft.summary !== undefined) script.summary = draft.summary
})
})
// Editor → store. Re-runs on `script.content` mutation (user typing
// or inbound write). UserDraft is read inside untrack so writing here
// doesn't ping-pong the inbound effect.
$effect(() => {
if (!workspaceId || !path) return
if (runtime.loadedScriptPath !== path) return
const script = runtime.scriptStore.val
if (!script) return
const content = script.content
if (content === lastInboundContent) return
untrack(() => {
const current = UserDraft.get<NewScript>('script', path, { workspace: workspaceId })
if (current && current.content === content) return
UserDraft.save<NewScript>(
'script',
path,
{ ...(current ?? script), ...script },
{
workspace: workspaceId
}
)
})
})
</script>
{#if runtime.savedScript.val}
<DiffDrawer
bind:this={diffDrawer}
restoreDeployed={restoreFromCurrentTarget}
restoreDraft={restoreFromCurrentTarget}
/>
{/if}
{#if runtime.loadingScript && !runtime.loadedScriptPath}
<div class="p-4 text-secondary text-sm">Loading script {path}</div>
{:else if runtime.notFoundScript && !runtime.loadedScriptPath}
<SessionItemNotFound kind="script" {path} {onNavigate} />
{:else if runtime.scriptStore.val}
<ScriptBuilder
bind:script={runtime.scriptStore.val}
bind:savedScript={runtime.savedScript.val}
initialPath={path}
fullyLoaded={!runtime.loadingScript}
disableHistoryChange={true}
{diffDrawer}
{onNavigate}
{initialTestPanelCollapsed}
/>
{/if}