mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
feat(dev): url is source of truth for path; add workspace file picker
Drops the server-side --path gate added in 3c2d5155e1. The dev page now
filters by its URL's ?path= and the CLI is a dumb broadcaster, which
lets multiple tabs each watch different paths. When the URL has no
?path=, the page asks the CLI for a list of workspace items (flows,
scripts, raw_apps) via a new {type:'listPaths'} WS message and renders
a picker. Clicking a flow or script soft-updates the URL via
history.pushState and loads it; raw_apps surface a hint to use
`wmill app dev` since they don't render here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7afd8846ca
commit
bd0b46c03d
@@ -105,6 +105,59 @@ function restorePathScripts(flowValue: any) {
|
||||
});
|
||||
}
|
||||
|
||||
type WmPathItem = {
|
||||
path: string;
|
||||
kind: "flow" | "script" | "raw_app";
|
||||
};
|
||||
|
||||
const FLOW_SUFFIXES = [".flow", "__flow"] as const;
|
||||
const APP_SUFFIXES = [".app", "__app", ".raw_app", "__raw_app"] as const;
|
||||
|
||||
function stripFolderSuffix(rel: string, suffixes: readonly string[]): string {
|
||||
for (const s of suffixes) {
|
||||
if (rel.endsWith(s)) return rel.slice(0, -s.length);
|
||||
}
|
||||
return rel;
|
||||
}
|
||||
|
||||
async function listWorkspacePaths(): Promise<WmPathItem[]> {
|
||||
const items: WmPathItem[] = [];
|
||||
async function walk(dir: string, rel: string) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (FLOW_SUFFIXES.some((s) => entry.name.endsWith(s))) {
|
||||
items.push({ path: stripFolderSuffix(childRel, FLOW_SUFFIXES), kind: "flow" });
|
||||
continue;
|
||||
}
|
||||
if (APP_SUFFIXES.some((s) => entry.name.endsWith(s))) {
|
||||
items.push({ path: stripFolderSuffix(childRel, APP_SUFFIXES), kind: "raw_app" });
|
||||
continue;
|
||||
}
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
} else if (entry.isFile()) {
|
||||
const matchedExt = exts.find((ext) => entry.name.endsWith(ext));
|
||||
if (matchedExt) {
|
||||
items.push({
|
||||
path: childRel.slice(0, -matchedExt.length),
|
||||
kind: "script",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(process.cwd(), "");
|
||||
items.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return items;
|
||||
}
|
||||
|
||||
export interface DevOpts {
|
||||
proxyPort?: number;
|
||||
path?: string;
|
||||
@@ -244,8 +297,6 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
|
||||
} else if (wmFlowPath.endsWith("__flow")) {
|
||||
wmFlowPath = wmFlowPath.slice(0, -"__flow".length);
|
||||
}
|
||||
// Skip work entirely when --path is set and this change is for a different path
|
||||
if (opts.path && wmFlowPath !== opts.path) return;
|
||||
const localFlow = (await yamlParseFile(
|
||||
localPath + "flow.yaml"
|
||||
)) as FlowFile;
|
||||
@@ -277,8 +328,6 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
|
||||
} else if (typ == "script") {
|
||||
const splitted = cpath.split(".");
|
||||
const wmPath = splitted[0];
|
||||
// Skip work entirely when --path is set and this change is for a different path
|
||||
if (opts.path && wmPath !== opts.path) return;
|
||||
const content = await readFile(cpath, "utf-8");
|
||||
const lang = inferContentTypeFromFilePath(cpath, opts.defaultTs);
|
||||
const typed =
|
||||
@@ -475,12 +524,9 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
|
||||
const connectedClients: Set<WebSocket> = new Set();
|
||||
|
||||
// Function to send a message to all connected clients.
|
||||
// When --path (or auto-detected flow path) is set, drop edits for any other
|
||||
// path so the dev page stays locked to the requested resource.
|
||||
// The dev page filters by URL path on its end, so the server stays a dumb broadcaster
|
||||
// and multiple tabs can each watch their own path.
|
||||
function broadcastChanges(lastEdit: LastEditScript | LastEditFlow) {
|
||||
if (opts.path && lastEdit.path !== opts.path) {
|
||||
return;
|
||||
}
|
||||
for (const client of connectedClients.values()) {
|
||||
client.send(JSON.stringify(lastEdit));
|
||||
}
|
||||
@@ -529,6 +575,14 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
|
||||
}).catch((err) => {
|
||||
log.error(`Failed to load path ${data.path}: ${err}`);
|
||||
});
|
||||
} else if (data.type === "listPaths") {
|
||||
listWorkspacePaths().then((items) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "paths", items }));
|
||||
}
|
||||
}).catch((err) => {
|
||||
log.error(`Failed to list paths: ${err}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
|
||||
import { dfs } from './flows/dfs'
|
||||
import { loadSchemaFromModule } from './flows/flowInfers'
|
||||
import { CornerDownLeft, Play } from 'lucide-svelte'
|
||||
import { CornerDownLeft, Play, Workflow, Code, Layout } from 'lucide-svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
import type { FlowCopilotContext } from './copilot/flow'
|
||||
@@ -165,6 +165,17 @@
|
||||
const searchParams = indexQ > -1 ? new URLSearchParams(href.substring(indexQ)) : undefined
|
||||
let relativePaths: any[] = $state([])
|
||||
|
||||
type WmPathItem = { path: string; kind: 'flow' | 'script' | 'raw_app' }
|
||||
function parseWatchPath(): string | undefined {
|
||||
const i = window.location.href.indexOf('?')
|
||||
if (i < 0) return undefined
|
||||
return new URLSearchParams(window.location.href.substring(i)).get('path') ?? undefined
|
||||
}
|
||||
const PATH_SUFFIX_RE = /(\.(flow|app|raw_app)|__(flow|app|raw_app))\/?$/
|
||||
let watchPath = $state(parseWatchPath()?.replace(PATH_SUFFIX_RE, ''))
|
||||
let pickerItems: WmPathItem[] = $state([])
|
||||
const pickerMode = $derived(!watchPath)
|
||||
|
||||
if (searchParams?.has('local')) {
|
||||
connectWs()
|
||||
}
|
||||
@@ -331,13 +342,40 @@
|
||||
)
|
||||
loadingCodebaseButton = false
|
||||
}
|
||||
const onPopState = () => {
|
||||
watchPath = parseWatchPath()?.replace(PATH_SUFFIX_RE, '')
|
||||
if (watchPath && socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'loadWmPath', path: watchPath }))
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('popstate', onPopState)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('message', el)
|
||||
window.removeEventListener('popstate', onPopState)
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket?.close()
|
||||
}
|
||||
})
|
||||
|
||||
function pickPath(item: WmPathItem) {
|
||||
if (item.kind === 'raw_app') {
|
||||
sendUserToast(
|
||||
`raw_apps aren't previewable here. Run \`wmill app dev\` from inside the app folder.`,
|
||||
false
|
||||
)
|
||||
return
|
||||
}
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('path', item.path)
|
||||
window.history.pushState({}, '', url.toString())
|
||||
watchPath = item.path
|
||||
socket?.send(JSON.stringify({ type: 'loadWmPath', path: item.path }))
|
||||
}
|
||||
|
||||
function connectWs() {
|
||||
try {
|
||||
if (socket) {
|
||||
@@ -350,11 +388,13 @@
|
||||
try {
|
||||
socket = new WebSocket(`ws://localhost:${port}/ws`)
|
||||
|
||||
// On connect, request a specific path if one is specified
|
||||
// On connect, request the watched path if any, otherwise ask for a list to render the picker
|
||||
socket.addEventListener('open', () => {
|
||||
const watchPath = searchParams?.get('path')
|
||||
if (watchPath && socket) {
|
||||
if (!socket) return
|
||||
if (watchPath) {
|
||||
socket.send(JSON.stringify({ type: 'loadWmPath', path: watchPath }))
|
||||
} else {
|
||||
socket.send(JSON.stringify({ type: 'listPaths' }))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -371,13 +411,16 @@
|
||||
console.log('Received invalid JSON: ' + msg)
|
||||
return
|
||||
}
|
||||
// Client-side filtering: only accept messages matching the watched path
|
||||
if (data.type === 'paths') {
|
||||
pickerItems = data.items ?? []
|
||||
return
|
||||
}
|
||||
// Picker mode (URL has no path) — ignore live broadcasts so a random
|
||||
// file change doesn't yank the page out of the picker.
|
||||
if (!watchPath) return
|
||||
// Normalize by stripping common folder suffixes so "f/foo__flow" matches "f/foo"
|
||||
const watchPath = searchParams
|
||||
?.get('path')
|
||||
?.replace(/(\.(flow|app|raw_app)|__(flow|app|raw_app))\/?$/, '')
|
||||
const dataPath = data.path?.replace(/(\.(flow|app|raw_app)|__(flow|app|raw_app))\/?$/, '')
|
||||
if (watchPath && dataPath && dataPath !== watchPath) {
|
||||
const dataPath = data.path?.replace(PATH_SUFFIX_RE, '')
|
||||
if (dataPath && dataPath !== watchPath) {
|
||||
return
|
||||
}
|
||||
if (data.type == 'script') {
|
||||
@@ -746,7 +789,90 @@
|
||||
<JobLoader noCode={true} bind:this={jobLoader} bind:isLoading={testIsLoading} bind:job={testJob} />
|
||||
|
||||
<main class="h-screen w-full">
|
||||
{#if mode == 'script'}
|
||||
{#if pickerMode}
|
||||
<div class="h-full w-full overflow-auto p-6">
|
||||
<div class="absolute top-2 left-2">
|
||||
<DarkModeToggle bind:darkMode bind:this={darkModeToggle} forcedDarkMode={false} />
|
||||
</div>
|
||||
<div class="absolute top-2 right-2 text-xs text-secondary">
|
||||
{#if $userStore}
|
||||
{$userStore?.username} on {$workspaceStore}
|
||||
{:else}
|
||||
<span class="text-red-600">Unable to login on {$workspaceStore}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="max-w-3xl mx-auto pt-8">
|
||||
<h1 class="text-2xl font-semibold text-primary mb-1">Pick a file to preview</h1>
|
||||
<p class="text-sm text-secondary mb-6">
|
||||
Click a flow or script to load it in the dev editor. The URL will update so you can
|
||||
bookmark or share it.
|
||||
</p>
|
||||
{#if pickerItems.length === 0}
|
||||
<div class="text-sm text-secondary"
|
||||
>No flows, scripts, or apps detected in this workspace.</div
|
||||
>
|
||||
{:else}
|
||||
{@const flows = pickerItems.filter((i) => i.kind === 'flow')}
|
||||
{@const scripts = pickerItems.filter((i) => i.kind === 'script')}
|
||||
{@const apps = pickerItems.filter((i) => i.kind === 'raw_app')}
|
||||
{#if flows.length > 0}
|
||||
<h2 class="text-sm font-semibold text-primary mt-4 mb-2 flex items-center gap-2">
|
||||
<Workflow size={14} /> Flows
|
||||
</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each flows as item (item.path)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
btnClasses="!justify-start"
|
||||
on:click={() => pickPath(item)}
|
||||
>
|
||||
{item.path}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if scripts.length > 0}
|
||||
<h2 class="text-sm font-semibold text-primary mt-6 mb-2 flex items-center gap-2">
|
||||
<Code size={14} /> Scripts
|
||||
</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each scripts as item (item.path)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
btnClasses="!justify-start"
|
||||
on:click={() => pickPath(item)}
|
||||
>
|
||||
{item.path}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if apps.length > 0}
|
||||
<h2 class="text-sm font-semibold text-primary mt-6 mb-2 flex items-center gap-2">
|
||||
<Layout size={14} /> Apps
|
||||
<span class="text-xs font-normal text-secondary"
|
||||
>(use <code>wmill app dev</code>)</span
|
||||
>
|
||||
</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each apps as item (item.path)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
btnClasses="!justify-start"
|
||||
on:click={() => pickPath(item)}
|
||||
>
|
||||
{item.path}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode == 'script'}
|
||||
<div class="flex flex-col min-h-full min-h-screen overflow-auto">
|
||||
<div class="absolute top-0 left-2">
|
||||
<DarkModeToggle bind:darkMode bind:this={darkModeToggle} forcedDarkMode={false} />
|
||||
|
||||
Reference in New Issue
Block a user