Files
windmill/frontend/src/lib/components/VariableEditor.svelte
T
Guilhem LemouelandClaude Opus 4.7 83fcc8457a feat(ai-chat): linkify variables/resources/triggers + inline drawer
- Workspace item registry now also lists variables, resources, schedules,
  and all 10 trigger kinds; resource wins over variable on path collisions
  (Windmill auto-creates a companion variable for every resource).
- Pill icons delegated to the canonical RowIcon component so each kind
  matches the home-page styling (script blue, flow teal, app orange,
  resource boxes, schedule calendar, etc.).
- Pill href includes the hash fragment each list page already consumes
  (#/resource/<path>, #<path> for variables/schedules/triggers), so
  opening the link puts the user on the list page with the matching
  editor drawer already open.
- For variable and resource pills, a hover-revealed side-panel button
  opens (or toggles closed) the editor drawer inline next to the chat,
  without navigating away. VariableEditor and ResourceEditorDrawer gain
  a closeDrawer() export and forward their close event so the host can
  drive toggling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:44:30 +02:00

260 lines
7.2 KiB
Svelte

<script lang="ts">
import { VariableService, WorkspaceService } from '$lib/gen'
import { createEventDispatcher, untrack } from 'svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { Button } from './common'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Alert from './common/alert/Alert.svelte'
import { sendUserToast } from '$lib/toast'
import { canWrite } from '$lib/utils'
import { Save } from 'lucide-svelte'
import VariableForm from './VariableForm.svelte'
import WsSpecificVersions from './WsSpecificVersions.svelte'
import { resource } from 'runed'
import { deepEqual } from 'fast-equals'
import { getUserExt } from '$lib/user'
import type { UserExt } from '$lib/stores'
const dispatch = createEventDispatcher()
type VariableState = {
path: string
variable: { value: string; is_secret: boolean; description: string }
labels: string[] | undefined
wsSpecific: boolean
}
let editPath: string | undefined = $state(undefined)
let states: Record<string, VariableState> = $state({})
let initialStates: Record<string, VariableState> = $state({})
let existedInitially: Record<string, boolean> = $state({})
let extraPerms: Record<string, Record<string, boolean>> = $state({})
let perWsUser: Record<string, UserExt | undefined> = $state({})
let selected: string | undefined = $state(undefined)
let pathError = $state('')
let drawer: Drawer | undefined = $state()
let form: VariableForm | undefined = $state()
const deployTo = resource(
() => selected,
async (ws) =>
ws ? (await WorkspaceService.getDeployTo({ workspace: ws })).deploy_to : undefined
)
const MAX_VARIABLE_LENGTH = 10000
const edit = $derived(editPath !== undefined)
const initialPath = $derived(editPath ?? '')
const current = $derived(selected ? states[selected] : undefined)
const can_write = $derived.by(() => {
if (!selected || !edit) return true
const perms = extraPerms[selected]
if (!perms) return true
return canWrite(editPath ?? '', perms, perWsUser[selected] ?? $userStore)
})
const dirtyWorkspaces = $derived(
Object.keys(states).filter((ws) => !deepEqual(states[ws], initialStates[ws]))
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
const otherDirty = $derived(
dirtyWorkspaces.length == 1
? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore)
: dirtyWorkspaces
)
const dirtyValid = $derived(
dirtyWorkspaces.every((ws) => states[ws].variable.value.length <= MAX_VARIABLE_LENGTH)
)
const dirtyCanWrite = $derived(
dirtyWorkspaces.every((ws) => {
const perms = extraPerms[ws]
return !perms || canWrite(editPath ?? '', perms, perWsUser[ws] ?? $userStore)
})
)
// Lazy-fetch the variable for the selected workspace when not already cached
$effect(() => {
const ws = selected
const p = editPath
if (!ws || !p) return
if (ws in states) return
untrack(() => {
Promise.all([
VariableService.getVariable({ workspace: ws, path: p, decryptSecret: false }),
getUserExt(ws)
]).then(([v, user]) => {
const s: 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
}
states[ws] = s
initialStates[ws] = structuredClone(s)
existedInitially[ws] = true
extraPerms[ws] = v.extra_perms ?? {}
perWsUser[ws] = user
})
})
})
function reset() {
states = {}
initialStates = {}
existedInitially = {}
extraPerms = {}
perWsUser = {}
pathError = ''
}
export function initNew(): void {
reset()
editPath = undefined
const ws = $workspaceStore!
const s: VariableState = {
path: '',
variable: { value: '', is_secret: true, description: '' },
labels: undefined,
wsSpecific: false
}
states[ws] = s
initialStates[ws] = structuredClone(s)
existedInitially[ws] = false
selected = ws
drawer?.openDrawer()
}
export function editVariable(edit_path: string): void {
reset()
editPath = edit_path
selected = $workspaceStore!
drawer?.openDrawer()
}
export function closeDrawer(): void {
drawer?.closeDrawer()
}
async function loadSecret(): Promise<void> {
if (!editPath || !selected) return
const getV = await VariableService.getVariable({
workspace: selected,
path: editPath,
decryptSecret: true
})
const s = states[selected]
const ini = initialStates[selected]
if (s) s.variable.value = getV.value ?? ''
if (ini) ini.variable.value = getV.value ?? ''
form?.setCode(getV.value ?? '')
}
async function save(): Promise<void> {
const dirty = dirtyWorkspaces
try {
for (const ws of dirty) {
const s = states[ws]
const ini = initialStates[ws]
if (existedInitially[ws]) {
await VariableService.updateVariable({
workspace: ws,
path: ini.path,
requestBody: {
path: ini.path != s.path ? s.path : undefined,
value: s.variable.value == '' ? undefined : s.variable.value,
is_secret:
ini.variable.is_secret != s.variable.is_secret ? s.variable.is_secret : undefined,
description:
ini.variable.description != s.variable.description
? s.variable.description
: undefined,
labels: s.labels,
ws_specific: s.wsSpecific
}
})
} else {
await VariableService.createVariable({
workspace: ws,
requestBody: {
path: s.path,
value: s.variable.value,
is_secret: s.variable.is_secret,
description: s.variable.description,
labels: s.labels,
ws_specific: s.wsSpecific
}
})
}
}
sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`)
dispatch('create')
drawer?.closeDrawer()
} catch (err) {
sendUserToast(`Could not save variable: ${err.body}`, true)
}
}
</script>
<Drawer bind:this={drawer} size="50rem" on:close>
<DrawerContent
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
on:close={() => drawer?.closeDrawer()}
>
<div class="flex flex-col gap-8">
{#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 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}
/>
{/key}
{/if}
</div>
{#snippet actions()}
{#if edit && $workspaceStore}
<WsSpecificVersions
kind="variable"
workspaceId={$workspaceStore}
{initialPath}
bind:selected
/>
{/if}
<Button
on:click={save}
disabled={!anyDirty || !dirtyValid || !dirtyCanWrite || pathError != ''}
startIcon={{ icon: Save }}
variant="accent"
size="sm"
>
{edit ? 'Update' : 'Save'}
</Button>
{/snippet}
</DrawerContent>
</Drawer>