mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 00:00:46 +00:00
feat: open ai chat path links in drawers (#9220)
* feat(ai-chat): link workspace paths and show tool item references Detect Windmill paths (u/..., f/...) in assistant messages and render them as clickable pills with the right icon, resolved against a per- workspace cache. Tool execution headers now list the script/flow/app paths referenced in tool parameters as external links. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ai-chat): linkify inline-code paths, refine pill styling - Inline-code spans whose value is exactly a Windmill path now render as a link pill (paths inside larger inline code or fenced blocks stay as code). - Tool-header chips moved to their own row to avoid overflow clipping when the title wraps. - Borderless pills, no default background (hover only), kind icons use the home-page palette (script blue, flow teal, app orange), and the external-link indicator only appears on hover. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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> * refactor: simplify ai chat workspace item links * refactor: keep ai chat path linkification only * perf: avoid eager ai chat path cache loads * refactor: simplify ai chat path linking * feat: open ai chat path links in drawers * refactor: homogenize workspace item kinds * fix: toggle ai chat item drawer * refactor: trim ai chat path cache * fix: cancel ai chat drawer reopen --------- Co-authored-by: Guilhem Lemouel <guilhemlemouel@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Generated
+2
@@ -51,6 +51,7 @@
|
||||
"jszip": "^3.10.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"minimatch": "^10.0.1",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
|
||||
"monaco-languageclient": "10.6.0",
|
||||
@@ -71,6 +72,7 @@
|
||||
"svelte-exmarkdown": "^5.0.0",
|
||||
"svelte-infinite-loading": "^1.4.0",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
|
||||
"vscode-languageclient": "~9.0.1",
|
||||
"vscode-uri": "~3.1.0",
|
||||
|
||||
@@ -124,6 +124,8 @@
|
||||
"jszip": "^3.10.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
|
||||
"monaco-languageclient": "10.6.0",
|
||||
|
||||
@@ -4,12 +4,56 @@
|
||||
import type { DisplayMessage } from './shared'
|
||||
import CodeDisplay from './script/CodeDisplay.svelte'
|
||||
import LinkRenderer from './LinkRenderer.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
extractCandidatePaths,
|
||||
remarkWindmillPaths,
|
||||
workspaceItemRegistry
|
||||
} from './workspaceItems.svelte'
|
||||
|
||||
interface Props {
|
||||
message: DisplayMessage
|
||||
}
|
||||
|
||||
let { message }: Props = $props()
|
||||
|
||||
const candidatePaths = $derived(extractCandidatePaths(message.content))
|
||||
const rendererPlugin = {
|
||||
renderer: {
|
||||
pre: CodeDisplay,
|
||||
a: LinkRenderer
|
||||
}
|
||||
}
|
||||
|
||||
// Only populate the registry for messages that contain path-shaped tokens. The
|
||||
// registry still dedups concurrent calls across messages and workspaces.
|
||||
$effect(() => {
|
||||
const ws = $workspaceStore
|
||||
if (ws && candidatePaths.length > 0) workspaceItemRegistry.ensureLoaded(ws)
|
||||
})
|
||||
|
||||
const plugins = $derived.by(() => {
|
||||
const ws = $workspaceStore ?? ''
|
||||
if (!ws || candidatePaths.length === 0) {
|
||||
return [gfmPlugin(), rendererPlugin]
|
||||
}
|
||||
|
||||
if (!workspaceItemRegistry.isLoaded(ws)) {
|
||||
return [gfmPlugin(), rendererPlugin]
|
||||
}
|
||||
|
||||
return [
|
||||
gfmPlugin(),
|
||||
{
|
||||
remarkPlugin: remarkWindmillPaths({
|
||||
resolve: (path) => workspaceItemRegistry.resolve(ws, path),
|
||||
workspace: ws || undefined
|
||||
}),
|
||||
renderer: {}
|
||||
},
|
||||
rendererPlugin
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -20,16 +64,5 @@
|
||||
prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs
|
||||
prose-table:block prose-table:max-w-full prose-table:overflow-x-auto prose-table:text-xs"
|
||||
>
|
||||
<Markdown
|
||||
md={message.content}
|
||||
plugins={[
|
||||
gfmPlugin(),
|
||||
{
|
||||
renderer: {
|
||||
pre: CodeDisplay,
|
||||
a: LinkRenderer
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<Markdown md={message.content} {plugins} />
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,10 @@
|
||||
azure: {
|
||||
label: 'Azure Event Grid trigger',
|
||||
load: () => import('$lib/components/triggers/azure/AzureTriggerEditorInner.svelte')
|
||||
},
|
||||
email: {
|
||||
label: 'Email trigger',
|
||||
load: () => import('$lib/components/triggers/email/EmailTriggerEditorInner.svelte')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,13 +143,25 @@
|
||||
throw new Error('Missing trigger kind')
|
||||
}
|
||||
|
||||
if (activeDrawer?.key === key && activeDrawer.path === action.path && drawer?.isOpen()) {
|
||||
activeDrawer = undefined
|
||||
editor = undefined
|
||||
drawer.closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
const config = drawerConfigs[key]
|
||||
const promise = activeDrawer?.key === key ? activeDrawer.promise : config.load()
|
||||
if (activeDrawer?.key !== key) {
|
||||
editor = undefined
|
||||
}
|
||||
|
||||
const request: ActiveDrawerState = { id: nextActiveDrawerId++, key, path: action.path, promise }
|
||||
const request: ActiveDrawerState = {
|
||||
id: nextActiveDrawerId++,
|
||||
key,
|
||||
path: action.path,
|
||||
promise
|
||||
}
|
||||
activeDrawer = request
|
||||
drawer?.openDrawer()
|
||||
|
||||
|
||||
@@ -1,15 +1,81 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { ExternalLink, PanelRight } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import { runToolDisplayAction } from './createdResourceActions.svelte'
|
||||
import {
|
||||
workspaceItemAction,
|
||||
type WindmillItemKind,
|
||||
type WorkspaceItemTargetKind
|
||||
} from './workspaceItems.svelte'
|
||||
|
||||
type Props = {
|
||||
href?: string
|
||||
children?: Snippet
|
||||
'data-wm-kind'?: WindmillItemKind
|
||||
'data-wm-path'?: string
|
||||
'data-wm-target-kind'?: WorkspaceItemTargetKind
|
||||
title?: string
|
||||
}
|
||||
let {
|
||||
href,
|
||||
children,
|
||||
'data-wm-kind': wmKind,
|
||||
'data-wm-path': wmPath,
|
||||
'data-wm-target-kind': wmTargetKind,
|
||||
title
|
||||
}: Props = $props()
|
||||
|
||||
const drawerAction = $derived(workspaceItemAction(wmKind, wmPath, wmTargetKind))
|
||||
|
||||
async function openDrawer(event?: Event) {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (drawerAction) {
|
||||
await runToolDisplayAction(drawerAction)
|
||||
}
|
||||
}
|
||||
let { href, children }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} target="_blank" rel="noopener noreferrer">
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{#if wmKind}
|
||||
<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"
|
||||
>
|
||||
<span class="inline-flex self-center shrink-0">
|
||||
<RowIcon kind={wmKind} 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 drawerAction}
|
||||
<Button
|
||||
type="button"
|
||||
size="xs3"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: PanelRight }}
|
||||
title="Open in drawer"
|
||||
aria-label="Open {wmPath} in drawer"
|
||||
wrapperClasses="ml-0.5 inline-flex self-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
btnClasses="!w-auto !rounded !p-0.5 !text-tertiary"
|
||||
onClick={openDrawer}
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<a {href} target="_blank" rel="noopener noreferrer" {title}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
Calendar,
|
||||
Database,
|
||||
KeyRound,
|
||||
Mail,
|
||||
Package,
|
||||
Route,
|
||||
SquarePen,
|
||||
@@ -44,7 +45,8 @@
|
||||
mqtt: { title: 'MQTT trigger', icon: MqttIcon },
|
||||
sqs: { title: 'SQS trigger', icon: AwsIcon },
|
||||
gcp: { title: 'GCP Pub/Sub trigger', icon: GoogleCloudIcon },
|
||||
azure: { title: 'Azure Event Grid trigger', icon: AzureIcon }
|
||||
azure: { title: 'Azure Event Grid trigger', icon: AzureIcon },
|
||||
email: { title: 'Email trigger', icon: Mail }
|
||||
}
|
||||
|
||||
function getActionCardConfig(action: ToolDisplayAction): ActionCardConfig {
|
||||
|
||||
@@ -466,6 +466,7 @@ export type CreatedResourceTriggerKind =
|
||||
| 'sqs'
|
||||
| 'gcp'
|
||||
| 'azure'
|
||||
| 'email'
|
||||
|
||||
export type CreatedResourceAction = {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
AppService,
|
||||
AzureTriggerService,
|
||||
EmailTriggerService,
|
||||
FlowService,
|
||||
GcpTriggerService,
|
||||
HttpTriggerService,
|
||||
KafkaTriggerService,
|
||||
MqttTriggerService,
|
||||
NatsTriggerService,
|
||||
PostgresTriggerService,
|
||||
ResourceService,
|
||||
ScheduleService,
|
||||
ScriptService,
|
||||
SqsTriggerService,
|
||||
VariableService,
|
||||
WebsocketTriggerService
|
||||
} from '$lib/gen'
|
||||
import { itemHref as offboardingItemHref } from '$lib/components/offboarding-utils'
|
||||
import { findAndReplace } from 'mdast-util-find-and-replace'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Root, InlineCode, Link } from 'mdast'
|
||||
import type { ToolDisplayAction } from './shared'
|
||||
|
||||
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
|
||||
path: string
|
||||
targetKind?: WorkspaceItemTargetKind
|
||||
}
|
||||
|
||||
export type WorkspaceItemTargetKind = 'script' | 'flow'
|
||||
|
||||
/**
|
||||
* Matches Windmill paths of the form `u/<owner>/<path>` or `f/<folder>/<path>`.
|
||||
*
|
||||
* Owners may contain `[A-Za-z0-9._-]`; the trailing path can include dots and slashes
|
||||
* (sub-paths, version segments) but must end on an alphanumeric / underscore / hyphen —
|
||||
* this prevents the regex from gobbling sentence punctuation like the period in
|
||||
* "look at f/foo/bar."
|
||||
*
|
||||
* The negative lookbehind prevents matches embedded in URLs or longer identifiers.
|
||||
*/
|
||||
export const WINDMILL_PATH_REGEX =
|
||||
/(?<![A-Za-z0-9/_.\-])([uf]\/[A-Za-z0-9_.\-]+\/[A-Za-z0-9_./\-]*[A-Za-z0-9_\-])/g
|
||||
|
||||
/**
|
||||
* Anchored variant of {@link WINDMILL_PATH_REGEX} for use against a whole string —
|
||||
* matches when the entire input is exactly a path (after trimming).
|
||||
*/
|
||||
const WINDMILL_PATH_EXACT_REGEX = /^[uf]\/[A-Za-z0-9_.\-]+\/[A-Za-z0-9_./\-]*[A-Za-z0-9_\-]$/
|
||||
|
||||
function workspaceItemTriggerKind(kind: WindmillItemKind): ToolDisplayAction['triggerKind'] {
|
||||
if (!kind.endsWith('_trigger')) return undefined
|
||||
return kind.slice(0, -'_trigger'.length) as ToolDisplayAction['triggerKind']
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 preserved for destination pages that consume it.
|
||||
*/
|
||||
export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string {
|
||||
const raw = offboardingItemHref(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}`
|
||||
}
|
||||
|
||||
type WorkspaceItemListResult = Array<{
|
||||
path: string
|
||||
is_flow?: boolean | null
|
||||
}>
|
||||
|
||||
const workspaceItemLoaders: Array<{
|
||||
kind: WindmillItemKind
|
||||
list: (workspace: string) => Promise<WorkspaceItemListResult>
|
||||
}> = [
|
||||
// First writer wins on path collisions. Keep resources before variables because
|
||||
// Windmill creates a companion variable for each resource at the same path.
|
||||
{ kind: 'script', list: (workspace) => ScriptService.listScripts({ workspace }) },
|
||||
{ kind: 'flow', list: (workspace) => FlowService.listFlows({ workspace }) },
|
||||
{ kind: 'app', list: (workspace) => AppService.listApps({ workspace }) },
|
||||
{ kind: 'resource', list: (workspace) => ResourceService.listResource({ workspace }) },
|
||||
{ kind: 'variable', list: (workspace) => VariableService.listVariable({ workspace }) },
|
||||
{ kind: 'schedule', list: (workspace) => ScheduleService.listSchedules({ workspace }) },
|
||||
{ kind: 'http_trigger', list: (workspace) => HttpTriggerService.listHttpTriggers({ workspace }) },
|
||||
{
|
||||
kind: 'websocket_trigger',
|
||||
list: (workspace) => WebsocketTriggerService.listWebsocketTriggers({ workspace })
|
||||
},
|
||||
{
|
||||
kind: 'kafka_trigger',
|
||||
list: (workspace) => KafkaTriggerService.listKafkaTriggers({ workspace })
|
||||
},
|
||||
{ kind: 'nats_trigger', list: (workspace) => NatsTriggerService.listNatsTriggers({ workspace }) },
|
||||
{
|
||||
kind: 'postgres_trigger',
|
||||
list: (workspace) => PostgresTriggerService.listPostgresTriggers({ workspace })
|
||||
},
|
||||
{ kind: 'mqtt_trigger', list: (workspace) => MqttTriggerService.listMqttTriggers({ workspace }) },
|
||||
{ kind: 'sqs_trigger', list: (workspace) => SqsTriggerService.listSqsTriggers({ workspace }) },
|
||||
{ kind: 'gcp_trigger', list: (workspace) => GcpTriggerService.listGcpTriggers({ workspace }) },
|
||||
{
|
||||
kind: 'azure_trigger',
|
||||
list: (workspace) => AzureTriggerService.listAzureTriggers({ workspace })
|
||||
},
|
||||
{
|
||||
kind: 'email_trigger',
|
||||
list: (workspace) => EmailTriggerService.listEmailTriggers({ workspace })
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Reactive registry that caches workspace item paths per workspace.
|
||||
*
|
||||
* - Loads lazily on first call to `ensureLoaded`
|
||||
* - Dedups concurrent in-flight loads
|
||||
* - Exposes a reactive map (`$state`) so consumers re-render once data lands
|
||||
*/
|
||||
class WorkspaceItemRegistry {
|
||||
#byWorkspace: Map<string, Map<string, WorkspaceItemEntry>> = $state(new Map())
|
||||
#inflight: Map<string, Promise<void>> = new Map()
|
||||
|
||||
private async load(workspace: string): Promise<void> {
|
||||
const loadedItems = await Promise.all(
|
||||
workspaceItemLoaders.map(async ({ kind, list }) => ({
|
||||
kind,
|
||||
items: await list(workspace).catch(() => [])
|
||||
}))
|
||||
)
|
||||
|
||||
const map = new Map<string, WorkspaceItemEntry>()
|
||||
for (const { kind, items } of loadedItems) {
|
||||
for (const it of items) {
|
||||
if (!map.has(it.path)) {
|
||||
map.set(it.path, {
|
||||
kind,
|
||||
path: it.path,
|
||||
targetKind:
|
||||
typeof it.is_flow === 'boolean' ? (it.is_flow ? 'flow' : 'script') : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a new outer map to trigger reactivity on consumers using $derived.
|
||||
const next = new Map(this.#byWorkspace)
|
||||
next.set(workspace, map)
|
||||
this.#byWorkspace = next
|
||||
}
|
||||
|
||||
/** Ensure the workspace items are loaded. Returns the in-flight promise if any. */
|
||||
ensureLoaded(workspace: string): Promise<void> {
|
||||
if (!workspace) return Promise.resolve()
|
||||
if (this.#byWorkspace.has(workspace)) return Promise.resolve()
|
||||
let pending = this.#inflight.get(workspace)
|
||||
if (!pending) {
|
||||
pending = this.load(workspace).finally(() => this.#inflight.delete(workspace))
|
||||
this.#inflight.set(workspace, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
/** Synchronously resolve a path. Returns undefined if the workspace isn't loaded yet. */
|
||||
resolve(workspace: string, path: string): WorkspaceItemEntry | undefined {
|
||||
if (!workspace) return undefined
|
||||
return this.#byWorkspace.get(workspace)?.get(path)
|
||||
}
|
||||
|
||||
/** Whether the registry has data for the given workspace. */
|
||||
isLoaded(workspace: string): boolean {
|
||||
return this.#byWorkspace.has(workspace)
|
||||
}
|
||||
}
|
||||
|
||||
export const workspaceItemRegistry = new WorkspaceItemRegistry()
|
||||
|
||||
/** Extract every Windmill-looking path from raw text (no resolution against the registry). */
|
||||
export function extractCandidatePaths(text: string | undefined | null): string[] {
|
||||
if (!text) return []
|
||||
const seen = new Set<string>()
|
||||
for (const match of text.matchAll(WINDMILL_PATH_REGEX)) {
|
||||
seen.add(match[1])
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
export function workspaceItemAction(
|
||||
kind: WindmillItemKind | undefined,
|
||||
path: string | undefined,
|
||||
targetKind?: WorkspaceItemTargetKind
|
||||
): ToolDisplayAction | undefined {
|
||||
if (!kind || !path) return undefined
|
||||
|
||||
const base = {
|
||||
id: `open_workspace_item:${kind}:${path}`,
|
||||
type: 'open_created_resource' as const,
|
||||
label: `Open ${path}`,
|
||||
path
|
||||
}
|
||||
|
||||
if (kind === 'resource' || kind === 'variable') {
|
||||
return { ...base, resource: kind }
|
||||
}
|
||||
|
||||
if (kind === 'schedule') {
|
||||
return targetKind ? { ...base, resource: 'schedule', targetKind } : undefined
|
||||
}
|
||||
|
||||
const triggerKind = workspaceItemTriggerKind(kind)
|
||||
if (!triggerKind || !targetKind) return undefined
|
||||
return { ...base, resource: 'trigger', triggerKind, targetKind }
|
||||
}
|
||||
|
||||
/** Build the link node used to replace a resolved path token. */
|
||||
function buildPathLinkNode(
|
||||
entry: WorkspaceItemEntry,
|
||||
displayPath: string,
|
||||
workspace: string | undefined
|
||||
): Link {
|
||||
const hProperties: Record<string, string> = {
|
||||
'data-wm-kind': entry.kind,
|
||||
'data-wm-path': entry.path
|
||||
}
|
||||
if (entry.targetKind) {
|
||||
hProperties['data-wm-target-kind'] = entry.targetKind
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link',
|
||||
url: itemHref(entry, workspace),
|
||||
title: null,
|
||||
data: {
|
||||
hProperties
|
||||
},
|
||||
children: [{ type: 'text', value: displayPath }]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remark plugin that rewrites Windmill path tokens (`u/...`, `f/...`) into link nodes,
|
||||
* but only when the path resolves to a known workspace item.
|
||||
*
|
||||
* Handles two cases:
|
||||
* 1. Bare path tokens in regular text — handled by `findAndReplace`, which only visits Text
|
||||
* nodes (so fenced code and inline code are naturally skipped). We additionally `ignore`
|
||||
* existing `link` nodes so we don't break autolinked URLs.
|
||||
* 2. Inline-code spans whose entire content is a single path — handled by a second pass via
|
||||
* `unist-util-visit`. LLMs often wrap identifiers in backticks (`` `u/admin/foo` ``);
|
||||
* when the inline code is *just* a path we treat the backticks as styling and replace
|
||||
* the node with a link pill. Mixed inline-code content (e.g. `` `f/foo + extra text` ``)
|
||||
* is left untouched.
|
||||
*/
|
||||
export function remarkWindmillPaths(options: {
|
||||
resolve: (path: string) => WorkspaceItemEntry | undefined
|
||||
workspace?: string
|
||||
}) {
|
||||
return () => (tree: Root) => {
|
||||
findAndReplace(
|
||||
tree,
|
||||
[
|
||||
WINDMILL_PATH_REGEX,
|
||||
(_match: string, path: string) => {
|
||||
const entry = options.resolve(path)
|
||||
if (!entry) return false
|
||||
return buildPathLinkNode(entry, path, options.workspace)
|
||||
}
|
||||
],
|
||||
{ ignore: ['link', 'linkReference'] }
|
||||
)
|
||||
|
||||
visit(tree, 'inlineCode', (node: InlineCode, index, parent) => {
|
||||
if (!parent || typeof index !== 'number') return
|
||||
const value = node.value.trim()
|
||||
if (!WINDMILL_PATH_EXACT_REGEX.test(value)) return
|
||||
const entry = options.resolve(value)
|
||||
if (!entry) return
|
||||
parent.children[index] = buildPathLinkNode(entry, value, options.workspace) as any
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
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() },
|
||||
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 {
|
||||
extractCandidatePaths,
|
||||
itemHref,
|
||||
remarkWindmillPaths,
|
||||
WINDMILL_PATH_REGEX,
|
||||
workspaceItemAction,
|
||||
type WorkspaceItemEntry
|
||||
} from './workspaceItems.svelte'
|
||||
|
||||
describe('WINDMILL_PATH_REGEX', () => {
|
||||
it('matches simple folder and user paths', () => {
|
||||
expect(extractCandidatePaths('Use f/marketing/send_email today')).toEqual([
|
||||
'f/marketing/send_email'
|
||||
])
|
||||
expect(extractCandidatePaths('Check u/admin/cleanup')).toEqual(['u/admin/cleanup'])
|
||||
})
|
||||
|
||||
it('matches paths with sub-folders and dotted segments', () => {
|
||||
expect(extractCandidatePaths('Pipeline f/etl/jobs/ingest_users.v2 runs nightly')).toEqual([
|
||||
'f/etl/jobs/ingest_users.v2'
|
||||
])
|
||||
})
|
||||
|
||||
it('matches usernames containing dots (e.g. firstname.lastname)', () => {
|
||||
expect(extractCandidatePaths('Owned by u/jane.doe/report')).toEqual(['u/jane.doe/report'])
|
||||
})
|
||||
|
||||
it('strips trailing sentence punctuation', () => {
|
||||
expect(extractCandidatePaths('Look at f/foo/bar.')).toEqual(['f/foo/bar'])
|
||||
expect(extractCandidatePaths('Try f/foo/bar, please')).toEqual(['f/foo/bar'])
|
||||
expect(extractCandidatePaths('Is f/foo/bar?')).toEqual(['f/foo/bar'])
|
||||
})
|
||||
|
||||
it('skips matches inside URLs', () => {
|
||||
expect(extractCandidatePaths('See https://example.com/u/me/script for context')).toEqual([])
|
||||
})
|
||||
|
||||
it('does not match incomplete paths', () => {
|
||||
expect(extractCandidatePaths('I tried f/folder/ but nothing')).toEqual([])
|
||||
expect(extractCandidatePaths('Just u/me')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns multiple unique paths from one string', () => {
|
||||
const paths = extractCandidatePaths('Run f/a/one then f/b/two and again f/a/one')
|
||||
expect(paths.sort()).toEqual(['f/a/one', 'f/b/two'])
|
||||
})
|
||||
|
||||
it('exposes a global regex', () => {
|
||||
expect(WINDMILL_PATH_REGEX.global).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemHref', () => {
|
||||
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'
|
||||
)
|
||||
expect(itemHref({ kind: 'app', path: 'u/me/dash' }, 'ws1')).toBe(
|
||||
'/apps/get/u/me/dash?workspace=ws1'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes variable / resource / schedule to list page with hash fragment', () => {
|
||||
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 fragment', () => {
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceItemAction', () => {
|
||||
it('creates drawer actions for variables and resources', () => {
|
||||
expect(workspaceItemAction('variable', 'u/me/secret')).toMatchObject({
|
||||
type: 'open_created_resource',
|
||||
resource: 'variable',
|
||||
path: 'u/me/secret'
|
||||
})
|
||||
expect(workspaceItemAction('resource', 'u/me/db')).toMatchObject({
|
||||
type: 'open_created_resource',
|
||||
resource: 'resource',
|
||||
path: 'u/me/db'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates drawer actions for schedules and triggers when target kind is known', () => {
|
||||
expect(workspaceItemAction('schedule', 'f/etl/daily', 'flow')).toMatchObject({
|
||||
resource: 'schedule',
|
||||
targetKind: 'flow'
|
||||
})
|
||||
expect(workspaceItemAction('http_trigger', 'f/api/route', 'script')).toMatchObject({
|
||||
resource: 'trigger',
|
||||
triggerKind: 'http',
|
||||
targetKind: 'script'
|
||||
})
|
||||
expect(workspaceItemAction('email_trigger', 'f/mail/inbox', 'script')).toMatchObject({
|
||||
resource: 'trigger',
|
||||
triggerKind: 'email',
|
||||
targetKind: 'script'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips non-drawerable items and trigger items without target kind', () => {
|
||||
expect(workspaceItemAction('script', 'f/a/b')).toBeUndefined()
|
||||
expect(workspaceItemAction('flow', 'f/a/b')).toBeUndefined()
|
||||
expect(workspaceItemAction('app', 'f/a/b')).toBeUndefined()
|
||||
expect(workspaceItemAction('schedule', 'f/a/b')).toBeUndefined()
|
||||
expect(workspaceItemAction('http_trigger', 'f/a/b')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const SAMPLE_ENTRIES: Record<string, WorkspaceItemEntry> = {
|
||||
'f/marketing/send_email': {
|
||||
kind: 'script',
|
||||
path: 'f/marketing/send_email'
|
||||
},
|
||||
'u/admin/cleanup_old_jobs': {
|
||||
kind: 'flow',
|
||||
path: 'u/admin/cleanup_old_jobs'
|
||||
},
|
||||
'f/ops/dashboard': { kind: 'app', path: 'f/ops/dashboard' },
|
||||
'f/etl/daily': {
|
||||
kind: 'schedule',
|
||||
path: 'f/etl/daily',
|
||||
targetKind: 'flow'
|
||||
}
|
||||
}
|
||||
|
||||
function buildProcessor(workspace?: string) {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkWindmillPaths({ resolve: (p) => SAMPLE_ENTRIES[p], workspace }))
|
||||
}
|
||||
|
||||
function findLinks(tree: MdastRoot): Link[] {
|
||||
const out: Link[] = []
|
||||
const walk = (node: any) => {
|
||||
if (!node) return
|
||||
if (node.type === 'link') out.push(node as Link)
|
||||
if (Array.isArray(node.children)) node.children.forEach(walk)
|
||||
}
|
||||
walk(tree)
|
||||
return out
|
||||
}
|
||||
|
||||
function findText(tree: MdastRoot): Text[] {
|
||||
const out: Text[] = []
|
||||
const walk = (node: any) => {
|
||||
if (!node) return
|
||||
if (node.type === 'text') out.push(node as Text)
|
||||
if (Array.isArray(node.children)) node.children.forEach(walk)
|
||||
}
|
||||
walk(tree)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('remarkWindmillPaths (mdast)', () => {
|
||||
it('rewrites known script / flow / app paths to link nodes with hProperties', () => {
|
||||
const processor = buildProcessor('admins')
|
||||
const tree = processor.runSync(
|
||||
processor.parse(
|
||||
'Use f/marketing/send_email and u/admin/cleanup_old_jobs, also try f/ops/dashboard.'
|
||||
)
|
||||
) as MdastRoot
|
||||
|
||||
const links = findLinks(tree)
|
||||
expect(links).toHaveLength(3)
|
||||
|
||||
const byPath = Object.fromEntries(
|
||||
links.map((l) => [(l.children[0] as Text).value, l])
|
||||
) as Record<string, Link>
|
||||
|
||||
expect(byPath['f/marketing/send_email'].url).toBe(
|
||||
'/scripts/get/f/marketing/send_email?workspace=admins'
|
||||
)
|
||||
expect(byPath['u/admin/cleanup_old_jobs'].url).toBe(
|
||||
'/flows/get/u/admin/cleanup_old_jobs?workspace=admins'
|
||||
)
|
||||
expect(byPath['f/ops/dashboard'].url).toBe('/apps/get/f/ops/dashboard?workspace=admins')
|
||||
|
||||
expect(byPath['f/marketing/send_email'].title).toBeNull()
|
||||
|
||||
const props = byPath['f/marketing/send_email'].data?.hProperties as Record<string, string>
|
||||
expect(props['data-wm-kind']).toBe('script')
|
||||
expect(props['data-wm-path']).toBe('f/marketing/send_email')
|
||||
})
|
||||
|
||||
it('adds target kind metadata when a drawer action needs it', () => {
|
||||
const processor = buildProcessor('admins')
|
||||
const tree = processor.runSync(processor.parse('Open f/etl/daily.')) as MdastRoot
|
||||
const links = findLinks(tree)
|
||||
expect(links).toHaveLength(1)
|
||||
const props = links[0].data?.hProperties as Record<string, string>
|
||||
expect(props['data-wm-kind']).toBe('schedule')
|
||||
expect(props['data-wm-path']).toBe('f/etl/daily')
|
||||
expect(props['data-wm-target-kind']).toBe('flow')
|
||||
})
|
||||
|
||||
it('leaves unknown paths as plain text', () => {
|
||||
const processor = buildProcessor()
|
||||
const tree = processor.runSync(
|
||||
processor.parse('Looking for f/nope/missing or u/ghost/script')
|
||||
) as MdastRoot
|
||||
expect(findLinks(tree)).toHaveLength(0)
|
||||
const joined = findText(tree)
|
||||
.map((t) => t.value)
|
||||
.join('')
|
||||
expect(joined).toContain('f/nope/missing')
|
||||
expect(joined).toContain('u/ghost/script')
|
||||
})
|
||||
|
||||
it('rewrites standalone inline-code paths into link pills', () => {
|
||||
const processor = buildProcessor('admins')
|
||||
const tree = processor.runSync(
|
||||
processor.parse('Open `f/marketing/send_email` to see it.')
|
||||
) as MdastRoot
|
||||
const links = findLinks(tree)
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].url).toBe('/scripts/get/f/marketing/send_email?workspace=admins')
|
||||
expect((links[0].data?.hProperties as Record<string, string>)['data-wm-kind']).toBe('script')
|
||||
})
|
||||
|
||||
it('leaves inline code alone when it contains more than just a path', () => {
|
||||
const processor = buildProcessor()
|
||||
const tree = processor.runSync(
|
||||
processor.parse('Like `f/marketing/send_email and friends` should stay code.')
|
||||
) as MdastRoot
|
||||
expect(findLinks(tree)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not rewrite paths inside fenced code blocks', () => {
|
||||
const processor = buildProcessor()
|
||||
const tree = processor.runSync(
|
||||
processor.parse('```\nf/marketing/send_email stays in the block\n```\n')
|
||||
) as MdastRoot
|
||||
expect(findLinks(tree)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('leaves inline code untouched when the wrapped path is unknown', () => {
|
||||
const processor = buildProcessor()
|
||||
const tree = processor.runSync(processor.parse('Try `f/nope/missing` instead.')) as MdastRoot
|
||||
expect(findLinks(tree)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not rewrite paths inside existing links (e.g. autolinked URLs)', () => {
|
||||
const processor = buildProcessor()
|
||||
// Markdown-explicit link with a URL containing what looks like a Windmill path.
|
||||
const tree = processor.runSync(
|
||||
processor.parse('See [docs](https://example.com/f/marketing/send_email).')
|
||||
) as MdastRoot
|
||||
const links = findLinks(tree)
|
||||
expect(links).toHaveLength(1)
|
||||
// Original docs link preserved, no synthetic Windmill link added.
|
||||
expect(links[0].url).toBe('https://example.com/f/marketing/send_email')
|
||||
expect(links[0].data?.hProperties).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles bold / italic wrapped paths', () => {
|
||||
const processor = buildProcessor()
|
||||
const tree = processor.runSync(
|
||||
processor.parse('Run **f/marketing/send_email** today, or _u/admin/cleanup_old_jobs_.')
|
||||
) as MdastRoot
|
||||
const links = findLinks(tree)
|
||||
expect(links).toHaveLength(2)
|
||||
expect(new Set(links.map((l) => (l.children[0] as Text).value))).toEqual(
|
||||
new Set(['f/marketing/send_email', 'u/admin/cleanup_old_jobs'])
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves data attributes through remark-rehype', () => {
|
||||
const processor = buildProcessor('admins').use(remarkRehype, { allowDangerousHtml: true })
|
||||
const hast: any = processor.runSync(processor.parse('Use f/marketing/send_email today.'))
|
||||
// Walk hast tree to find <a> element.
|
||||
const links: any[] = []
|
||||
const walk = (node: any) => {
|
||||
if (!node) return
|
||||
if (node.type === 'element' && node.tagName === 'a') links.push(node)
|
||||
if (Array.isArray(node.children)) node.children.forEach(walk)
|
||||
}
|
||||
walk(hast)
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].properties.href).toBe('/scripts/get/f/marketing/send_email?workspace=admins')
|
||||
// data-* may be normalized by the mdast/hast bridge.
|
||||
expect(links[0].properties['dataWmKind'] ?? links[0].properties['data-wm-kind']).toBe('script')
|
||||
expect(links[0].properties['dataWmPath'] ?? links[0].properties['data-wm-path']).toBe(
|
||||
'f/marketing/send_email'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -77,16 +77,22 @@ export function kindLabel(kind: string): string {
|
||||
|
||||
export function itemHref(kind: string, path: string): string | undefined {
|
||||
switch (kind) {
|
||||
case 'script':
|
||||
case 'scripts':
|
||||
return `/scripts/get/${path}`
|
||||
case 'flow':
|
||||
case 'flows':
|
||||
return `/flows/get/${path}`
|
||||
case 'app':
|
||||
case 'apps':
|
||||
return `/apps/get/${path}`
|
||||
case 'resource':
|
||||
case 'resources':
|
||||
return `/resources#/resource/${path}`
|
||||
case 'variable':
|
||||
case 'variables':
|
||||
return `/variables#${path}`
|
||||
case 'schedule':
|
||||
case 'schedules':
|
||||
return `/schedules#${path}`
|
||||
default: {
|
||||
|
||||
Reference in New Issue
Block a user