mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 08:03:33 +00:00
* 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>
143 lines
4.7 KiB
TypeScript
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
|
|
}
|
|
}
|