Files
windmill/frontend/src/lib/components/workspaceItemsLoader.svelte.ts
T
GuilhemandClaude Opus 5 b10f83c763 feat(sessions): show an item's deployed page in the preview panel (#11458)
* feat(sessions): show an item's deployed page in the preview panel

The preview panel showed an item's editor and nothing else. It now shows the
deployed page too, as a tab of its own.

**Two sides, two tabs.** The editor and the deployed page are different things
to look at, so `isItemTabFor` keys on the side rather than the item: asking for
a side already open focuses it, asking for the other opens it alongside. Two
editors for one item still cannot coexist — they would race its single
(kind, path) cell — but an editor beside a viewer is safe, since the viewer
reads the deployed version over the API and holds no cell. The tab breadcrumb
keeps re-pointing in place; `Exit & see details` and the detail page's `Edit`
open the other side instead of consuming the one you are on. A tab's label
carries `(edit)` when it is the editor side.

**The detail pages moved out of their routes.** `/scripts/get` and `/flows/get`
are now thin wrappers over `ScriptDetail` and `FlowDetail`, which the panel
renders too. Everything they reach — drawers, triggers, saved inputs — is scoped
to the viewer's workspace through `setOperatingWorkspace`, and every navigation
they attempt is caught by `interceptNav` and turned into a move inside the
panel, so nothing takes the browser out of the session. A plain click is
intercepted; ⌘-click and middle-click still open a real tab, which is why the
pages keep their `href`s.

**The picker opens what exists.** A row opens the deployed page, or the editor
when nothing is deployed there, and carries a Draft / Draft only badge in the
review dock's words. `WorkspaceItem` gained `draftOnly` and `hasDraft` from the
listers, which already returned both; deploys now invalidate the picker cache
through `itemDeployed`, so a just-deployed item stops reading as a draft. Rows
also carry a hover Edit action, reachable from the keyboard with the pick
modifier.

`open_preview` gained a `mode`, narrowed out of the advertised schema for a
session that cannot write drafts and re-checked per path in the handler, where
a refusal reports "couldn't check" apart from "denied". The mode is resolved
before those checks, so a call that omits it is gated as the editor it will
become.

Alongside, the workspace a detail page acts on is now the one it is showing
rather than the one the browser is navigated to: `MoveDrawer`,
`toggleWorkspaceErrorHandler` and the pages' own permission gates read the
operating workspace and its acting user, and `RunForm` mints a password
argument's ephemeral variable there too — in a fork session all of these
previously answered for the parent. `InWorkspaceAppViewer` hands the app's
`ctx` user down as a prop instead of writing the global `userStore`, which from
a session tab was re-pointing every permission check on the page.

Fixes found on the way: `DetailPageLayout` claimed `h-screen` inside a panel
that is not the viewport; Edit on a historical script version dropped the
version from the intercepted click; the Run button stayed spinning after a run
that left the page mounted; and the window-level run shortcut fired from a
collapsed panel and from keys another handler had already claimed.

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

* fix(sessions): historical edits, stale not-found, shared edit rights, tab labels

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

* fix(sessions): keep deployed-page links and workspace reads in the session

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

* fix(sessions): scope detail hrefs to the session, re-point the viewer's own tab

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

* fix(sessions): keep a previewed raw app's route out of the session URL

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

* fix(sessions): resolve edit-in-fork against the session workspace

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

* fix(sessions): read advanced run tags from the session workspace

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

* fix(sessions): fork from the session workspace, star only navigation items

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

* fix(sessions): mount only the side a preview tab shows

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

* fix(sessions): address CI review round 1 findings

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

* fix(sessions): route links in a deployed page's drawers through the panel

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

* fix(sessions): tell the chat which deployed page the panel shows

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

* fix(sessions): only a 404 reads as an undeployed raw app

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

* fix(sessions): scope the unparseable-JSON run gate to the form

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

* fix(sessions): a flow deleted from the panel stays in its tab

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

* fix(sessions): the picker's edit shortcut works on the current row

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

* fix(sessions): load-error toasts say what failed

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

* fix(sessions): keep an oversized invalid JSON editor in its form's run gate

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

* revert: keep main's draft_only description in openapi.yaml

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:43:52 +02:00

143 lines
4.7 KiB
TypeScript

import { untrack } from 'svelte'
import {
getCachedItems,
loadKind,
type WorkspaceItem,
type WorkspaceItemKind
} from './workspacePicker'
/**
* Shared loader for workspace items in drill pickers. Owns the
* `loaded` / `loadingKind` state, the stale-while-revalidate `ensureLoaded`
* coroutine, the `kind:` / `dir:` scope-segment decoder, and the
* "load every kind on first search" filter callback.
*
* Both `WorkspaceItemDrillPicker` and `ChatContextPicker` mount a
* `DrillPicker` over a workspace tree built from these maps. They each
* keep their own scope-walking policy (chat collapses an optional
* `'workspace'` wrapper segment; workspace handles single-kind mode at
* the top), but the kind decoding and lazy fetch live here.
*
* Both getters are read inside the returned closures so changing
* workspace or kinds after mount Just Works.
*/
function itemsEqual(a: WorkspaceItem[] | undefined, b: WorkspaceItem[]): boolean {
if (!a || a.length !== b.length) return false
return a.every(
(item, i) =>
item.path === b[i].path &&
item.summary === b[i].summary &&
item.kind === b[i].kind &&
item.raw_app === b[i].raw_app &&
item.draftPath === b[i].draftPath &&
item.draftOnly === b[i].draftOnly &&
item.hasDraft === b[i].hasDraft
)
}
export function useWorkspaceItemsLoader(
workspace: () => string | undefined,
kinds: () => readonly WorkspaceItemKind[]
) {
// Seed from the module-level cache so kinds already fetched in this
// session render on the first frame.
let loaded = $state<Partial<Record<WorkspaceItemKind, WorkspaceItem[]>>>(
(() => {
const ws = untrack(workspace)
if (!ws) return {}
const out: Partial<Record<WorkspaceItemKind, WorkspaceItem[]>> = {}
for (const k of untrack(kinds)) {
const cached = getCachedItems(ws, k)
if (cached) out[k] = cached
}
return out
})()
)
let loadingKind = $state<Partial<Record<WorkspaceItemKind, boolean>>>({})
// Workspace+kind pairs this loader instance has refreshed — at most one
// background re-fetch per kind per mount. Marked on success only, so a
// failed fetch is retried by the next call instead of stranding stale data.
const revalidated = new Set<string>()
async function ensureLoaded(kind: WorkspaceItemKind) {
const ws = workspace()
if (!ws) return
const revalidateKey = `${ws}:${kind}`
// `loaded[kind]` read inside `untrack` so callers wiring this into
// a reactive context (DrillPicker's onFilterChange effect) don't
// subscribe to a signal `ensureLoaded` itself writes — that would
// re-fire the effect on every assignment and busy-loop.
const hasCached = !!untrack(() => loaded[kind])
if (hasCached && revalidated.has(revalidateKey)) return
if (!hasCached) loadingKind[kind] = true
try {
const items = await loadKind(ws, kind, { revalidate: true })
revalidated.add(revalidateKey)
// Keep the reference stable when nothing changed so an open picker's
// derived tree isn't rebuilt under the user on every revalidation.
if (
!itemsEqual(
untrack(() => loaded[kind]),
items
)
)
loaded[kind] = items
} catch (e) {
// Callers fire-and-forget; surface the failure without an
// unhandled rejection. Cached items (if any) keep rendering.
console.error(`Failed to load workspace ${kind}s`, e)
} finally {
loadingKind[kind] = false
}
}
function ensureAll() {
for (const k of kinds()) ensureLoaded(k)
}
/** Decode one scope segment and trigger loads for the kind(s) it refers to.
* Accepts:
* - `kind:<k>` (or `kind:all` — loads everything)
* - `dir:<k>:<path>` (the single-kind layout where there's no `kind:`
* wrapper at the top of the path)
* Unknown segments and kinds outside the current `kinds()` set are
* ignored — the caller has already filtered scope chains it cares about.
*/
function ensureForScopeSegment(segment: string) {
const ks = kinds()
const triggerKind = (k: string) => {
if (k === 'all') return ensureAll()
if ((ks as readonly string[]).includes(k)) ensureLoaded(k as WorkspaceItemKind)
}
if (segment.startsWith('kind:')) {
triggerKind(segment.slice(5))
return
}
if (segment.startsWith('dir:')) {
const rest = segment.slice(4)
const colon = rest.indexOf(':')
if (colon > 0) triggerKind(rest.slice(0, colon))
}
}
/** Global search → load every kind so results appear across the tree.
* Skip on the empty filter so a bare mount doesn't cold-load anything. */
function onFilterChange(filter: string) {
if (filter.trim() === '') return
ensureAll()
}
return {
get loaded() {
return loaded
},
get loadingKind() {
return loadingKind
},
ensureLoaded,
ensureAll,
ensureForScopeSegment,
onFilterChange
}
}