mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 08:03:45 +00:00
The preview panel showed an item's editor and nothing else. It now shows the deployed page too, as a tab of its own, and that page is where a deployed run happens. **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. **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, 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. **Deployed runs adopt the page.** `run_script` and `run_flow` open the item's own deployed page with the model's arguments in its run form, announced by the strip that says the agent is waiting and declines the call. Run hands those arguments to the parked call rather than starting a job, so the tool still owns the run and the chat card still gets its logs; the tab then becomes that run. Test runs keep the chat's own form — they execute the draft, which the deployed page would not. `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". 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)
|
|
}
|
|
}
|