mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-26 08:01:38 +00:00
feat(ai-chat): linkify variables/resources/triggers + inline drawer
- Workspace item registry now also lists variables, resources, schedules, and all 10 trigger kinds; resource wins over variable on path collisions (Windmill auto-creates a companion variable for every resource). - Pill icons delegated to the canonical RowIcon component so each kind matches the home-page styling (script blue, flow teal, app orange, resource boxes, schedule calendar, etc.). - Pill href includes the hash fragment each list page already consumes (#/resource/<path>, #<path> for variables/schedules/triggers), so opening the link puts the user on the list page with the matching editor drawer already open. - For variable and resource pills, a hover-revealed side-panel button opens (or toggles closed) the editor drawer inline next to the chat, without navigating away. VariableEditor and ResourceEditorDrawer gain a closeDrawer() export and forward their close event so the host can drive toggling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,10 @@
|
||||
drawer?.openDrawer?.()
|
||||
}
|
||||
|
||||
export function closeDrawer(): void {
|
||||
drawer?.closeDrawer?.()
|
||||
}
|
||||
|
||||
export async function initNew(
|
||||
resourceType: string,
|
||||
nDefaultValues?: Record<string, any>
|
||||
@@ -45,10 +49,10 @@
|
||||
let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit')
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="50rem" {disableChatOffset}>
|
||||
<Drawer bind:this={drawer} size="50rem" {disableChatOffset} on:close>
|
||||
<DrawerContent
|
||||
title={mode == 'edit' ? 'Edit ' + path : 'Add a resource'}
|
||||
on:close={drawer?.closeDrawer}
|
||||
on:close={() => drawer?.closeDrawer()}
|
||||
>
|
||||
{#await import('./ResourceEditor.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
|
||||
@@ -136,6 +136,10 @@
|
||||
drawer?.openDrawer()
|
||||
}
|
||||
|
||||
export function closeDrawer(): void {
|
||||
drawer?.closeDrawer()
|
||||
}
|
||||
|
||||
async function loadSecret(): Promise<void> {
|
||||
if (!editPath || !selected) return
|
||||
const getV = await VariableService.getVariable({
|
||||
@@ -196,10 +200,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="50rem">
|
||||
<Drawer bind:this={drawer} size="50rem" on:close>
|
||||
<DrawerContent
|
||||
title={edit ? `Update variable at ${initialPath}` : 'Add a variable'}
|
||||
on:close={drawer?.closeDrawer}
|
||||
on:close={() => drawer?.closeDrawer()}
|
||||
>
|
||||
<div class="flex flex-col gap-8">
|
||||
{#if !can_write}
|
||||
|
||||
@@ -146,6 +146,53 @@ class AIChatManager {
|
||||
/** Cached datatables for app context (fetched asynchronously) */
|
||||
cachedDatatables = $state<AppDatatableElement[]>([])
|
||||
|
||||
/**
|
||||
* Inline-drawer request for a workspace item referenced from a chat message.
|
||||
*
|
||||
* Consumed by the drawer host mounted in AiChatLayout, which calls the matching
|
||||
* editor's open method.
|
||||
*
|
||||
* `version` increments on every request so re-clicking after the drawer was closed
|
||||
* via the X button (which doesn't reset this state) still re-triggers the effect.
|
||||
*/
|
||||
workspaceItemDrawer = $state<
|
||||
| {
|
||||
kind: WorkspaceItemEntry['kind']
|
||||
path: string
|
||||
version: number
|
||||
open: boolean
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
/**
|
||||
* Toggle the inline drawer for a workspace item.
|
||||
*
|
||||
* Behavior:
|
||||
* - If a drawer for the same kind+path is currently open, close it.
|
||||
* - Otherwise, set up state to open (or re-open) the drawer.
|
||||
*/
|
||||
toggleWorkspaceItemDrawer(target: { kind: WorkspaceItemEntry['kind']; path: string }): void {
|
||||
const cur = this.workspaceItemDrawer
|
||||
const sameTarget = cur?.kind === target.kind && cur.path === target.path
|
||||
if (cur?.open && sameTarget) {
|
||||
this.workspaceItemDrawer = { ...cur, open: false, version: cur.version + 1 }
|
||||
return
|
||||
}
|
||||
this.workspaceItemDrawer = {
|
||||
kind: target.kind,
|
||||
path: target.path,
|
||||
version: (cur?.version ?? 0) + 1,
|
||||
open: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark the inline drawer as closed (called by the host when the drawer's own X is hit). */
|
||||
markWorkspaceItemDrawerClosed(): void {
|
||||
if (!this.workspaceItemDrawer?.open) return
|
||||
this.workspaceItemDrawer = { ...this.workspaceItemDrawer, open: false }
|
||||
}
|
||||
|
||||
private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
|
||||
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { Menu } from 'lucide-svelte'
|
||||
import CreatedResourceActionDrawers from './CreatedResourceActionDrawers.svelte'
|
||||
import WorkspaceItemDrawerHost from './WorkspaceItemDrawerHost.svelte'
|
||||
|
||||
interface Props {
|
||||
noPadding?: boolean
|
||||
@@ -50,6 +51,7 @@
|
||||
|
||||
{#if !disableAi}
|
||||
<CreatedResourceActionDrawers />
|
||||
<WorkspaceItemDrawerHost />
|
||||
<Splitpanes horizontal={false} class="flex-1 min-h-0">
|
||||
<Pane size={100 - chatState.size} minSize={50} class="flex flex-col grow min-h-0 ">
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { Code2, ExternalLink, LayoutDashboard } from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import type { WindmillItemKind } from './workspaceItems.svelte'
|
||||
import { ExternalLink, PanelRight } from 'lucide-svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
import { hasInlineDrawer, type WindmillItemKind } from './workspaceItems.svelte'
|
||||
|
||||
type Props = {
|
||||
href?: string
|
||||
@@ -11,10 +12,10 @@
|
||||
'data-wm-path'?: string
|
||||
title?: string
|
||||
}
|
||||
let { href, children, 'data-wm-kind': wmKind, title }: Props = $props()
|
||||
let { href, children, 'data-wm-kind': wmKind, 'data-wm-path': wmPath, title }: Props = $props()
|
||||
|
||||
// Fallback to URL-based detection if the data attribute didn't make it through
|
||||
// (hast/rehype can rename custom properties on some pipelines).
|
||||
// Fallback to URL-based detection for scripts/flows/apps. Other kinds rely on the
|
||||
// data attribute set by the remark plugin.
|
||||
const kind = $derived.by((): WindmillItemKind | undefined => {
|
||||
if (wmKind) return wmKind
|
||||
if (!href) return undefined
|
||||
@@ -23,33 +24,41 @@
|
||||
if (href.startsWith('/apps/get/')) return 'app'
|
||||
return undefined
|
||||
})
|
||||
const drawerable = $derived(kind ? hasInlineDrawer(kind) : false)
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
{#if kind}
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={title || href}
|
||||
class="group inline-flex items-baseline gap-1 px-1 rounded hover:bg-surface-hover text-primary no-underline font-mono text-[0.9em] align-baseline"
|
||||
>
|
||||
<span class="inline-flex self-center shrink-0">
|
||||
{#if kind === 'script'}
|
||||
<Code2 size={12} class="text-blue-500" />
|
||||
{:else if kind === 'flow'}
|
||||
<BarsStaggered size={12} style="" class="!fill-current text-teal-500" />
|
||||
{:else if kind === 'app'}
|
||||
<LayoutDashboard size={12} class="text-orange-500" />
|
||||
{/if}
|
||||
</span>
|
||||
{@render children?.()}
|
||||
<span
|
||||
class="inline-flex self-center shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
<span class="group inline-flex items-baseline">
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={title || wmPath || href}
|
||||
class="inline-flex items-baseline gap-1 px-1 rounded hover:bg-surface-hover text-primary no-underline font-mono text-[0.9em] align-baseline"
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
</span>
|
||||
</a>
|
||||
<span class="inline-flex self-center shrink-0">
|
||||
<RowIcon {kind} size={12} />
|
||||
</span>
|
||||
{@render children?.()}
|
||||
<span
|
||||
class="inline-flex self-center shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
</span>
|
||||
</a>
|
||||
{#if drawerable && wmPath}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => aiChatManager.toggleWorkspaceItemDrawer({ kind, path: wmPath })}
|
||||
title="Open in drawer"
|
||||
aria-label="Open {wmPath} in drawer"
|
||||
class="ml-0.5 inline-flex self-center shrink-0 rounded p-0.5 text-tertiary hover:bg-surface-hover opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<PanelRight size={11} />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<a {href} target="_blank" rel="noopener noreferrer" {title}>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
ChevronRight,
|
||||
XCircle,
|
||||
Play,
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
ExternalLink
|
||||
ExternalLink,
|
||||
PanelRight
|
||||
} from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
@@ -15,10 +14,11 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ToolContentDisplay from './ToolContentDisplay.svelte'
|
||||
import ToolMessageActions from './ToolMessageActions.svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
extractCandidatePaths,
|
||||
hasInlineDrawer,
|
||||
itemHref,
|
||||
workspaceItemRegistry,
|
||||
type WorkspaceItemEntry
|
||||
@@ -100,15 +100,17 @@
|
||||
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
|
||||
>
|
||||
<!-- Collapsible Header -->
|
||||
<button
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
|
||||
'border-b border-gray-200 dark:border-gray-700 bg-surface-secondary',
|
||||
message.needsConfirmation ? 'opacity-80' : ''
|
||||
)}
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
disabled={!message.showDetails && !message.isStreamingArguments}
|
||||
>
|
||||
<div class="flex items-start gap-2 flex-1 min-w-0">
|
||||
<button
|
||||
class="w-full p-2 hover:bg-surface-hover transition-colors flex items-start gap-2 text-left"
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
disabled={!message.showDetails && !message.isStreamingArguments}
|
||||
>
|
||||
{#if message.showDetails || message.isStreamingArguments}
|
||||
<span class="shrink-0 mt-0.5">
|
||||
{#if isExpanded}
|
||||
@@ -128,41 +130,51 @@
|
||||
<span class="text-green-500">✓</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<span class="text-primary font-medium text-2xs">
|
||||
{message.content}
|
||||
</span>
|
||||
{#if referencedItems.length > 0}
|
||||
<div class="flex flex-row flex-wrap items-center gap-1 min-w-0">
|
||||
{#each referencedItems as item (item.path)}
|
||||
<a
|
||||
href={itemHref(item, $workspaceStore ?? undefined)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
title={item.summary || item.path}
|
||||
class="group inline-flex items-center gap-1 px-1 py-0.5 rounded hover:bg-surface-hover text-primary no-underline font-mono text-2xs max-w-full min-w-0"
|
||||
<span class="text-primary font-medium text-2xs flex-1 min-w-0">
|
||||
{message.content}
|
||||
</span>
|
||||
</button>
|
||||
{#if referencedItems.length > 0}
|
||||
<!-- Chip row lives outside the toggle button so we can include real <button>s
|
||||
for the "open in drawer" affordance without nesting interactive elements. -->
|
||||
<div class="flex flex-row flex-wrap items-center gap-1 px-2 pb-2 -mt-1 min-w-0">
|
||||
{#each referencedItems as item (item.path)}
|
||||
<span class="group inline-flex items-center min-w-0 max-w-full">
|
||||
<a
|
||||
href={itemHref(item, $workspaceStore ?? undefined)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={item.summary || item.path}
|
||||
class="inline-flex items-center gap-1 px-1 py-0.5 rounded hover:bg-surface-hover text-primary no-underline font-mono text-2xs min-w-0"
|
||||
>
|
||||
<span class="inline-flex shrink-0">
|
||||
<RowIcon kind={item.kind} size={12} />
|
||||
</span>
|
||||
<span class="truncate">{item.path}</span>
|
||||
<ExternalLink
|
||||
class="w-2.5 h-2.5 shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
{#if hasInlineDrawer(item.kind)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
aiChatManager.toggleWorkspaceItemDrawer({
|
||||
kind: item.kind,
|
||||
path: item.path
|
||||
})}
|
||||
title="Open in drawer"
|
||||
aria-label="Open {item.path} in drawer"
|
||||
class="ml-0.5 inline-flex self-center shrink-0 rounded p-0.5 text-tertiary hover:bg-surface-hover opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<span class="inline-flex shrink-0">
|
||||
{#if item.kind === 'script'}
|
||||
<Code2 class="w-3 h-3 text-blue-500" />
|
||||
{:else if item.kind === 'flow'}
|
||||
<BarsStaggered size={12} style="" class="!fill-current text-teal-500" />
|
||||
{:else}
|
||||
<LayoutDashboard class="w-3 h-3 text-orange-500" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="truncate">{item.path}</span>
|
||||
<ExternalLink
|
||||
class="w-2.5 h-2.5 shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<PanelRight class="w-2.5 h-2.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Expanded Content -->
|
||||
{#if isExpanded}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
|
||||
/**
|
||||
* Hosts the workspace-item drawers next to the chat. When a chat pill asks to open
|
||||
* one, the host calls the matching editor's open method; when the pill is clicked
|
||||
* again (toggle) or the chat manager wants to close, the host calls `closeDrawer`.
|
||||
*
|
||||
* Both editors are mounted unconditionally on purpose. Their internal `<Drawer>`
|
||||
* wires close handling at mount time, and the resources page (which has worked for
|
||||
* a long time) uses the same always-mounted pattern. Conditional mounting via
|
||||
* `{#if}` was racy — the `on:close` handler captured an undefined reference and the
|
||||
* X button stopped closing the drawer. The drawer markup stays hidden until
|
||||
* `openDrawer()` is called, and the heavy editor body is still loaded lazily via
|
||||
* `import()` inside `ResourceEditorDrawer`, so this isn't expensive.
|
||||
*/
|
||||
|
||||
let variableEditor: VariableEditor | undefined = $state()
|
||||
let resourceEditor: ResourceEditorDrawer | undefined = $state()
|
||||
|
||||
let lastVersion = -1
|
||||
|
||||
$effect(() => {
|
||||
const t = aiChatManager.workspaceItemDrawer
|
||||
if (!t) return
|
||||
// React on every version bump (covers open, re-open, and toggle-close).
|
||||
if (t.version === lastVersion) return
|
||||
lastVersion = t.version
|
||||
if (!t.open) {
|
||||
variableEditor?.closeDrawer()
|
||||
resourceEditor?.closeDrawer()
|
||||
return
|
||||
}
|
||||
if (t.kind === 'variable' && variableEditor) {
|
||||
variableEditor.editVariable(t.path)
|
||||
} else if (t.kind === 'resource' && resourceEditor) {
|
||||
resourceEditor.initEdit(t.path)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<VariableEditor
|
||||
bind:this={variableEditor}
|
||||
on:close={() => aiChatManager.markWorkspaceItemDrawerClosed()}
|
||||
/>
|
||||
<ResourceEditorDrawer
|
||||
bind:this={resourceEditor}
|
||||
on:close={() => aiChatManager.markWorkspaceItemDrawerClosed()}
|
||||
/>
|
||||
@@ -1,9 +1,42 @@
|
||||
import { AppService, FlowService, ScriptService } from '$lib/gen'
|
||||
import {
|
||||
AppService,
|
||||
AzureTriggerService,
|
||||
EmailTriggerService,
|
||||
FlowService,
|
||||
GcpTriggerService,
|
||||
HttpTriggerService,
|
||||
KafkaTriggerService,
|
||||
MqttTriggerService,
|
||||
NatsTriggerService,
|
||||
PostgresTriggerService,
|
||||
ResourceService,
|
||||
ScheduleService,
|
||||
ScriptService,
|
||||
SqsTriggerService,
|
||||
VariableService,
|
||||
WebsocketTriggerService
|
||||
} from '$lib/gen'
|
||||
import { findAndReplace } from 'mdast-util-find-and-replace'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Root, InlineCode, Link } from 'mdast'
|
||||
|
||||
export type WindmillItemKind = 'script' | 'flow' | 'app'
|
||||
export type WindmillItemKind =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'app'
|
||||
| 'variable'
|
||||
| 'resource'
|
||||
| 'schedule'
|
||||
| 'http_trigger'
|
||||
| 'websocket_trigger'
|
||||
| 'kafka_trigger'
|
||||
| 'nats_trigger'
|
||||
| 'postgres_trigger'
|
||||
| 'mqtt_trigger'
|
||||
| 'sqs_trigger'
|
||||
| 'gcp_trigger'
|
||||
| 'azure_trigger'
|
||||
| 'email_trigger'
|
||||
|
||||
export interface WorkspaceItemEntry {
|
||||
kind: WindmillItemKind
|
||||
@@ -30,16 +63,60 @@ export const WINDMILL_PATH_REGEX =
|
||||
*/
|
||||
const WINDMILL_PATH_EXACT_REGEX = /^[uf]\/[A-Za-z0-9_.\-]+\/[A-Za-z0-9_./\-]*[A-Za-z0-9_\-]$/
|
||||
|
||||
const itemKindToRoute: Record<WindmillItemKind, string> = {
|
||||
script: '/scripts/get',
|
||||
flow: '/flows/get',
|
||||
app: '/apps/get'
|
||||
/**
|
||||
* URL builder per kind.
|
||||
*
|
||||
* - For scripts/flows/apps we link to the dedicated `/get/{path}` page.
|
||||
* - For everything else, link to the relevant list page with a hash fragment that the
|
||||
* list page reads on mount to pop the editor drawer open. Hash format mirrors what
|
||||
* each list page already expects (`#<path>` for most, `#/resource/<path>` for the
|
||||
* resources page).
|
||||
*/
|
||||
const itemKindToHref: Record<WindmillItemKind, (path: string) => string> = {
|
||||
script: (p) => `/scripts/get/${p}`,
|
||||
flow: (p) => `/flows/get/${p}`,
|
||||
app: (p) => `/apps/get/${p}`,
|
||||
variable: (p) => `/variables#${p}`,
|
||||
resource: (p) => `/resources#/resource/${p}`,
|
||||
schedule: (p) => `/schedules#${p}`,
|
||||
http_trigger: (p) => `/routes#${p}`,
|
||||
websocket_trigger: (p) => `/websocket_triggers/#${p}`,
|
||||
kafka_trigger: (p) => `/kafka_triggers/#${p}`,
|
||||
nats_trigger: (p) => `/nats_triggers/#${p}`,
|
||||
postgres_trigger: (p) => `/postgres_triggers/#${p}`,
|
||||
mqtt_trigger: (p) => `/mqtt_triggers/#${p}`,
|
||||
sqs_trigger: (p) => `/sqs_triggers/#${p}`,
|
||||
gcp_trigger: (p) => `/gcp_triggers/#${p}`,
|
||||
azure_trigger: (p) => `/azure_triggers/#${p}`,
|
||||
email_trigger: (p) => `/email_triggers/#${p}`
|
||||
}
|
||||
|
||||
/** Build the in-app URL for a resolved workspace item. */
|
||||
/** Kinds whose items can be opened in an inline drawer from the chat. */
|
||||
const DRAWERABLE_KINDS = new Set<WindmillItemKind>(['variable', 'resource'])
|
||||
|
||||
/** Whether the chat pill should expose an "open in drawer" affordance for this kind. */
|
||||
export function hasInlineDrawer(kind: WindmillItemKind): boolean {
|
||||
return DRAWERABLE_KINDS.has(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the in-app URL for a resolved workspace item.
|
||||
*
|
||||
* The workspace query param goes before the hash so the SvelteKit router still applies
|
||||
* it; the hash fragment is consumed client-side by the list page on mount to open the
|
||||
* matching drawer.
|
||||
*/
|
||||
export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string {
|
||||
const base = `${itemKindToRoute[entry.kind]}/${entry.path}`
|
||||
return workspace ? `${base}?workspace=${workspace}` : base
|
||||
const raw = itemKindToHref[entry.kind](entry.path)
|
||||
if (!workspace) return raw
|
||||
const hashIdx = raw.indexOf('#')
|
||||
if (hashIdx === -1) {
|
||||
return raw.includes('?') ? `${raw}&workspace=${workspace}` : `${raw}?workspace=${workspace}`
|
||||
}
|
||||
const pathPart = raw.slice(0, hashIdx)
|
||||
const hashPart = raw.slice(hashIdx)
|
||||
const sep = pathPart.includes('?') ? '&' : '?'
|
||||
return `${pathPart}${sep}workspace=${workspace}${hashPart}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,22 +131,78 @@ class WorkspaceItemRegistry {
|
||||
#inflight: Map<string, Promise<void>> = new Map()
|
||||
|
||||
private async load(workspace: string): Promise<void> {
|
||||
const [scripts, flows, apps] = await Promise.all([
|
||||
// Fire every list endpoint in parallel. `.catch(() => [])` keeps a single failing
|
||||
// endpoint from poisoning the whole snapshot — the registry just records empty
|
||||
// for that kind, the rest still resolve.
|
||||
const [
|
||||
scripts,
|
||||
flows,
|
||||
apps,
|
||||
variables,
|
||||
resources,
|
||||
schedules,
|
||||
httpTriggers,
|
||||
wsTriggers,
|
||||
kafkaTriggers,
|
||||
natsTriggers,
|
||||
pgTriggers,
|
||||
mqttTriggers,
|
||||
sqsTriggers,
|
||||
gcpTriggers,
|
||||
azureTriggers,
|
||||
emailTriggers
|
||||
] = await Promise.all([
|
||||
ScriptService.listScripts({ workspace }).catch(() => []),
|
||||
FlowService.listFlows({ workspace }).catch(() => []),
|
||||
AppService.listApps({ workspace }).catch(() => [])
|
||||
AppService.listApps({ workspace }).catch(() => []),
|
||||
VariableService.listVariable({ workspace }).catch(() => []),
|
||||
ResourceService.listResource({ workspace }).catch(() => []),
|
||||
ScheduleService.listSchedules({ workspace }).catch(() => []),
|
||||
HttpTriggerService.listHttpTriggers({ workspace }).catch(() => []),
|
||||
WebsocketTriggerService.listWebsocketTriggers({ workspace }).catch(() => []),
|
||||
KafkaTriggerService.listKafkaTriggers({ workspace }).catch(() => []),
|
||||
NatsTriggerService.listNatsTriggers({ workspace }).catch(() => []),
|
||||
PostgresTriggerService.listPostgresTriggers({ workspace }).catch(() => []),
|
||||
MqttTriggerService.listMqttTriggers({ workspace }).catch(() => []),
|
||||
SqsTriggerService.listSqsTriggers({ workspace }).catch(() => []),
|
||||
GcpTriggerService.listGcpTriggers({ workspace }).catch(() => []),
|
||||
AzureTriggerService.listAzureTriggers({ workspace }).catch(() => []),
|
||||
EmailTriggerService.listEmailTriggers({ workspace }).catch(() => [])
|
||||
])
|
||||
|
||||
const map = new Map<string, WorkspaceItemEntry>()
|
||||
for (const s of scripts) {
|
||||
map.set(s.path, { kind: 'script', path: s.path, summary: s.summary })
|
||||
}
|
||||
for (const f of flows) {
|
||||
map.set(f.path, { kind: 'flow', path: f.path, summary: f.summary })
|
||||
}
|
||||
for (const a of apps) {
|
||||
map.set(a.path, { kind: 'app', path: a.path, summary: a.summary })
|
||||
const add = (
|
||||
items: Array<{ path: string; summary?: string | null }>,
|
||||
kind: WindmillItemKind
|
||||
) => {
|
||||
for (const it of items) {
|
||||
// First writer wins — scripts/flows/apps are checked first so they win over
|
||||
// triggers if a path collides (extremely unlikely but possible across kinds).
|
||||
if (!map.has(it.path)) {
|
||||
map.set(it.path, { kind, path: it.path, summary: it.summary ?? undefined })
|
||||
}
|
||||
}
|
||||
}
|
||||
// Order matters because of first-writer-wins on path collisions. Resources are
|
||||
// added before variables: Windmill auto-creates a companion variable at the same
|
||||
// path for every resource, and a path written by the user almost always means the
|
||||
// resource, not the hidden variable.
|
||||
add(scripts, 'script')
|
||||
add(flows, 'flow')
|
||||
add(apps, 'app')
|
||||
add(resources, 'resource')
|
||||
add(variables, 'variable')
|
||||
add(schedules, 'schedule')
|
||||
add(httpTriggers, 'http_trigger')
|
||||
add(wsTriggers, 'websocket_trigger')
|
||||
add(kafkaTriggers, 'kafka_trigger')
|
||||
add(natsTriggers, 'nats_trigger')
|
||||
add(pgTriggers, 'postgres_trigger')
|
||||
add(mqttTriggers, 'mqtt_trigger')
|
||||
add(sqsTriggers, 'sqs_trigger')
|
||||
add(gcpTriggers, 'gcp_trigger')
|
||||
add(azureTriggers, 'azure_trigger')
|
||||
add(emailTriggers, 'email_trigger')
|
||||
|
||||
// Build a new outer map to trigger reactivity on consumers using $derived.
|
||||
const next = new Map(this.#byWorkspace)
|
||||
|
||||
@@ -7,7 +7,20 @@ import type { Root as MdastRoot, Link, Text } from 'mdast'
|
||||
vi.mock('$lib/gen', () => ({
|
||||
ScriptService: { listScripts: vi.fn() },
|
||||
FlowService: { listFlows: vi.fn() },
|
||||
AppService: { listApps: vi.fn() }
|
||||
AppService: { listApps: vi.fn() },
|
||||
VariableService: { listVariable: vi.fn() },
|
||||
ResourceService: { listResource: vi.fn() },
|
||||
ScheduleService: { listSchedules: vi.fn() },
|
||||
HttpTriggerService: { listHttpTriggers: vi.fn() },
|
||||
WebsocketTriggerService: { listWebsocketTriggers: vi.fn() },
|
||||
KafkaTriggerService: { listKafkaTriggers: vi.fn() },
|
||||
NatsTriggerService: { listNatsTriggers: vi.fn() },
|
||||
PostgresTriggerService: { listPostgresTriggers: vi.fn() },
|
||||
MqttTriggerService: { listMqttTriggers: vi.fn() },
|
||||
SqsTriggerService: { listSqsTriggers: vi.fn() },
|
||||
GcpTriggerService: { listGcpTriggers: vi.fn() },
|
||||
AzureTriggerService: { listAzureTriggers: vi.fn() },
|
||||
EmailTriggerService: { listEmailTriggers: vi.fn() }
|
||||
}))
|
||||
|
||||
import {
|
||||
@@ -62,7 +75,7 @@ describe('WINDMILL_PATH_REGEX', () => {
|
||||
})
|
||||
|
||||
describe('itemHref', () => {
|
||||
it('routes by kind and appends ?workspace when provided', () => {
|
||||
it('routes script/flow/app to /get/{path}', () => {
|
||||
expect(itemHref({ kind: 'script', path: 'f/a/b' })).toBe('/scripts/get/f/a/b')
|
||||
expect(itemHref({ kind: 'flow', path: 'f/a/b' }, 'admins')).toBe(
|
||||
'/flows/get/f/a/b?workspace=admins'
|
||||
@@ -71,6 +84,41 @@ describe('itemHref', () => {
|
||||
'/apps/get/u/me/dash?workspace=ws1'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes variable / resource / schedule to list page with hash to pop the drawer', () => {
|
||||
expect(itemHref({ kind: 'variable', path: 'u/me/secret' })).toBe('/variables#u/me/secret')
|
||||
expect(itemHref({ kind: 'resource', path: 'u/me/db' }, 'ws1')).toBe(
|
||||
'/resources?workspace=ws1#/resource/u/me/db'
|
||||
)
|
||||
expect(itemHref({ kind: 'schedule', path: 'f/etl/daily' })).toBe('/schedules#f/etl/daily')
|
||||
})
|
||||
|
||||
it('routes each trigger kind with hash for drawer auto-open', () => {
|
||||
const cases: Array<[WorkspaceItemEntry['kind'], string]> = [
|
||||
['http_trigger', '/routes#f/a/b'],
|
||||
['websocket_trigger', '/websocket_triggers/#f/a/b'],
|
||||
['kafka_trigger', '/kafka_triggers/#f/a/b'],
|
||||
['nats_trigger', '/nats_triggers/#f/a/b'],
|
||||
['postgres_trigger', '/postgres_triggers/#f/a/b'],
|
||||
['mqtt_trigger', '/mqtt_triggers/#f/a/b'],
|
||||
['sqs_trigger', '/sqs_triggers/#f/a/b'],
|
||||
['gcp_trigger', '/gcp_triggers/#f/a/b'],
|
||||
['azure_trigger', '/azure_triggers/#f/a/b'],
|
||||
['email_trigger', '/email_triggers/#f/a/b']
|
||||
]
|
||||
for (const [kind, expected] of cases) {
|
||||
expect(itemHref({ kind, path: 'f/a/b' })).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('puts workspace query param before the hash so the router applies it', () => {
|
||||
expect(itemHref({ kind: 'variable', path: 'u/me/secret' }, 'ws1')).toBe(
|
||||
'/variables?workspace=ws1#u/me/secret'
|
||||
)
|
||||
expect(itemHref({ kind: 'http_trigger', path: 'f/a/b' }, 'ws1')).toBe(
|
||||
'/routes?workspace=ws1#f/a/b'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const SAMPLE_ENTRIES: Record<string, WorkspaceItemEntry> = {
|
||||
|
||||
Reference in New Issue
Block a user