mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 08:03:28 +00:00
fix(sessions): move every tab on an item the editor renamed or removed, not just its own
Warm sessions share one draft cell and one server row per item, so a rename or a draft-only discard made in one left the others editing a path that is gone. The editor's report now goes to the page, which re-points every tab on that item in that workspace — the same fan-out the chat tools already use. Matching on the item is also what the host's own staleness check was for: a report that lands after its tab has been re-pointed simply finds nothing to match, so `stillShowing` goes. 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
9d7b06ecc4
commit
345f40affb
@@ -12,7 +12,6 @@
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import {
|
||||
entityEditorHref,
|
||||
entityListHref,
|
||||
entityListPage,
|
||||
resolvePreviewTab,
|
||||
@@ -37,7 +36,8 @@
|
||||
darkMode,
|
||||
fullscreen = false,
|
||||
onNavigate,
|
||||
onLoad
|
||||
onLoad,
|
||||
onEntityMoved
|
||||
}: {
|
||||
tab: SessionPreviewTab
|
||||
session: Session | undefined
|
||||
@@ -63,6 +63,14 @@
|
||||
onNavigate: (item: WorkspaceItem) => void
|
||||
/** Iframe finished loading — the page reads back its observed location. */
|
||||
onLoad: (frame: HTMLIFrameElement) => void
|
||||
/** A hosted entity editor moved its item: renamed it (`to`) or removed it.
|
||||
* The page re-points every tab on that item, across warm sessions. */
|
||||
onEntityMoved: (ev: {
|
||||
kind: EntityEditorKind
|
||||
path: string
|
||||
workspace: string
|
||||
to?: string
|
||||
}) => void
|
||||
} = $props()
|
||||
|
||||
// Editor vs iframe is decided purely from the tab URL (see resolvePreviewTab):
|
||||
@@ -180,42 +188,26 @@
|
||||
: undefined
|
||||
)
|
||||
|
||||
// An editor reports a removal or a rename after the write it awaited, by which
|
||||
// time the tab may hold something else entirely. The report is about the item it
|
||||
// started on, so acting on anything else re-points an unrelated editor — and
|
||||
// builds the destination out of its location.
|
||||
function stillShowing(kind: EntityEditorKind, path: string, ws: string): boolean {
|
||||
const now = resolvePreviewTab(tab.url)
|
||||
return (
|
||||
now.kind === 'entity' &&
|
||||
now.entityKind === kind &&
|
||||
now.path === path &&
|
||||
ws === workspaceId
|
||||
)
|
||||
}
|
||||
|
||||
// The item is gone (a draft-only one whose draft was discarded), so the tab has
|
||||
// the same destination as `backToList` — but bound to this tab by id, not to
|
||||
// whichever is active: by the time a discard lands the user may be looking at
|
||||
// another tab, which must not be the one sent to the list.
|
||||
// An editor reports a removal or a rename about the item it started on, after
|
||||
// the write it awaited — by which time this tab may hold something else, and
|
||||
// other warm sessions may hold that same item. Both are the page's to resolve:
|
||||
// it re-points every tab still on the item, in the workspace it was written in,
|
||||
// which is also the only way the reporting tab is spared a report gone stale.
|
||||
// A removal has no destination path; a rename carries the one it moved to.
|
||||
const returnToList = $derived(
|
||||
slot.kind === 'entity' && runtime
|
||||
? (kind: EntityEditorKind, fromPath: string, fromWs: string) => {
|
||||
if (!stillShowing(kind, fromPath, fromWs)) return
|
||||
runtime.previewTabs.retargetTabTo(tab.id, entityListHref(whereIs(tab)))
|
||||
}
|
||||
slot.kind === 'entity'
|
||||
? (kind: EntityEditorKind, fromPath: string, fromWs: string) =>
|
||||
onEntityMoved({ kind, path: fromPath, workspace: fromWs })
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Follow a rename: the tab, its label, the chat's ACTIVE PREVIEW and the draft
|
||||
// key all address the item by path, so they have to move with it or they name an
|
||||
// item that no longer exists.
|
||||
// The tab, its label, the chat's ACTIVE PREVIEW and the draft key all address
|
||||
// the item by path, so they have to move with it or they name an item that no
|
||||
// longer exists.
|
||||
const retargetTo = $derived(
|
||||
slot.kind === 'entity' && runtime
|
||||
? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => {
|
||||
if (!stillShowing(kind, fromPath, fromWs)) return
|
||||
runtime.previewTabs.retargetTabTo(tab.id, entityEditorHref(whereIs(tab), newPath))
|
||||
}
|
||||
slot.kind === 'entity'
|
||||
? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) =>
|
||||
onEntityMoved({ kind, path: fromPath, workspace: fromWs, to: newPath })
|
||||
: undefined
|
||||
)
|
||||
|
||||
|
||||
@@ -230,11 +230,12 @@ const flushes = new SvelteMap<string, number>()
|
||||
|
||||
/**
|
||||
* Whether the last save that LANDED for a key deleted the draft or wrote one.
|
||||
* Written only here, by the response handler, which is what distinguishes it
|
||||
* from the display-level draft hint: that one is also published optimistically
|
||||
* and by the editors themselves, so it cannot say whether a POST succeeded.
|
||||
* A caller acting on a delete (leaving an editor whose item it removed) needs
|
||||
* the difference. Never cleared — "last landed" is meaningless until one has.
|
||||
* Written only by the response handler, which is what distinguishes it from the
|
||||
* display-level draft hint: that one is also published optimistically and by the
|
||||
* editors themselves, so it cannot say whether a POST succeeded. A caller acting
|
||||
* on a delete (leaving an editor whose item it removed) needs the difference.
|
||||
* Dropped when the server stops being ours to describe — a conflict, or a resync
|
||||
* from a load — and otherwise kept, since the key's own next landing replaces it.
|
||||
*/
|
||||
const lastLanded = new Map<string, 'delete' | 'upsert'>()
|
||||
|
||||
@@ -609,10 +610,9 @@ export const UserDraftDbSyncer = {
|
||||
|
||||
/**
|
||||
* Whether the last save that landed for this key was the draft's deletion.
|
||||
* False while none has landed — a delete that failed or conflicted never
|
||||
* reaches the response handler, so it never claims to have landed, and one
|
||||
* that landed earlier is dropped as soon as the server's state moves outside
|
||||
* our writes.
|
||||
* False while none has landed: a delete that failed or conflicted never
|
||||
* reaches the response handler, and a conflict drops what an earlier one
|
||||
* recorded rather than letting it answer for this attempt.
|
||||
*/
|
||||
lastLandedWasDelete(query: UserDraftLastSyncQuery): boolean {
|
||||
return lastLanded.get(draftKey(query.workspace, query.itemKind, query.path)) === 'delete'
|
||||
|
||||
@@ -64,8 +64,11 @@
|
||||
matchPreviewPage,
|
||||
pageKey,
|
||||
parseArtifactRoute,
|
||||
entityEditorHref,
|
||||
entityKindForPage,
|
||||
entityListHref,
|
||||
entityListPage,
|
||||
type EntityEditorKind,
|
||||
parseEntityEditorRoute,
|
||||
parsePreviewItemRoute,
|
||||
previewLocationLabel,
|
||||
@@ -608,6 +611,32 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// A hosted entity editor moved its item — renamed it, or removed it by
|
||||
// discarding the draft that was all of it. Every tab on that item follows,
|
||||
// across warm sessions: they share the one draft cell and the one server row,
|
||||
// so a tab left behind edits a path that is gone. Matching on the item is also
|
||||
// what keeps a report that landed late from moving a tab that has since been
|
||||
// re-pointed — there is nothing to match.
|
||||
function moveEntityTabs(ev: {
|
||||
kind: EntityEditorKind
|
||||
path: string
|
||||
workspace: string
|
||||
to?: string
|
||||
}) {
|
||||
const page = entityListPage(ev.kind)?.path
|
||||
if (!page) return
|
||||
for (const s of warmSessions) {
|
||||
const owner = getRuntime(s.id)?.previewTabs
|
||||
if (!owner || getEffectiveWorkspaceId(s) !== ev.workspace) continue
|
||||
for (const tab of owner.tabs) {
|
||||
const loc = whereIs(tab)
|
||||
const entity = parseEntityEditorRoute(loc)
|
||||
if (!entity || entity.path !== ev.path || stripBase(loc) !== page) continue
|
||||
// The tab's own location, so the list it came from keeps its filters.
|
||||
owner.retargetTabTo(tab.id, ev.to ? entityEditorHref(loc, ev.to) : entityListHref(loc))
|
||||
}
|
||||
}
|
||||
}
|
||||
function flushReload() {
|
||||
const pages = pendingPages
|
||||
const mutations = pendingMutations
|
||||
@@ -1160,6 +1189,7 @@
|
||||
{fullscreen}
|
||||
onNavigate={navigateEditorTo}
|
||||
onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)}
|
||||
onEntityMoved={moveEntityTabs}
|
||||
/>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user