From bd0b46c03d9d70a8c03d74fa30029993ae89bf20 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 23 Apr 2026 13:39:11 +0200 Subject: [PATCH] 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) --- cli/src/commands/dev/dev.ts | 72 ++++++++++-- frontend/src/lib/components/Dev.svelte | 148 +++++++++++++++++++++++-- 2 files changed, 200 insertions(+), 20 deletions(-) diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index 36322809e9..dc3b8dff48 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -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 { + 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 = 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}`); + }); } }); }); diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 7f18984fee..59882fb43c 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -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 @@
- {#if mode == 'script'} + {#if pickerMode} +
+
+ +
+
+ {#if $userStore} + {$userStore?.username} on {$workspaceStore} + {:else} + Unable to login on {$workspaceStore} + {/if} +
+
+

Pick a file to preview

+

+ Click a flow or script to load it in the dev editor. The URL will update so you can + bookmark or share it. +

+ {#if pickerItems.length === 0} +
No flows, scripts, or apps detected in this workspace.
+ {: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} +

+ Flows +

+
+ {#each flows as item (item.path)} + + {/each} +
+ {/if} + {#if scripts.length > 0} +

+ Scripts +

+
+ {#each scripts as item (item.path)} + + {/each} +
+ {/if} + {#if apps.length > 0} +

+ Apps + (use wmill app dev) +

+
+ {#each apps as item (item.path)} + + {/each} +
+ {/if} + {/if} +
+
+ {:else if mode == 'script'}