mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 00:03:11 +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>
190 lines
6.7 KiB
TypeScript
190 lines
6.7 KiB
TypeScript
export type WorkspaceItemKind = 'flow' | 'script' | 'app'
|
|
|
|
export type WorkspaceItem = {
|
|
path: string
|
|
summary: string
|
|
kind: WorkspaceItemKind
|
|
raw_app?: boolean
|
|
/** User-typed friendly path for a draft-only item parked at a
|
|
* `…/draft_<uuid>` storage path (the backend lists' `draft_path`).
|
|
* Display-only: `path` stays the storage key and is what URLs and
|
|
* dedupe must use — routing to the friendly path 404s. */
|
|
draftPath?: string
|
|
/** Nothing is deployed at this path: the row exists only because the
|
|
* listers were asked for `include_draft_only`. Decides which side of an
|
|
* item a picker opens, so it must be invalidated on deploy — a stale
|
|
* `true` sends the reader to the editor of something they just shipped. */
|
|
draftOnly?: boolean
|
|
/** The authed user has a draft for this item. True for a draft-only item too,
|
|
* so read it with `draftOnly` to tell "nothing deployed" from "deployed, with
|
|
* edits pending". */
|
|
hasDraft?: boolean
|
|
}
|
|
|
|
/** Path to display (and group by) in pickers: the friendly draft path when
|
|
* the item has one, else the real path. */
|
|
export function workspaceItemDisplayPath(item: Pick<WorkspaceItem, 'path' | 'draftPath'>): string {
|
|
return item.draftPath ?? item.path
|
|
}
|
|
|
|
/** Display label for kinds — used in the picker rows and search section headers. */
|
|
export const KIND_LABEL: Record<WorkspaceItemKind, string> = {
|
|
flow: 'Flows',
|
|
script: 'Scripts',
|
|
app: 'Apps'
|
|
}
|
|
|
|
/** Lowercase variant — matches the editor breadcrumb style. */
|
|
export const KIND_LABEL_LOWER: Record<WorkspaceItemKind, string> = {
|
|
flow: 'flows',
|
|
script: 'scripts',
|
|
app: 'apps'
|
|
}
|
|
|
|
/** Composite keys used for keyboard nav and `initialHighlight`. The picker and
|
|
* its callers must agree on these — keep them all here. `'all'` is a virtual
|
|
* kind for the cross-kind root view; only valid for `kindKey` / `dirKey`
|
|
* (leaves always belong to a real kind). */
|
|
export const kindKey = (k: WorkspaceItemKind | 'all') => `kind:${k}`
|
|
export const dirKey = (k: WorkspaceItemKind | 'all', fullPath: string) => `dir:${k}:${fullPath}`
|
|
export const leafKeyFor = (k: WorkspaceItemKind, path: string) => `leaf:${k}:${path}`
|
|
|
|
export function editPathFor(item: WorkspaceItem): string {
|
|
if (item.kind === 'flow') return `/flows/edit/${item.path}`
|
|
if (item.kind === 'script') return `/scripts/edit/${item.path}`
|
|
return item.raw_app ? `/apps_raw/edit/${item.path}` : `/apps/edit/${item.path}`
|
|
}
|
|
|
|
/** The deployed-version page for an item, the `/get/` counterpart of {@link editPathFor}. */
|
|
export function viewPathFor(item: WorkspaceItem): string {
|
|
if (item.kind === 'flow') return `/flows/get/${item.path}`
|
|
if (item.kind === 'script') return `/scripts/get/${item.path}`
|
|
return item.raw_app ? `/apps_raw/get/${item.path}` : `/apps/get/${item.path}`
|
|
}
|
|
|
|
type WorkspaceCache = {
|
|
flow?: WorkspaceItem[]
|
|
script?: WorkspaceItem[]
|
|
app?: WorkspaceItem[]
|
|
}
|
|
|
|
/** Module-level session cache. Persists across picker mounts within a single
|
|
* page session so cached kinds render on the first frame. Pickers revalidate
|
|
* once per mount via `loadKind(..., { revalidate: true })`, so stale entries
|
|
* self-heal on the next open; call `invalidate()` after creating/deleting an
|
|
* item when even a brief flash of the stale list must be avoided. */
|
|
const cache = new Map<string, WorkspaceCache>()
|
|
const inflight = new Map<string, Promise<WorkspaceItem[]>>()
|
|
/** Bumped by `invalidate()`. Each in-flight `loadKind` captures the version
|
|
* at start and only writes back to the cache if it still matches — so a
|
|
* deploy mid-fetch can't have its stale predecessor repopulate the cache. */
|
|
const cacheVersion = new Map<string, number>()
|
|
|
|
const cacheKey = (workspace: string, kind: WorkspaceItemKind) => `${workspace}:${kind}`
|
|
|
|
const KINDS: WorkspaceItemKind[] = ['flow', 'script', 'app']
|
|
|
|
function bumpVersion(workspace: string, kind: WorkspaceItemKind) {
|
|
const k = cacheKey(workspace, kind)
|
|
cacheVersion.set(k, (cacheVersion.get(k) ?? 0) + 1)
|
|
}
|
|
|
|
export function getCachedItems(
|
|
workspace: string,
|
|
kind: WorkspaceItemKind
|
|
): WorkspaceItem[] | undefined {
|
|
return cache.get(workspace)?.[kind]
|
|
}
|
|
|
|
/** Drop a workspace+kind (or a whole workspace) from the cache so the next
|
|
* picker open re-fetches. Use after creating/deleting items. Also bumps the
|
|
* version so any in-flight `loadKind` started before the invalidate won't
|
|
* write its (now-stale) result back to the cache. */
|
|
export function invalidate(workspace: string, kind?: WorkspaceItemKind) {
|
|
if (!kind) {
|
|
cache.delete(workspace)
|
|
for (const k of KINDS) bumpVersion(workspace, k)
|
|
return
|
|
}
|
|
const bucket = cache.get(workspace)
|
|
if (bucket) delete bucket[kind]
|
|
bumpVersion(workspace, kind)
|
|
}
|
|
|
|
export async function loadKind(
|
|
workspace: string,
|
|
kind: WorkspaceItemKind,
|
|
opts?: { revalidate?: boolean }
|
|
): Promise<WorkspaceItem[]> {
|
|
const existing = cache.get(workspace)?.[kind]
|
|
if (existing && !opts?.revalidate) return existing
|
|
const key = cacheKey(workspace, kind)
|
|
// An in-flight fetch is already hitting the network, so it satisfies a
|
|
// revalidate request too.
|
|
const flying = inflight.get(key)
|
|
if (flying) return flying
|
|
|
|
const startVersion = cacheVersion.get(key) ?? 0
|
|
const promise = (async () => {
|
|
const { ScriptService, FlowService, AppService } = await import('$lib/gen')
|
|
let items: WorkspaceItem[]
|
|
if (kind === 'flow') {
|
|
const flows = await FlowService.listFlows({
|
|
workspace,
|
|
includeDraftOnly: true,
|
|
withoutDescription: true
|
|
})
|
|
items = flows.map((f) => ({
|
|
path: f.path,
|
|
summary: f.summary ?? '',
|
|
kind: 'flow' as const,
|
|
draftPath: f.draft_path,
|
|
draftOnly: f.draft_only ?? false,
|
|
hasDraft: f.is_draft ?? false
|
|
}))
|
|
} else if (kind === 'script') {
|
|
const scripts = await ScriptService.listScripts({
|
|
workspace,
|
|
includeDraftOnly: true,
|
|
withoutDescription: true
|
|
})
|
|
items = scripts.map((s) => ({
|
|
path: s.path,
|
|
summary: s.summary ?? '',
|
|
kind: 'script' as const,
|
|
draftPath: s.draft_path,
|
|
draftOnly: s.draft_only ?? false,
|
|
hasDraft: s.is_draft ?? false
|
|
}))
|
|
} else {
|
|
const apps = await AppService.listApps({
|
|
workspace,
|
|
includeDraftOnly: true
|
|
})
|
|
items = apps.map((a) => ({
|
|
path: a.path,
|
|
summary: a.summary ?? '',
|
|
kind: 'app' as const,
|
|
raw_app: a.raw_app ?? false,
|
|
draftPath: a.draft_path,
|
|
draftOnly: a.draft_only ?? false,
|
|
hasDraft: a.is_draft ?? false
|
|
}))
|
|
}
|
|
// Only commit if the cache version hasn't changed since we started —
|
|
// otherwise we'd overwrite a deliberate `invalidate()` with stale data.
|
|
if ((cacheVersion.get(key) ?? 0) === startVersion) {
|
|
const bucket = cache.get(workspace) ?? {}
|
|
bucket[kind] = items
|
|
cache.set(workspace, bucket)
|
|
}
|
|
return items
|
|
})()
|
|
inflight.set(key, promise)
|
|
try {
|
|
return await promise
|
|
} finally {
|
|
inflight.delete(key)
|
|
}
|
|
}
|