Files
windmill/frontend/src/lib/components/ScriptVersionHistory.svelte
T
hugocasaandClaude Opus 5 c09de594b6 feat: version resource values with history, diff and restore (#10596)
* feat: version resource values with history, diff and restore

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

* fix: record resource versions in a trigger so direct writes are covered

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

* fix: show the selected version's value and tighten history write access

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

* perf: gate resource version recording in trigger WHEN clauses

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

* feat: clear a resource's past versions, and address review nits

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

* fix: restore the displayed version and keep author attribution on pooled writes

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

* fix: scope history to the selected workspace and gate clearing on ownership

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

* fix: gate restore on write access and clearing on the signed-in workspace

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

* refactor(frontend): share the version-history row between script and resource drawers

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

* perf: trim resource version history in the monitor sweep, not on write

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

* fix(frontend): match the script versions drawer shell for resource history

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

* perf(frontend): highlight version values instead of mounting monaco

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

* fix(frontend): match the script drawer's code preview presentation

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

* fix: rank version trim in one windowed pass instead of a correlated delete

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

* refactor(frontend): treat the newest version as current by position

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

* perf: gate the resource version trim to an hourly sweep

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

* fix: unnest the version row action and correct the trim cadence docs

* perf: cap the history listing and use sets for reference lookup

* feat: warn when a resource is written more than 60 times a minute

* fix: lower the resource write advisory to 20 per minute

* fix: discard stale history loads and never diff against an unread value

* fix: correct the write advisory boundary and document the eviction lock

* fix: read history and the live value from one snapshot

* refactor: read the drawer's diff baseline from versions, not the live resource

* fix: open the history drawer with no version selected

* fix: disarm the clear confirmation and clear the pane when the selection moves

* fix: explain the missing diff and drop a guard that can no longer fire

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:32:18 +02:00

240 lines
7.3 KiB
Svelte

<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { emptyString } from '$lib/utils'
import { ScriptService, type ScriptHistory } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Skeleton } from '$lib/components/common'
import FlowModuleScript from './flows/content/FlowModuleScript.svelte'
import { createEventDispatcher } from 'svelte'
import Button from './common/button/Button.svelte'
import { ExternalLink, Pencil, ArrowRight, X, Diff, Code } from 'lucide-svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import VersionListItem from './VersionListItem.svelte'
const dispatch = createEventDispatcher()
let { openDetails = false, scriptPath }: { openDetails?: boolean; scriptPath: string } = $props()
let deploymentMsgUpdateMode = $state(false)
let deploymentMsgUpdate: string | undefined = $state()
let selectedVersion: ScriptHistory | undefined = $state()
let selectedVersionIndex: number | undefined = $state()
let versions: ScriptHistory[] | undefined = $state()
let loading: boolean = $state(false)
async function loadVersions() {
loading = true
versions = await ScriptService.getScriptHistoryByPath({
workspace: $workspaceStore!,
path: scriptPath
})
loading = false
}
async function updateDeploymentMsg(scriptHash: string | undefined) {
if (
selectedVersion === undefined ||
scriptHash === undefined ||
emptyString(deploymentMsgUpdate)
) {
return
}
await ScriptService.updateScriptHistory({
workspace: $workspaceStore!,
path: scriptPath,
hash: scriptHash,
requestBody: {
deployment_msg: deploymentMsgUpdate!
}
})
selectedVersion.deployment_msg = deploymentMsgUpdate
deploymentMsgUpdateMode = false
loadVersions()
}
loadVersions()
let showDiff: boolean = $state(false)
let previousHash: string | undefined = $state()
</script>
<Splitpanes class="!overflow-visible">
<Pane size={20}>
<div class="flex flex-col gap-2 px-2 pt-2 w-full">
{#if !loading}
{#if versions && versions.length > 0}
<div class="flex gap-2 flex-col">
{#each versions ?? [] as version, versionIndex}
<VersionListItem
selected={selectedVersion?.script_hash == version.script_hash}
onclick={() => {
selectedVersion = version
selectedVersionIndex = versionIndex
if (showDiff && versions && selectedVersionIndex === versions.length - 1) {
showDiff = false
}
const availableVersions = versions?.slice(selectedVersionIndex + 1)
if (
previousHash &&
!availableVersions?.find((v) => v.script_hash === previousHash)
) {
previousHash = availableVersions?.[0]?.script_hash
}
deploymentMsgUpdate = undefined
deploymentMsgUpdateMode = false
}}
>
<span class="text-xs truncate">
{#if emptyString(version.deployment_msg)}Version {version.script_hash}{:else}{version.deployment_msg}{/if}
</span>
{#snippet action()}
{#if openDetails}
<Button
on:click={() => {
dispatch('openDetails', { version: version.script_hash })
}}
class="mr-2 shrink-0 inline-flex gap-1 text-xs items-center"
size="xs"
color="light"
variant="border"
>
Run page<ExternalLink size={14} />
</Button>
{/if}
{/snippet}
</VersionListItem>
{/each}
</div>
{:else}
<div class="text-sm text-primary">No items</div>
{/if}
{:else}
<Skeleton layout={[[40], [40], [40], [40], [40]]} />
{/if}
</div>
</Pane>
<Pane size={80}>
<div class="h-full w-full overflow-auto">
{#if selectedVersion}
{#key selectedVersion}
<div class="flex flex-col min-h-full">
<span class="flex flex-row text-sm p-2 text-primary">
{#if deploymentMsgUpdateMode}
<div class="flex w-full">
<input
type="text"
bind:value={deploymentMsgUpdate}
class="!w-auto grow"
onclick={(e) => {
e.stopPropagation()
}}
onkeydown={(e) => {
e.stopPropagation()
}}
onkeypress={(e) => {
e.stopPropagation()
if (e.key === 'Enter') updateDeploymentMsg(selectedVersion?.script_hash)
}}
/>
<Button
size="xs"
color="blue"
buttonType="button"
btnClasses="!p-1 !w-[34px] !ml-1"
aria-label="Save deployment message"
on:click={() => {
updateDeploymentMsg(selectedVersion?.script_hash)
}}
>
<ArrowRight size={14} />
</Button>
<Button
size="xs"
color="light"
buttonType="button"
btnClasses="!p-1 !w-[34px] !ml-1"
aria-label="Abort"
on:click={() => {
deploymentMsgUpdateMode = false
deploymentMsgUpdate = undefined
}}
>
<X size={14} />
</Button>
</div>
{:else}
{#if selectedVersion.deployment_msg}
{selectedVersion.deployment_msg}
{:else}
Version {selectedVersion.script_hash}
{/if}
<button
onclick={() => {
deploymentMsgUpdate = selectedVersion?.deployment_msg
deploymentMsgUpdateMode = true
}}
title="Update commit message"
class="flex items-center px-1 rounded-sm hover:text-primary text-secondary h-5"
aria-label="Update commit message"
>
<Pencil size={14} />
</button>
{/if}
</span>
{#if selectedVersionIndex !== undefined && versions?.slice(selectedVersionIndex + 1).length}
<div class="p-2 flex flex-row items-center gap-2 h-8">
<div class="w-min">
<ToggleButtonGroup
selected={showDiff ? 'diff' : 'code'}
on:selected={({ detail }) => {
showDiff = detail === 'diff'
}}
>
{#snippet children({ item })}
<ToggleButton small value="code" label="Code" icon={Code} {item} />
<ToggleButton small value="diff" label="Diff" icon={Diff} {item} />
{/snippet}
</ToggleButtonGroup>
</div>
{#if showDiff}
<div class="text-xs">Versions:</div>
<select bind:value={previousHash} class="!text-xs !w-40">
{#each versions?.slice(selectedVersionIndex + 1) ?? [] as version}
<option
value={version.script_hash}
selected={version.script_hash === selectedVersion.script_hash}
class="!text-xs"
>
{version.deployment_msg ?? version.script_hash}
</option>
{/each}
</select>
{/if}
</div>
{:else}
<div class="p-2 text-xs text-secondary"> No previous version found </div>
{/if}
<FlowModuleScript
showDate
path={scriptPath}
hash={selectedVersion.script_hash}
{previousHash}
{showDiff}
/>
</div>
{/key}
{:else}
<div class="text-sm p-2 text-primary">Select a deployment version to see its details</div>
{/if}
</div>
</Pane>
</Splitpanes>