mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 08:03:28 +00:00
fix(sessions): keep every released value, and refresh a tab whose editor was replaced
A cell can be let go of more than once inside one save — released with an edit, reopened, released again as a freshly loaded baseline — and keeping only the last snapshot let that baseline erase the edit the record exists to preserve. All of them are kept for the length of the window now, and any one differing from what was written leaves the draft alone. A save also reported only which tab it came from, so a tab left and reopened on the same item during the write was taken for the editor that had settled its own baseline. The editor standing there loaded before the save landed and has not; it says whether it is still the mounted one, and the tab is refreshed when it is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
co-authored by
Claude Opus 5
parent
8e26b91ad9
commit
42bd39e093
@@ -76,6 +76,8 @@
|
||||
to?: string
|
||||
fromSessionId: string | undefined
|
||||
fromTabId: string
|
||||
/** Whether the editor that reported is still the one mounted on this tab. */
|
||||
fromLive?: boolean
|
||||
}) => void
|
||||
} = $props()
|
||||
|
||||
@@ -227,14 +229,21 @@
|
||||
)
|
||||
const reportSaved = $derived(
|
||||
slot.kind === 'entity'
|
||||
? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) =>
|
||||
? (
|
||||
kind: EntityEditorKind,
|
||||
newPath: string,
|
||||
fromPath: string,
|
||||
fromWs: string,
|
||||
fromLive: boolean
|
||||
) =>
|
||||
onEntityWritten({
|
||||
kind,
|
||||
path: fromPath,
|
||||
workspace: fromWs,
|
||||
to: newPath,
|
||||
fromSessionId: session?.id,
|
||||
fromTabId: tab.id
|
||||
fromTabId: tab.id,
|
||||
fromLive
|
||||
})
|
||||
: undefined
|
||||
)
|
||||
@@ -428,7 +437,8 @@
|
||||
{workspaceId}
|
||||
onBack={backToList}
|
||||
onRemoved={(from, ws) => reportRemoved?.('trigger_schedule', from, ws)}
|
||||
onSavedTo={(to, from, ws) => reportSaved?.('trigger_schedule', to, from, ws)}
|
||||
onSavedTo={(to, from, ws, live) =>
|
||||
reportSaved?.('trigger_schedule', to, from, ws, live)}
|
||||
onViewRuns={viewRuns}
|
||||
/>
|
||||
{/await}
|
||||
@@ -441,7 +451,7 @@
|
||||
{workspaceId}
|
||||
onBack={backToList}
|
||||
onRemoved={(from, ws) => reportRemoved?.('resource', from, ws)}
|
||||
onSavedTo={(to, from, ws) => reportSaved?.('resource', to, from, ws)}
|
||||
onSavedTo={(to, from, ws, live) => reportSaved?.('resource', to, from, ws, live)}
|
||||
/>
|
||||
{/await}
|
||||
{:else if slot.entityKind === 'variable'}
|
||||
@@ -453,7 +463,7 @@
|
||||
{workspaceId}
|
||||
onBack={backToList}
|
||||
onRemoved={(from, ws) => reportRemoved?.('variable', from, ws)}
|
||||
onSavedTo={(to, from, ws) => reportSaved?.('variable', to, from, ws)}
|
||||
onSavedTo={(to, from, ws, live) => reportSaved?.('variable', to, from, ws, live)}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { untrack, onDestroy } from 'svelte'
|
||||
|
||||
let {
|
||||
path,
|
||||
@@ -24,9 +24,23 @@
|
||||
* a rename moves the ones addressing it by path — their label, the chat's
|
||||
* ACTIVE PREVIEW and the draft key all do — and a plain save moves none but
|
||||
* leaves the others holding a baseline the deploy has replaced. */
|
||||
onSavedTo?: (newPath: string, fromPath: string, fromWorkspace: string) => void
|
||||
onSavedTo?: (
|
||||
newPath: string,
|
||||
fromPath: string,
|
||||
fromWorkspace: string,
|
||||
/** Whether the editor that saved is still the one mounted here. A tab left
|
||||
* and returned to the same item during the write has another in its place,
|
||||
* loaded before the save landed, which cannot have settled its own baseline. */
|
||||
fromLive: boolean
|
||||
) => void
|
||||
} = $props()
|
||||
|
||||
// A tab left and reopened on the same item mounts a new view over this one; the
|
||||
// editor that saved is then gone, and what it reports about its baseline is not
|
||||
// about the form on screen.
|
||||
let mounted = true
|
||||
onDestroy(() => (mounted = false))
|
||||
|
||||
let editor = $state<ResourceEditorDrawer | undefined>()
|
||||
|
||||
// Re-selects when the tab is pointed at another resource; the component keeps
|
||||
@@ -45,8 +59,8 @@
|
||||
workspace={workspaceId}
|
||||
{onBack}
|
||||
{onRemoved}
|
||||
onRestored={(ws, restored) => onSavedTo?.(restored, restored, ws)}
|
||||
onRestored={(ws, restored) => onSavedTo?.(restored, restored, ws, mounted)}
|
||||
onSaved={(saved, from, fromWs) => {
|
||||
if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs)
|
||||
if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs, mounted)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { setTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { ArrowLeft } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { untrack, onDestroy } from 'svelte'
|
||||
|
||||
let {
|
||||
path,
|
||||
@@ -29,11 +29,25 @@
|
||||
* a rename moves the ones addressing it by path — their label, the chat's
|
||||
* ACTIVE PREVIEW and the draft key all do — and a plain save moves none but
|
||||
* leaves the others holding a baseline the deploy has replaced. */
|
||||
onSavedTo?: (newPath: string, fromPath: string, fromWorkspace: string) => void
|
||||
onSavedTo?: (
|
||||
newPath: string,
|
||||
fromPath: string,
|
||||
fromWorkspace: string,
|
||||
/** Whether the editor that saved is still the one mounted here. A tab left
|
||||
* and returned to the same item during the write has another in its place,
|
||||
* loaded before the save landed, which cannot have settled its own baseline. */
|
||||
fromLive: boolean
|
||||
) => void
|
||||
/** Show this schedule's runs, given the Runs page's filter query. */
|
||||
onViewRuns?: (query: string) => void
|
||||
} = $props()
|
||||
|
||||
// A tab left and reopened on the same item mounts a new view over this one; the
|
||||
// editor that saved is then gone, and what it reports about its baseline is not
|
||||
// about the form on screen.
|
||||
let mounted = true
|
||||
onDestroy(() => (mounted = false))
|
||||
|
||||
// Captured at init, so it must read the current prop rather than close over it.
|
||||
setTriggerWorkspace(() => workspaceId)
|
||||
|
||||
@@ -78,7 +92,7 @@
|
||||
if (from === path && !keptEdit) generation++
|
||||
// A draft-only schedule opens with its path editable (saving CREATEs), so
|
||||
// the save can land somewhere other than where the tab is pointed.
|
||||
if (saved) onSavedTo?.(saved, from, fromWs)
|
||||
if (saved) onSavedTo?.(saved, from, fromWs, mounted)
|
||||
}}
|
||||
>
|
||||
{#snippet customLabel()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { untrack, onDestroy } from 'svelte'
|
||||
|
||||
let {
|
||||
path,
|
||||
@@ -24,9 +24,23 @@
|
||||
* a rename moves the ones addressing it by path — their label, the chat's
|
||||
* ACTIVE PREVIEW and the draft key all do — and a plain save moves none but
|
||||
* leaves the others holding a baseline the deploy has replaced. */
|
||||
onSavedTo?: (newPath: string, fromPath: string, fromWorkspace: string) => void
|
||||
onSavedTo?: (
|
||||
newPath: string,
|
||||
fromPath: string,
|
||||
fromWorkspace: string,
|
||||
/** Whether the editor that saved is still the one mounted here. A tab left
|
||||
* and returned to the same item during the write has another in its place,
|
||||
* loaded before the save landed, which cannot have settled its own baseline. */
|
||||
fromLive: boolean
|
||||
) => void
|
||||
} = $props()
|
||||
|
||||
// A tab left and reopened on the same item mounts a new view over this one; the
|
||||
// editor that saved is then gone, and what it reports about its baseline is not
|
||||
// about the form on screen.
|
||||
let mounted = true
|
||||
onDestroy(() => (mounted = false))
|
||||
|
||||
let editor = $state<VariableEditor | undefined>()
|
||||
|
||||
// Selects the variable this tab holds, on the instance the `{#key}` below just
|
||||
@@ -50,7 +64,7 @@
|
||||
{onBack}
|
||||
{onRemoved}
|
||||
onSaved={(saved, from, fromWs) => {
|
||||
if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs)
|
||||
if (saved && from && fromWs) onSavedTo?.(saved, from, fromWs, mounted)
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
@@ -149,7 +149,7 @@ function noteMarker(set: Set<string>, key: string): void {
|
||||
* release is an editor simply closing, and remembering its value would retain a
|
||||
* whole resource — or a variable's decrypted secret — for the tab's lifetime.
|
||||
*/
|
||||
const releasedValues = new Map<string, unknown>()
|
||||
const releasedValues = new Map<string, unknown[]>()
|
||||
/** Keys with a write in flight, by how many are. Reactive: it is also what the
|
||||
* editors disable their Save on. */
|
||||
const settleWindows = new SvelteMap<string, number>()
|
||||
@@ -193,16 +193,18 @@ export function beginDraftSettleWindow(
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Only reads: the window that recorded it owns its lifetime. Two saves of the same
|
||||
* cell can be in flight at once, and the second settle has to see the same edit the
|
||||
* first did, or it reads the released cell as "nothing newer" and deletes it.
|
||||
* Every value the cell was let go of during the window, in order. All of them: an
|
||||
* item reopened and closed again while the write is in flight releases a freshly
|
||||
* loaded baseline, and keeping only the last would let that erase the edit before
|
||||
* it. Only reads — the window owns their lifetime, and a second save settling the
|
||||
* same cell has to see what the first did.
|
||||
*/
|
||||
function releasedValue<V>(
|
||||
function releasedDuringWindow<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
opts?: UserDraftOptions
|
||||
): V | undefined {
|
||||
return releasedValues.get(mapKey(resolveWorkspace(opts), itemKind, path)) as V | undefined
|
||||
): V[] {
|
||||
return (releasedValues.get(mapKey(resolveWorkspace(opts), itemKind, path)) ?? []) as V[]
|
||||
}
|
||||
|
||||
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
|
||||
@@ -357,7 +359,8 @@ export async function settleDraftAfterWrite<V>(
|
||||
// freshly loaded entry standing over the edit it released, and reading only the
|
||||
// live one would take that baseline for "nothing newer" and delete the edit.
|
||||
const newer = (v: V | undefined) => v !== undefined && !draftValuesEqual(v, written)
|
||||
const diverged = newer(live) || newer(releasedValue<V>(itemKind, fromPath, opts))
|
||||
const diverged =
|
||||
newer(live) || releasedDuringWindow<V>(itemKind, fromPath, opts).some((v) => newer(v))
|
||||
if (savedPath === fromPath && diverged) return
|
||||
// The form stays editable across the delete's own request, so a keystroke made
|
||||
// then queues a write behind it: under an unchanged path that write is the
|
||||
@@ -1113,7 +1116,12 @@ function releaseEntry(mk: string): void {
|
||||
// only here, once, at refcount 0. This is what lets multiple holders (warm
|
||||
// session previews + the nav editor) share the entry and drop in any order.
|
||||
if (entry.count <= 0) {
|
||||
if (settleWindows.has(mk)) releasedValues.set(mk, snapshotDraftValue(entry.state.val))
|
||||
if (settleWindows.has(mk)) {
|
||||
releasedValues.set(mk, [
|
||||
...(releasedValues.get(mk) ?? []),
|
||||
snapshotDraftValue(entry.state.val)
|
||||
])
|
||||
}
|
||||
// The live entry was authoritative while mounted; once gone, drop any
|
||||
// cached write for this key so a later read falls back to the server
|
||||
// rather than a value the editor may have changed in the meantime.
|
||||
|
||||
@@ -633,6 +633,8 @@
|
||||
to?: string
|
||||
fromSessionId: string | undefined
|
||||
fromTabId: string
|
||||
/** Whether the editor that reported is still the one mounted on that tab. */
|
||||
fromLive?: boolean
|
||||
}) {
|
||||
const page = entityListPage(ev.kind)?.path
|
||||
if (!page) return
|
||||
@@ -659,8 +661,9 @@
|
||||
// Same path: nothing to re-point, but every OTHER editor on it holds a
|
||||
// baseline the deploy has replaced and would offer to discard back to it.
|
||||
// The reporting one has settled its own, including an edit typed while the
|
||||
// save was in flight, which a remount would re-read from under.
|
||||
else if (s.id !== ev.fromSessionId || tab.id !== ev.fromTabId)
|
||||
// save was in flight, which a remount would re-read from under — unless it
|
||||
// is gone, and what stands in its place loaded before the save landed.
|
||||
else if (ev.fromLive === false || s.id !== ev.fromSessionId || tab.id !== ev.fromTabId)
|
||||
tabHosts[key]?.reload({ entity: 'refresh' })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user