Compare commits
11 Commits
worker-bat
...
glm/add-lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ac96b96e6 | ||
|
|
0c1d71070a | ||
|
|
371eb430ac | ||
|
|
8009960ab1 | ||
|
|
3272c29c2e | ||
|
|
58fbfc181c | ||
|
|
a4ef96f056 | ||
|
|
d1ee68c376 | ||
|
|
154ffe59bb | ||
|
|
62b57ae4a2 | ||
|
|
f91f56317b |
@@ -12,6 +12,7 @@ import { resolveWorkspace } from "../../core/context.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
import path from "node:path";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import {
|
||||
buildFolderPath,
|
||||
loadNonDottedPathsSetting,
|
||||
@@ -99,7 +100,18 @@ import "./index.css";
|
||||
|
||||
createApp(App).mount('#root')`;
|
||||
|
||||
const indexCss = `.myclass {
|
||||
const indexCss = `body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
#root {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.myclass {
|
||||
border: 1px solid gray;
|
||||
padding: 2px;
|
||||
}`;
|
||||
@@ -498,6 +510,26 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName};
|
||||
await mkdir(appDir, { recursive: true });
|
||||
await mkdir(path.join(appDir, "backend"), { recursive: true });
|
||||
await mkdir(path.join(appDir, "sql_to_apply"), { recursive: true });
|
||||
await mkdir(path.join(appDir, ".claude"), { recursive: true });
|
||||
|
||||
// Create .claude/launch.json for Claude Code preview
|
||||
const launchJson = {
|
||||
version: "0.0.1",
|
||||
configurations: [
|
||||
{
|
||||
name: "windmill",
|
||||
runtimeExecutable: "bash",
|
||||
runtimeArgs: ["-c", "wmill app dev --no-open --port ${PORT:-4000}"],
|
||||
port: 4000,
|
||||
autoPort: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
await writeFile(
|
||||
path.join(appDir, ".claude", "launch.json"),
|
||||
JSON.stringify(launchJson, null, 2) + "\n",
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
// Create raw_app.yaml with data configuration
|
||||
const rawAppConfig: Record<string, unknown> = {
|
||||
@@ -610,6 +642,8 @@ This folder is for SQL migration files that will be applied to datatables during
|
||||
log.info("");
|
||||
log.info(colors.gray("Directory structure:"));
|
||||
log.info(colors.gray(` ${folderName}/`));
|
||||
log.info(colors.gray(" ├── .claude/"));
|
||||
log.info(colors.gray(" │ └── launch.json"));
|
||||
log.info(colors.gray(" ├── AGENTS.md ← Read this first!"));
|
||||
log.info(colors.gray(" ├── raw_app.yaml"));
|
||||
log.info(colors.gray(" ├── DATATABLES.md"));
|
||||
@@ -662,6 +696,67 @@ This folder is for SQL migration files that will be applied to datatables during
|
||||
}
|
||||
log.info("");
|
||||
log.info(colors.gray(" 4. wmill sync push (to deploy when ready)"));
|
||||
|
||||
// Offer to open in Claude Desktop
|
||||
let hasClaudeDesktop = false;
|
||||
try {
|
||||
execSync("ls /Applications/Claude.app", { stdio: "ignore" });
|
||||
hasClaudeDesktop = true;
|
||||
} catch {
|
||||
// Claude Desktop not installed
|
||||
}
|
||||
|
||||
if (hasClaudeDesktop) {
|
||||
log.info("");
|
||||
const openInDesktop = await Confirm.prompt({
|
||||
message: "Open in Claude Desktop?",
|
||||
default: true,
|
||||
});
|
||||
|
||||
if (openInDesktop) {
|
||||
try {
|
||||
const absAppDir = path.resolve(appDir);
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
// Create a persisted CLI session with welcome message (async to allow spinner)
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let i = 0;
|
||||
const spinner = setInterval(() => {
|
||||
process.stdout.write(`\r${colors.gray(`${frames[i++ % frames.length]} Creating Claude session...`)}`);
|
||||
}, 80);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
exec(
|
||||
`claude --session-id "${sessionId}" -p "Say: Your app is ready, click on preview to test it!"`,
|
||||
{ cwd: absAppDir },
|
||||
(error) => (error ? reject(error) : resolve())
|
||||
);
|
||||
});
|
||||
|
||||
clearInterval(spinner);
|
||||
process.stdout.write("\r" + " ".repeat(40) + "\r");
|
||||
|
||||
// Import the session into Claude Desktop Code mode
|
||||
const deepLink = `claude://resume?session=${sessionId}&cwd=${encodeURIComponent(absAppDir)}`;
|
||||
exec(`open ${JSON.stringify(deepLink)}`);
|
||||
|
||||
log.info(colors.bold.green("Opened in Claude Desktop!"));
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
log.warn(
|
||||
colors.yellow(
|
||||
`Could not open in Claude Desktop: ${errorMessage}`
|
||||
)
|
||||
);
|
||||
log.info(
|
||||
colors.gray(
|
||||
"You can manually run: cd " + folderName + " && claude"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
|
||||
@@ -2,18 +2,21 @@ import { Command } from "@cliffy/command";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { sep as SEP } from "node:path";
|
||||
import { yamlParseFile } from "../../utils/yaml.ts";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
|
||||
import * as getPort from "get-port";
|
||||
import * as http from "node:http";
|
||||
import * as https from "node:https";
|
||||
import * as open from "open";
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { access, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
|
||||
import { watch } from "node:fs";
|
||||
import { getTypeStrFromPath, GlobalOptions } from "../../types.ts";
|
||||
import { ignoreF } from "../sync/sync.ts";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { resolveWorkspace } from "../../core/context.ts";
|
||||
import {
|
||||
GLOBAL_CONFIG_OPT,
|
||||
SyncOptions,
|
||||
mergeConfigWithConfigFile,
|
||||
} from "../../core/conf.ts";
|
||||
@@ -22,17 +25,134 @@ import { inferContentTypeFromFilePath } from "../../utils/script_common.ts";
|
||||
import { OpenFlow } from "../../../gen/types.gen.ts";
|
||||
import { FlowFile } from "../flow/flow.ts";
|
||||
import { replaceInlineScripts, replaceAllPathScriptsWithLocal } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
|
||||
import { extractInlineScripts, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
|
||||
import { parseMetadataFile } from "../../utils/metadata.ts";
|
||||
import {
|
||||
getFolderSuffixWithSep,
|
||||
getMetadataFileName,
|
||||
extractFolderPath,
|
||||
getNonDottedPaths,
|
||||
loadNonDottedPathsSetting,
|
||||
} from "../../utils/resource_folders.ts";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import { listSyncCodebases } from "../../utils/codebase.ts";
|
||||
import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts";
|
||||
|
||||
const PORT = 3001;
|
||||
async function dev(opts: GlobalOptions & SyncOptions) {
|
||||
|
||||
// PathScript snapshot/restore for flow round-trip
|
||||
const TAG_KEY = "_originalPathScript" as const;
|
||||
|
||||
function walkFlowModules(modules: any[], visitor: { onModule: (m: any) => void }) {
|
||||
for (const module of modules) {
|
||||
if (!module.value) continue;
|
||||
const val = module.value;
|
||||
if (val.type === "forloopflow" || val.type === "whileloopflow") {
|
||||
walkFlowModules(val.modules, visitor);
|
||||
} else if (val.type === "branchall") {
|
||||
for (const branch of val.branches ?? []) {
|
||||
walkFlowModules(branch.modules, visitor);
|
||||
}
|
||||
} else if (val.type === "branchone") {
|
||||
for (const branch of val.branches ?? []) {
|
||||
walkFlowModules(branch.modules, visitor);
|
||||
}
|
||||
if (val.default) {
|
||||
walkFlowModules(val.default, visitor);
|
||||
}
|
||||
} else {
|
||||
visitor.onModule(module);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walkFlow(flowValue: any, visitor: { onModule: (m: any) => void }) {
|
||||
if (flowValue?.modules) walkFlowModules(flowValue.modules, visitor);
|
||||
if (flowValue?.failure_module) walkFlowModules([flowValue.failure_module], visitor);
|
||||
if (flowValue?.preprocessor_module) walkFlowModules([flowValue.preprocessor_module], visitor);
|
||||
}
|
||||
|
||||
function snapshotPathScripts(flowValue: any) {
|
||||
walkFlow(flowValue, {
|
||||
onModule(module) {
|
||||
if (module.value.type === "script") {
|
||||
module[TAG_KEY] = JSON.parse(JSON.stringify(module.value));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function tagReplacedPathScripts(flowValue: any) {
|
||||
walkFlow(flowValue, {
|
||||
onModule(module) {
|
||||
if (module[TAG_KEY] && module.value.type === "rawscript") {
|
||||
module.value[TAG_KEY] = module[TAG_KEY];
|
||||
delete module[TAG_KEY];
|
||||
} else if (module[TAG_KEY]) {
|
||||
delete module[TAG_KEY];
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function restorePathScripts(flowValue: any) {
|
||||
walkFlow(flowValue, {
|
||||
onModule(module) {
|
||||
if (module.value[TAG_KEY]) {
|
||||
module.value = module.value[TAG_KEY];
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface DevOpts {
|
||||
proxyPort?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
|
||||
// Auto-detect flow folder: if no --path and cwd is a flow folder, resolve path and chdir to workspace root
|
||||
if (!opts.path) {
|
||||
const cwd = process.cwd();
|
||||
const cwdBasename = path.basename(cwd);
|
||||
|
||||
// Need to init nonDottedPaths before checking suffix
|
||||
await loadNonDottedPathsSetting();
|
||||
|
||||
if (cwdBasename.endsWith(".flow") || cwdBasename.endsWith("__flow")) {
|
||||
GLOBAL_CONFIG_OPT.noCdToRoot = true;
|
||||
|
||||
// Find workspace root
|
||||
let searchDir = cwd;
|
||||
let workspaceRoot: string | undefined;
|
||||
while (true) {
|
||||
const wmillYaml = path.join(searchDir, "wmill.yaml");
|
||||
if (fs.existsSync(wmillYaml)) {
|
||||
workspaceRoot = searchDir;
|
||||
break;
|
||||
}
|
||||
const parentDir = path.dirname(searchDir);
|
||||
if (parentDir === searchDir) break;
|
||||
searchDir = parentDir;
|
||||
}
|
||||
|
||||
if (workspaceRoot) {
|
||||
const relPath = path.relative(workspaceRoot, cwd).replaceAll("\\", "/");
|
||||
// Strip whichever flow suffix is actually present (dotted or non-dotted)
|
||||
if (relPath.endsWith(".flow")) {
|
||||
opts.path = relPath.slice(0, -".flow".length);
|
||||
} else if (relPath.endsWith("__flow")) {
|
||||
opts.path = relPath.slice(0, -"__flow".length);
|
||||
} else {
|
||||
opts.path = relPath;
|
||||
}
|
||||
opts.proxyPort = opts.proxyPort ?? 3100;
|
||||
log.info(`Detected flow folder, path: ${opts.path}`);
|
||||
process.chdir(workspaceRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
@@ -70,12 +190,13 @@ async function dev(opts: GlobalOptions & SyncOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
const flowFolderSuffix = getFolderSuffixWithSep("flow");
|
||||
const flowMetadataFile = getMetadataFileName("flow", "yaml");
|
||||
async function loadPaths(pathsToLoad: string[]) {
|
||||
const paths = pathsToLoad.filter((path) =>
|
||||
const paths = pathsToLoad.filter((p) =>
|
||||
exts.some(
|
||||
(ext) => path.endsWith(ext) || path.endsWith(flowFolderSuffix + flowMetadataFile)
|
||||
(ext) => p.endsWith(ext)
|
||||
|| p.endsWith(".flow/" + flowMetadataFile)
|
||||
|| p.endsWith("__flow/" + flowMetadataFile)
|
||||
)
|
||||
);
|
||||
if (paths.length == 0) {
|
||||
@@ -83,11 +204,39 @@ async function dev(opts: GlobalOptions & SyncOptions) {
|
||||
}
|
||||
const nativePath = (await realpath(paths[0])).replace(base + SEP, "");
|
||||
const cpath = nativePath.replaceAll("\\", "/");
|
||||
if (!ignore(nativePath, false)) {
|
||||
const typ = getTypeStrFromPath(cpath);
|
||||
// Bypass ignore for paths inside flow folders — ignore() only checks the configured
|
||||
// suffix (dotted or non-dotted), but the workspace may contain both kinds
|
||||
const isInFlowFolder = cpath.includes(".flow/") || cpath.includes("__flow/");
|
||||
if (isInFlowFolder || !ignore(nativePath, false)) {
|
||||
let typ: string;
|
||||
if (isInFlowFolder) {
|
||||
// Force flow type for any file inside a flow folder — getTypeStrFromPath
|
||||
// only recognises the configured suffix (dotted or non-dotted) and would
|
||||
// mis-classify or throw for the other variant
|
||||
typ = "flow";
|
||||
} else {
|
||||
typ = getTypeStrFromPath(cpath);
|
||||
// If a script file is inside a flow folder, treat it as a flow change
|
||||
// (handles both .flow/ and __flow/ regardless of nonDottedPaths setting)
|
||||
if (typ === "script" && (cpath.includes(".flow/") || cpath.includes("__flow/"))) {
|
||||
typ = "flow";
|
||||
}
|
||||
}
|
||||
log.info("Detected change in " + cpath + " (" + typ + ")");
|
||||
if (typ == "flow") {
|
||||
const localPath = extractFolderPath(cpath, "flow")!;
|
||||
// Try extractFolderPath, fallback to manual extraction for mixed suffix cases
|
||||
let localPath = extractFolderPath(cpath, "flow");
|
||||
if (!localPath) {
|
||||
// extractFolderPath only checks the configured suffix; try both manually
|
||||
for (const suffix of [".flow/", "__flow/"]) {
|
||||
const idx = cpath.indexOf(suffix);
|
||||
if (idx !== -1) {
|
||||
localPath = cpath.substring(0, idx) + suffix;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!localPath) return;
|
||||
const localFlow = (await yamlParseFile(
|
||||
localPath + "flow.yaml"
|
||||
)) as FlowFile;
|
||||
@@ -99,19 +248,29 @@ async function dev(opts: GlobalOptions & SyncOptions) {
|
||||
SEP,
|
||||
undefined,
|
||||
);
|
||||
// Replace PathScript modules with local file content so dev mode uses local versions
|
||||
// Snapshot PathScript modules before replacement, then tag after
|
||||
snapshotPathScripts(localFlow);
|
||||
const localScriptReader = createPreviewLocalScriptReader({
|
||||
exts,
|
||||
defaultTs: opts.defaultTs,
|
||||
codebases,
|
||||
});
|
||||
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
|
||||
tagReplacedPathScripts(localFlow);
|
||||
// Strip whichever flow suffix is present (dotted or non-dotted)
|
||||
let wmFlowPath = localPath.replace(/\/$/, "");
|
||||
if (wmFlowPath.endsWith(".flow")) {
|
||||
wmFlowPath = wmFlowPath.slice(0, -".flow".length);
|
||||
} else if (wmFlowPath.endsWith("__flow")) {
|
||||
wmFlowPath = wmFlowPath.slice(0, -"__flow".length);
|
||||
}
|
||||
currentLastEdit = {
|
||||
type: "flow",
|
||||
flow: localFlow,
|
||||
uriPath: localPath,
|
||||
path: wmFlowPath,
|
||||
};
|
||||
log.info("Updated " + localPath);
|
||||
log.info("Updated " + wmFlowPath);
|
||||
broadcastChanges(currentLastEdit);
|
||||
} else if (typ == "script") {
|
||||
const content = await readFile(cpath, "utf-8");
|
||||
@@ -153,8 +312,162 @@ async function dev(opts: GlobalOptions & SyncOptions) {
|
||||
type: "flow";
|
||||
flow: OpenFlow;
|
||||
uriPath: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
// Normalize a windmill path by stripping any trailing flow/app suffix
|
||||
function normalizeWmPath(p: string): string {
|
||||
let result = p.replace(/\/$/, "");
|
||||
// Strip whichever flow suffix is present (dotted or non-dotted)
|
||||
if (result.endsWith(".flow")) {
|
||||
result = result.slice(0, -".flow".length);
|
||||
} else if (result.endsWith("__flow")) {
|
||||
result = result.slice(0, -"__flow".length);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Load a resource by its windmill path (e.g., "u/admin/my_script" or "f/my_flow")
|
||||
async function loadWmPath(wmPath: string): Promise<LastEditScript | LastEditFlow | undefined> {
|
||||
wmPath = normalizeWmPath(wmPath);
|
||||
// Try as flow — check both dotted and non-dotted suffixes
|
||||
let flowDir: string | undefined;
|
||||
let flowYaml: string | undefined;
|
||||
for (const suffix of [".flow", "__flow"]) {
|
||||
const candidate = wmPath + suffix + "/";
|
||||
try {
|
||||
await access(candidate + "flow.yaml");
|
||||
flowDir = candidate;
|
||||
flowYaml = candidate + "flow.yaml";
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
if (!flowDir || !flowYaml) throw new Error("not a flow");
|
||||
const localFlow = (await yamlParseFile(flowYaml)) as FlowFile;
|
||||
await replaceInlineScripts(
|
||||
localFlow.value.modules,
|
||||
async (p: string) => await readFile(flowDir + p, "utf-8"),
|
||||
log,
|
||||
flowDir,
|
||||
SEP,
|
||||
undefined,
|
||||
);
|
||||
snapshotPathScripts(localFlow);
|
||||
const localScriptReader = createPreviewLocalScriptReader({
|
||||
exts,
|
||||
defaultTs: opts.defaultTs,
|
||||
codebases,
|
||||
});
|
||||
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
|
||||
tagReplacedPathScripts(localFlow);
|
||||
const edit: LastEditFlow = {
|
||||
type: "flow",
|
||||
flow: localFlow,
|
||||
uriPath: flowDir,
|
||||
path: wmPath,
|
||||
};
|
||||
currentLastEdit = edit;
|
||||
return edit;
|
||||
} catch {
|
||||
// Not a flow, try as script
|
||||
}
|
||||
|
||||
// Try as script
|
||||
for (const ext of exts) {
|
||||
const filePath = wmPath + ext;
|
||||
try {
|
||||
await access(filePath);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const lang = inferContentTypeFromFilePath(filePath, opts.defaultTs);
|
||||
const typed = (await parseMetadataFile(removeExtensionToPath(filePath), undefined))?.payload;
|
||||
const edit: LastEditScript = {
|
||||
type: "script",
|
||||
content,
|
||||
path: wmPath,
|
||||
language: lang,
|
||||
tag: typed?.tag,
|
||||
lock: typed?.lock,
|
||||
};
|
||||
currentLastEdit = edit;
|
||||
return edit;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
log.error(`Could not find file for path: ${wmPath}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle flow edits from the dev UI — write changes back to disk
|
||||
async function handleFlowRoundTrip(data: { flow: any; uriPath: string }) {
|
||||
if (!data.uriPath || !data.flow?.value) return;
|
||||
|
||||
let flowDir = data.uriPath;
|
||||
if (!flowDir.endsWith("/")) flowDir += "/";
|
||||
if (flowDir.includes("://")) {
|
||||
flowDir = new URL(flowDir).pathname;
|
||||
}
|
||||
|
||||
restorePathScripts(data.flow.value);
|
||||
|
||||
const flowYamlPath = flowDir + "flow.yaml";
|
||||
let currentModules: any[] | undefined;
|
||||
try {
|
||||
const currentFlow = (await yamlParseFile(flowYamlPath)) as FlowFile;
|
||||
currentModules = currentFlow.value?.modules;
|
||||
} catch {
|
||||
// flow.yaml doesn't exist yet or is invalid
|
||||
}
|
||||
|
||||
const inlineScriptMapping: Record<string, string> = {};
|
||||
extractCurrentMapping(currentModules, inlineScriptMapping);
|
||||
|
||||
const allExtracted = extractInlineScripts(
|
||||
data.flow.value.modules ?? [],
|
||||
inlineScriptMapping,
|
||||
"/",
|
||||
opts.defaultTs ?? "bun",
|
||||
undefined,
|
||||
{ skipInlineScriptSuffix: getNonDottedPaths() },
|
||||
);
|
||||
|
||||
for (const s of allExtracted) {
|
||||
const filePath = flowDir + s.path;
|
||||
let needsWrite = true;
|
||||
try {
|
||||
const existing = await readFile(filePath, "utf-8");
|
||||
if (existing === s.content) needsWrite = false;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
if (needsWrite) {
|
||||
await writeFile(filePath, s.content, "utf-8");
|
||||
log.info(`Wrote inline script: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const flowYaml = yamlStringify(data.flow);
|
||||
await writeFile(flowYamlPath, flowYaml, "utf-8");
|
||||
log.info(`Wrote flow: ${flowYamlPath}`);
|
||||
|
||||
// Clean up orphaned inline script files
|
||||
const extractedPaths = new Set(allExtracted.map((s) => s.path));
|
||||
try {
|
||||
const dirFiles = await readdir(flowDir);
|
||||
for (const file of dirFiles) {
|
||||
if (file === "flow.yaml" || file === "flow.json" || file.startsWith(".")) continue;
|
||||
if (!extractedPaths.has(file)) {
|
||||
await unlink(flowDir + file);
|
||||
log.info(`Removed orphaned file: ${flowDir + file}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory read failed
|
||||
}
|
||||
}
|
||||
|
||||
const connectedClients: Set<WebSocket> = new Set();
|
||||
|
||||
// Function to send a message to all connected clients
|
||||
@@ -164,27 +477,14 @@ async function dev(opts: GlobalOptions & SyncOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function startApp() {
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
});
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// WebSocket server event listeners
|
||||
function setupDevWs(wss: WebSocketServer) {
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
connectedClients.add(ws);
|
||||
console.log("New client connected");
|
||||
|
||||
ws.on("open", () => {
|
||||
if (currentLastEdit) {
|
||||
broadcastChanges(currentLastEdit);
|
||||
}
|
||||
});
|
||||
console.log("New dev client connected");
|
||||
|
||||
ws.on("close", () => {
|
||||
connectedClients.delete(ws);
|
||||
console.log("Client disconnected");
|
||||
console.log("Dev client disconnected");
|
||||
});
|
||||
|
||||
ws.on("message", (message: WebSocket.RawData) => {
|
||||
@@ -198,19 +498,161 @@ async function dev(opts: GlobalOptions & SyncOptions) {
|
||||
|
||||
if (data.type === "load") {
|
||||
loadPaths([data.path]);
|
||||
} else if (data.type === "flow") {
|
||||
handleFlowRoundTrip(data).catch((err) => {
|
||||
log.error(`Failed to write flow changes: ${err}`);
|
||||
});
|
||||
} else if (data.type === "loadWmPath") {
|
||||
loadWmPath(data.path).then((edit) => {
|
||||
if (edit && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(edit));
|
||||
}
|
||||
}).catch((err) => {
|
||||
log.error(`Failed to load path ${data.path}: ${err}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Start the server
|
||||
const port = await getPort.default({ port: 3001 });
|
||||
// --- Reverse proxy (when --proxy-port is set) ---
|
||||
|
||||
async function startProxyServer(proxyPort: number) {
|
||||
const remote = new URL(workspace.remote);
|
||||
const isHttps = remote.protocol === "https:";
|
||||
const remoteHost = remote.hostname;
|
||||
const remotePort = remote.port ? parseInt(remote.port) : (isHttps ? 443 : 80);
|
||||
const httpModule = isHttps ? https : http;
|
||||
|
||||
const devWss = new WebSocketServer({ noServer: true });
|
||||
setupDevWs(devWss);
|
||||
|
||||
const proxyWss = new WebSocketServer({ noServer: true });
|
||||
|
||||
const proxyServer = http.createServer((clientReq, clientRes) => {
|
||||
const parsedUrl = new URL(clientReq.url ?? "/", `http://localhost`);
|
||||
if (parsedUrl.pathname === "/" || parsedUrl.pathname === "") {
|
||||
let devUrl = `/dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}&port=${proxyPort}`;
|
||||
if (opts.path) {
|
||||
devUrl += `&path=${opts.path}`;
|
||||
}
|
||||
clientRes.writeHead(302, { Location: devUrl });
|
||||
clientRes.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const fwdHeaders: Record<string, string | string[] | undefined> = {
|
||||
...clientReq.headers,
|
||||
host: remote.host,
|
||||
};
|
||||
delete fwdHeaders["connection"];
|
||||
delete fwdHeaders["keep-alive"];
|
||||
delete fwdHeaders["transfer-encoding"];
|
||||
delete fwdHeaders["accept-encoding"];
|
||||
|
||||
const proxyOpts: http.RequestOptions = {
|
||||
hostname: remoteHost,
|
||||
port: remotePort,
|
||||
path: clientReq.url,
|
||||
method: clientReq.method,
|
||||
headers: fwdHeaders,
|
||||
};
|
||||
|
||||
const proxyReq = httpModule.request(proxyOpts, (proxyRes) => {
|
||||
const setCookie = proxyRes.headers["set-cookie"];
|
||||
if (setCookie) {
|
||||
proxyRes.headers["set-cookie"] = setCookie.map((cookie) =>
|
||||
cookie.replace(/domain=[^;]+/gi, "domain=localhost")
|
||||
);
|
||||
}
|
||||
clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
||||
proxyRes.pipe(clientRes, { end: true });
|
||||
});
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
console.error("Proxy error:", err.message);
|
||||
clientRes.writeHead(502);
|
||||
clientRes.end("Bad Gateway");
|
||||
});
|
||||
|
||||
clientReq.pipe(proxyReq, { end: true });
|
||||
});
|
||||
|
||||
// WebSocket upgrades
|
||||
proxyServer.on("upgrade", (req, socket, head) => {
|
||||
const pathname = req.url?.split("?")[0] ?? "";
|
||||
|
||||
if (pathname === "/ws_dev" || pathname === "/ws") {
|
||||
devWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
devWss.emit("connection", ws, req);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/ws/") || pathname.startsWith("/ws_mp/") || pathname.startsWith("/ws_debug/")) {
|
||||
const wsProtocol = isHttps ? "wss" : "ws";
|
||||
const remoteWsUrl = `${wsProtocol}://${remote.host}${req.url}`;
|
||||
const remoteWs = new WebSocket(remoteWsUrl, {
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: remote.host,
|
||||
},
|
||||
});
|
||||
|
||||
remoteWs.on("open", () => {
|
||||
proxyWss.handleUpgrade(req, socket, head, (clientWs) => {
|
||||
clientWs.on("message", (data) => {
|
||||
if (remoteWs.readyState === WebSocket.OPEN) {
|
||||
remoteWs.send(data);
|
||||
}
|
||||
});
|
||||
remoteWs.on("message", (data) => {
|
||||
if (clientWs.readyState === WebSocket.OPEN) {
|
||||
clientWs.send(data);
|
||||
}
|
||||
});
|
||||
clientWs.on("close", () => remoteWs.close());
|
||||
remoteWs.on("close", () => clientWs.close());
|
||||
});
|
||||
});
|
||||
|
||||
remoteWs.on("error", (err) => {
|
||||
console.error("WebSocket proxy error:", err.message);
|
||||
socket.destroy();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
proxyServer.listen(proxyPort, () => {
|
||||
console.log(`Dev proxy listening on http://localhost:${proxyPort}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Legacy server (direct WebSocket, no proxy) ---
|
||||
|
||||
async function startLegacyServer() {
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
});
|
||||
const wss = new WebSocketServer({ server });
|
||||
setupDevWs(wss);
|
||||
|
||||
const port = await getPort.default({ port: PORT });
|
||||
const url =
|
||||
`${workspace.remote}dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}` +
|
||||
(port === PORT ? "" : `&port=${port}`);
|
||||
(port === PORT ? "" : `&port=${port}`) +
|
||||
(opts.path ? `&path=${opts.path}` : "");
|
||||
|
||||
console.log(`Go to ${url}`);
|
||||
try {
|
||||
open.openApp(open.apps.browser, { arguments: [url] }).catch((error) => {
|
||||
open.default(url).catch((error) => {
|
||||
console.error(
|
||||
`Failed to open browser, please navigate to ${url}, error: ${error}`
|
||||
);
|
||||
@@ -231,7 +673,18 @@ async function dev(opts: GlobalOptions & SyncOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([startApp(), watchChanges()]);
|
||||
// --- Start ---
|
||||
|
||||
// If --path is set, load it immediately
|
||||
if (opts.path) {
|
||||
await loadWmPath(opts.path);
|
||||
}
|
||||
|
||||
const startServer = opts.proxyPort
|
||||
? () => startProxyServer(opts.proxyPort!)
|
||||
: () => startLegacyServer();
|
||||
|
||||
await Promise.all([startServer(), watchChanges()]);
|
||||
console.log("Stopped dev mode");
|
||||
}
|
||||
|
||||
@@ -241,6 +694,14 @@ const command = new Command()
|
||||
"--includes <pattern...:string>",
|
||||
"Filter paths givena glob pattern or path"
|
||||
)
|
||||
.option(
|
||||
"--proxy-port <port:number>",
|
||||
"Port for a localhost reverse proxy to the remote Windmill server"
|
||||
)
|
||||
.option(
|
||||
"--path <path:string>",
|
||||
"Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)"
|
||||
)
|
||||
.action(dev as any);
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Command } from "@cliffy/command";
|
||||
import { Confirm } from "@cliffy/prompt/confirm";
|
||||
import { Table } from "@cliffy/table";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { sep as SEP } from "node:path";
|
||||
import { sep as SEP, join as pathJoin, resolve as pathResolve } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import { yamlParseFile } from "../../utils/yaml.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
@@ -541,6 +542,55 @@ export function bootstrap(
|
||||
|
||||
const flowYamlPath = `${flowDirFullPath}/flow.yaml`;
|
||||
writeFileSync(flowYamlPath, newFlowDefinitionYaml, { flag: "wx", encoding: "utf-8" });
|
||||
|
||||
// Generate .claude/launch.json for Claude Code preview
|
||||
const claudeDir = pathJoin(flowDirFullPath, ".claude");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const launchJson = {
|
||||
version: "0.0.1",
|
||||
configurations: [{
|
||||
name: "windmill",
|
||||
runtimeExecutable: "bash",
|
||||
runtimeArgs: ["-c", "wmill dev --proxy-port ${PORT:-4000}"],
|
||||
port: 4000,
|
||||
autoPort: true,
|
||||
}],
|
||||
};
|
||||
writeFileSync(
|
||||
pathJoin(claudeDir, "launch.json"),
|
||||
JSON.stringify(launchJson, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
log.info(colors.green(`Created flow at ${flowDirFullPath}`));
|
||||
|
||||
// Detect Claude CLI and Claude Desktop
|
||||
let hasClaudeCli = false;
|
||||
let hasClaudeDesktop = false;
|
||||
try {
|
||||
execSync("which claude", { stdio: "ignore" });
|
||||
hasClaudeCli = true;
|
||||
} catch {}
|
||||
try {
|
||||
execSync("ls /Applications/Claude.app", { stdio: "ignore" });
|
||||
hasClaudeDesktop = true;
|
||||
} catch {}
|
||||
|
||||
if (hasClaudeCli && hasClaudeDesktop) {
|
||||
log.info("");
|
||||
log.info(colors.bold("To develop this flow with Claude Desktop:"));
|
||||
log.info(colors.gray(` 1. cd ${flowDirFullPath} && claude`));
|
||||
log.info(colors.gray(` 2. Type /desktop to open the session in Claude Desktop`));
|
||||
} else if (hasClaudeCli) {
|
||||
log.info("");
|
||||
log.info(colors.bold("To preview this flow:"));
|
||||
log.info(colors.gray(` wmill dev --path ${flowPath}`));
|
||||
} else if (hasClaudeDesktop) {
|
||||
const absFlowDir = pathResolve(flowDirFullPath);
|
||||
log.info("");
|
||||
log.info(colors.bold("To develop this flow with Claude Desktop:"));
|
||||
log.info(colors.gray(` Open Claude Desktop with "${absFlowDir}" as root folder`));
|
||||
}
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { stat, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { stat, writeFile, rm, mkdir, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import { Command } from "@cliffy/command";
|
||||
import { Confirm } from "@cliffy/prompt/confirm";
|
||||
@@ -354,6 +356,92 @@ async function initAction(opts: InitOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate .claude/launch.json for each flow folder
|
||||
try {
|
||||
const flowSuffix = nonDottedPaths ? "__flow" : ".flow";
|
||||
const flowLaunchJson = JSON.stringify({
|
||||
version: "0.0.1",
|
||||
configurations: [{
|
||||
name: "windmill",
|
||||
runtimeExecutable: "bash",
|
||||
runtimeArgs: ["-c", "wmill dev --proxy-port ${PORT:-4000}"],
|
||||
port: 4000,
|
||||
autoPort: true,
|
||||
}],
|
||||
}, null, 2) + "\n";
|
||||
|
||||
let flowCount = 0;
|
||||
async function scanForFlows(dir: string) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.name.endsWith(flowSuffix)) {
|
||||
const claudeDir = join(fullPath, ".claude");
|
||||
const launchPath = join(claudeDir, "launch.json");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(launchPath, flowLaunchJson, "utf-8");
|
||||
flowCount++;
|
||||
} else {
|
||||
await scanForFlows(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await scanForFlows(".");
|
||||
if (flowCount > 0) {
|
||||
log.info(colors.green(`Created .claude/launch.json for ${flowCount} flow folder(s)`));
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`Could not scan for flow folders: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
|
||||
// Generate .claude/launch.json for each raw_app folder
|
||||
try {
|
||||
const rawAppSuffix = nonDottedPaths ? "__raw_app" : ".raw_app";
|
||||
const appLaunchJson = JSON.stringify({
|
||||
version: "0.0.1",
|
||||
configurations: [{
|
||||
name: "windmill",
|
||||
runtimeExecutable: "bash",
|
||||
runtimeArgs: ["-c", "wmill app dev --no-open --port ${PORT:-4000}"],
|
||||
port: 4000,
|
||||
autoPort: true,
|
||||
}],
|
||||
}, null, 2) + "\n";
|
||||
|
||||
let appCount = 0;
|
||||
async function scanForApps(dir: string) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.name.endsWith(rawAppSuffix)) {
|
||||
const claudeDir = join(fullPath, ".claude");
|
||||
const launchPath = join(claudeDir, "launch.json");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(launchPath, appLaunchJson, "utf-8");
|
||||
appCount++;
|
||||
} else {
|
||||
await scanForApps(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await scanForApps(".");
|
||||
if (appCount > 0) {
|
||||
log.info(colors.green(`Created .claude/launch.json for ${appCount} raw app folder(s)`));
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`Could not scan for raw app folders: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
|
||||
// Generate resource type namespace
|
||||
try {
|
||||
await generateRTNamespace(opts as GlobalOptions);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { fetchVersion, resolveWorkspace } from "../../core/context.ts";
|
||||
import { readFile, writeFile, readdir, stat, rm, copyFile, mkdir } from "node:fs/promises";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { colors } from "@cliffy/ansi/colors";
|
||||
import { Command } from "@cliffy/command";
|
||||
import { Confirm } from "@cliffy/prompt/confirm";
|
||||
@@ -2305,6 +2306,86 @@ export async function pull(
|
||||
false,
|
||||
);
|
||||
}
|
||||
// Generate .claude/launch.json for all flow folders
|
||||
try {
|
||||
const flowSuffix = getFolderSuffix("flow");
|
||||
const flowLaunchJson = JSON.stringify({
|
||||
version: "0.0.1",
|
||||
configurations: [{
|
||||
name: "windmill",
|
||||
runtimeExecutable: "bash",
|
||||
runtimeArgs: ["-c", "wmill dev --proxy-port ${PORT:-4000}"],
|
||||
port: 4000,
|
||||
autoPort: true,
|
||||
}],
|
||||
}, null, 2) + "\n";
|
||||
|
||||
let flowLaunchCount = 0;
|
||||
async function scanForFlows(dir: string) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.name.endsWith(flowSuffix)) {
|
||||
const claudeDir = path.join(fullPath, ".claude");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(path.join(claudeDir, "launch.json"), flowLaunchJson, "utf-8");
|
||||
flowLaunchCount++;
|
||||
} else {
|
||||
await scanForFlows(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await scanForFlows(".");
|
||||
if (flowLaunchCount > 0) {
|
||||
log.info(colors.green(`Created .claude/launch.json for ${flowLaunchCount} flow folder(s)`));
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn(`Could not scan for flow folders: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
|
||||
// Generate .claude/launch.json for all raw_app folders
|
||||
try {
|
||||
const rawAppSuffix = getFolderSuffix("raw_app");
|
||||
const appLaunchJson = JSON.stringify({
|
||||
version: "0.0.1",
|
||||
configurations: [{
|
||||
name: "windmill",
|
||||
runtimeExecutable: "bash",
|
||||
runtimeArgs: ["-c", "wmill app dev --no-open --port ${PORT:-4000}"],
|
||||
port: 4000,
|
||||
autoPort: true,
|
||||
}],
|
||||
}, null, 2) + "\n";
|
||||
|
||||
let appLaunchCount = 0;
|
||||
async function scanForApps(dir: string) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.name.endsWith(rawAppSuffix)) {
|
||||
const claudeDir = path.join(fullPath, ".claude");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(path.join(claudeDir, "launch.json"), appLaunchJson, "utf-8");
|
||||
appLaunchCount++;
|
||||
} else {
|
||||
await scanForApps(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await scanForApps(".");
|
||||
if (appLaunchCount > 0) {
|
||||
log.info(colors.green(`Created .claude/launch.json for ${appLaunchCount} raw app folder(s)`));
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn(`Could not scan for raw app folders: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
|
||||
if (tracker.apps.length > 0) {
|
||||
log.info(
|
||||
colors.gray(
|
||||
|
||||
@@ -5015,6 +5015,8 @@ Launch a dev server that will spawn a webserver with HMR
|
||||
|
||||
**Options:**
|
||||
- \`--includes <pattern...:string>\` - Filter paths givena glob pattern or path
|
||||
- \`--proxy-port <port:number>\` - Port for a localhost reverse proxy to the remote Windmill server
|
||||
- \`--path <path:string>\` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)
|
||||
|
||||
### docs
|
||||
|
||||
|
||||
@@ -111,6 +111,9 @@
|
||||
|
||||
let darkModeToggle: DarkModeToggle | undefined = $state()
|
||||
let darkMode: boolean = $state(document.documentElement.classList.contains('dark'))
|
||||
let flowContainerWidth = $state(0)
|
||||
let flowContainerHeight = $state(0)
|
||||
let flowHorizontalSplit = $derived(flowContainerWidth < flowContainerHeight)
|
||||
let modeInitialized = $state(false)
|
||||
function initializeMode() {
|
||||
modeInitialized = true
|
||||
@@ -344,6 +347,14 @@
|
||||
try {
|
||||
socket = new WebSocket(`ws://localhost:${port}/ws`)
|
||||
|
||||
// On connect, request a specific path if one is specified
|
||||
socket.addEventListener('open', () => {
|
||||
const watchPath = searchParams?.get('path')
|
||||
if (watchPath && socket) {
|
||||
socket.send(JSON.stringify({ type: 'loadWmPath', path: watchPath }))
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for messages
|
||||
socket.addEventListener('message', (event) => {
|
||||
replaceData(event.data)
|
||||
@@ -357,6 +368,15 @@
|
||||
console.log('Received invalid JSON: ' + msg)
|
||||
return
|
||||
}
|
||||
// Client-side filtering: only accept messages matching the watched path
|
||||
// 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) {
|
||||
return
|
||||
}
|
||||
if (data.type == 'script') {
|
||||
replaceScript(data)
|
||||
} else if (data.type == 'flow') {
|
||||
@@ -568,13 +588,20 @@
|
||||
setGroupEditorContext(groupEditor, canCreateGroup)
|
||||
|
||||
let lastSent: OpenFlow | undefined = undefined
|
||||
const isInIframe = window.parent !== window
|
||||
function updateFlow(flow: OpenFlow) {
|
||||
if (lockChanges) {
|
||||
return
|
||||
}
|
||||
if (!deepEqual(flow, lastSent)) {
|
||||
lastSent = $state.snapshot(flow)
|
||||
window?.parent.postMessage({ type: 'flow', flow: lastSent, uriPath: lastUriPath }, '*')
|
||||
if (isInIframe) {
|
||||
// VS Code extension: round-trip via postMessage
|
||||
window?.parent.postMessage({ type: 'flow', flow: lastSent, uriPath: lastUriPath }, '*')
|
||||
} else if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
// CLI dev mode: round-trip via WebSocket
|
||||
socket.send(JSON.stringify({ type: 'flow', flow: lastSent, uriPath: lastUriPath }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,7 +873,11 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- <div class="h-full w-full grid grid-cols-2"> -->
|
||||
<div class="h-full w-full">
|
||||
<div
|
||||
class="h-full w-full"
|
||||
bind:clientWidth={flowContainerWidth}
|
||||
bind:clientHeight={flowContainerHeight}
|
||||
>
|
||||
<div class="flex flex-col max-h-screen h-full relative">
|
||||
<div class="absolute top-0 left-2">
|
||||
<DarkModeToggle bind:darkMode bind:this={darkModeToggle} forcedDarkMode={false} />
|
||||
@@ -857,48 +888,51 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-1 z-50 absolute -translate-x-[100%] right-2 top-2 gap-2">
|
||||
<FlowPreviewButtons
|
||||
{suspendStatus}
|
||||
bind:this={flowPreviewButtons}
|
||||
{onJobDone}
|
||||
bind:localModuleStates
|
||||
onRunPreview={() => {
|
||||
showJobStatus = true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Splitpanes horizontal class="h-full max-h-screen grow">
|
||||
<Splitpanes horizontal={flowHorizontalSplit} class="h-full max-h-screen grow">
|
||||
<Pane size={67}>
|
||||
{#if flowStore.val?.value?.modules}
|
||||
<div id="flow-editor"></div>
|
||||
<FlowModuleSchemaMap
|
||||
bind:this={flowModuleSchemaMap}
|
||||
disableAi
|
||||
disableTutorials
|
||||
smallErrorHandler={true}
|
||||
disableStaticInputs
|
||||
localModuleStates={showJobStatus ? localModuleStates : {}}
|
||||
onTestUpTo={flowPreviewButtons?.testUpTo}
|
||||
testModuleStates={modulesTestStates}
|
||||
isOwner={flowPreviewContent?.getIsOwner?.()}
|
||||
onTestFlow={flowPreviewButtons?.runPreview}
|
||||
isRunning={flowPreviewContent?.getIsRunning?.()}
|
||||
onCancelTestFlow={flowPreviewContent?.cancelTest}
|
||||
onOpenPreview={flowPreviewButtons?.openPreview}
|
||||
onHideJobStatus={resetModulesStates}
|
||||
flowJob={job}
|
||||
{showJobStatus}
|
||||
onDelete={(id) => {
|
||||
delete localModuleStates[id]
|
||||
delete modulesTestStates.states[id]
|
||||
}}
|
||||
{flowHasChanged}
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-red-400 mt-20">Missing flow modules</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
<div class="relative h-full w-full">
|
||||
{#if flowStore.val?.value?.modules}
|
||||
<div id="flow-editor"></div>
|
||||
<div class="flex justify-center pt-1 z-50 absolute right-2 top-2 gap-2">
|
||||
<FlowPreviewButtons
|
||||
{suspendStatus}
|
||||
bind:this={flowPreviewButtons}
|
||||
{onJobDone}
|
||||
bind:localModuleStates
|
||||
onRunPreview={() => {
|
||||
showJobStatus = true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FlowModuleSchemaMap
|
||||
bind:this={flowModuleSchemaMap}
|
||||
disableAi
|
||||
disableTutorials
|
||||
smallErrorHandler={true}
|
||||
disableStaticInputs
|
||||
localModuleStates={showJobStatus ? localModuleStates : {}}
|
||||
onTestUpTo={flowPreviewButtons?.testUpTo}
|
||||
testModuleStates={modulesTestStates}
|
||||
isOwner={flowPreviewContent?.getIsOwner?.()}
|
||||
onTestFlow={flowPreviewButtons?.runPreview}
|
||||
isRunning={flowPreviewContent?.getIsRunning?.()}
|
||||
onCancelTestFlow={flowPreviewContent?.cancelTest}
|
||||
onOpenPreview={flowPreviewButtons?.openPreview}
|
||||
onHideJobStatus={resetModulesStates}
|
||||
flowJob={job}
|
||||
{showJobStatus}
|
||||
onDelete={(id) => {
|
||||
delete localModuleStates[id]
|
||||
delete modulesTestStates.states[id]
|
||||
}}
|
||||
{flowHasChanged}
|
||||
controlsPosition="bottom"
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-red-400 mt-20">Missing flow modules</div>
|
||||
{/if}
|
||||
</div></Pane
|
||||
>
|
||||
<Pane size={33}>
|
||||
{#key reload}
|
||||
<FlowEditorPanel
|
||||
|
||||
@@ -813,7 +813,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="min-h-0 flex-grow" id="flow-editor-editor">
|
||||
<div
|
||||
class="min-h-0 flex-grow"
|
||||
id="flow-editor-editor"
|
||||
class:no-splitter={flowModule.value.type === 'aiagent' || noEditor}
|
||||
>
|
||||
<Splitpanes horizontal>
|
||||
{#if flowModule.value.type !== 'aiagent'}
|
||||
<Pane bind:size={editorPanelSize} minSize={10} class="relative">
|
||||
@@ -1161,7 +1165,9 @@
|
||||
<Section label="Continue on error">
|
||||
{#snippet header()}
|
||||
<Tooltip>
|
||||
When enabled, the flow will continue to the next step even if this step fails (after exhausting all retries, if any). This enables to process the error in a branch one for instance.
|
||||
When enabled, the flow will continue to the next step even if this
|
||||
step fails (after exhausting all retries, if any). This enables to
|
||||
process the error in a branch one for instance.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<Toggle
|
||||
@@ -1522,3 +1528,9 @@
|
||||
<Button size="sm" on:click={confirmDebugBetaWarning}>Continue</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.no-splitter :global(.splitpanes__splitter) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
suspendStatus?: StateStore<Record<string, { job: Job; nb: number }>>
|
||||
onDelete?: (id: string) => void
|
||||
flowHasChanged?: boolean
|
||||
controlsPosition?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -124,6 +125,7 @@
|
||||
showJobStatus = false,
|
||||
suspendStatus = $bindable({ val: {} }),
|
||||
onDelete,
|
||||
controlsPosition = 'top',
|
||||
flowHasChanged
|
||||
}: Props = $props()
|
||||
|
||||
@@ -620,7 +622,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="z-10 flex-auto grow bg-surface-secondary" bind:clientHeight={minHeight}>
|
||||
<div class="z-10 flex-auto grow min-h-0 bg-surface-secondary" bind:clientHeight={minHeight}>
|
||||
<FlowGraphV2
|
||||
bind:this={graph}
|
||||
earlyStop={flowStore.val.value?.skip_expr !== undefined}
|
||||
@@ -1044,6 +1046,7 @@
|
||||
{onCancelTestFlow}
|
||||
{onOpenPreview}
|
||||
{onHideJobStatus}
|
||||
{controlsPosition}
|
||||
exitNoteMode={() => (noteMode = false)}
|
||||
onNotePositionUpdate={(noteId, position) => {
|
||||
// Update note position via NoteEditor context in edit mode
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
diffBeforeFlow?: OpenFlow
|
||||
currentInputSchema?: Record<string, any>
|
||||
markRemovedAsShadowed?: boolean
|
||||
controlsPosition?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -269,7 +270,8 @@
|
||||
onDeleteMultiple = undefined,
|
||||
onDuplicateMultiple = undefined,
|
||||
onMoveMultiple = undefined,
|
||||
movingIds = undefined
|
||||
movingIds = undefined,
|
||||
controlsPosition = 'top'
|
||||
}: Props = $props()
|
||||
|
||||
// Initialize note manager with fine-grained reactivity
|
||||
@@ -738,7 +740,8 @@
|
||||
} else {
|
||||
const minY = Math.min(...nodes.map((n) => n.position.y))
|
||||
const maxBottom = Math.max(...nodes.map((n) => n.position.y + NODE.height + 100))
|
||||
height = Math.max(maxBottom - minY, minHeight)
|
||||
const computed = maxBottom - minY
|
||||
height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,7 +1167,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<Controls
|
||||
position="top-right"
|
||||
position={controlsPosition === 'bottom' ? 'bottom-right' : 'top-right'}
|
||||
orientation="horizontal"
|
||||
showLock={false}
|
||||
fitViewOptions={{ nodes: nodes.filter((n) => n.type !== 'note') }}
|
||||
@@ -1227,7 +1230,7 @@
|
||||
</Controls>
|
||||
|
||||
<Controls
|
||||
position="top-left"
|
||||
position={controlsPosition === 'bottom' ? 'bottom-left' : 'top-left'}
|
||||
orientation="vertical"
|
||||
showLock={false}
|
||||
showZoom={false}
|
||||
|
||||
@@ -57,6 +57,8 @@ Launch a dev server that will spawn a webserver with HMR
|
||||
|
||||
**Options:**
|
||||
- `--includes <pattern...:string>` - Filter paths givena glob pattern or path
|
||||
- `--proxy-port <port:number>` - Port for a localhost reverse proxy to the remote Windmill server
|
||||
- `--path <path:string>` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)
|
||||
|
||||
### docs
|
||||
|
||||
|
||||
@@ -1584,6 +1584,8 @@ Launch a dev server that will spawn a webserver with HMR
|
||||
|
||||
**Options:**
|
||||
- \`--includes <pattern...:string>\` - Filter paths givena glob pattern or path
|
||||
- \`--proxy-port <port:number>\` - Port for a localhost reverse proxy to the remote Windmill server
|
||||
- \`--path <path:string>\` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)
|
||||
|
||||
### docs
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ Launch a dev server that will spawn a webserver with HMR
|
||||
|
||||
**Options:**
|
||||
- `--includes <pattern...:string>` - Filter paths givena glob pattern or path
|
||||
- `--proxy-port <port:number>` - Port for a localhost reverse proxy to the remote Windmill server
|
||||
- `--path <path:string>` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)
|
||||
|
||||
### docs
|
||||
|
||||
|
||||
Reference in New Issue
Block a user