diff --git a/backend/.sqlx/query-1f1e4046c55ec33e0ea51d3424c21f538ccc72b86f4ead52292463c29f8dfea6.json b/backend/.sqlx/query-1f1e4046c55ec33e0ea51d3424c21f538ccc72b86f4ead52292463c29f8dfea6.json new file mode 100644 index 0000000000..cd77353df3 --- /dev/null +++ b/backend/.sqlx/query-1f1e4046c55ec33e0ea51d3424c21f538ccc72b86f4ead52292463c29f8dfea6.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_shared_ui (workspace_id, files, version, edited_at, edited_by)\n VALUES ($1, $2, 1, now(), $3)\n ON CONFLICT (workspace_id) DO UPDATE\n SET files = EXCLUDED.files,\n version = workspace_shared_ui.version + 1,\n edited_at = now(),\n edited_by = EXCLUDED.edited_by", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Jsonb", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1f1e4046c55ec33e0ea51d3424c21f538ccc72b86f4ead52292463c29f8dfea6" +} diff --git a/backend/.sqlx/query-410718c2290d3d4688382fe28c9aca76edec85ce6f0b0ede908d0fb1ac6925c4.json b/backend/.sqlx/query-410718c2290d3d4688382fe28c9aca76edec85ce6f0b0ede908d0fb1ac6925c4.json new file mode 100644 index 0000000000..30554ee749 --- /dev/null +++ b/backend/.sqlx/query-410718c2290d3d4688382fe28c9aca76edec85ce6f0b0ede908d0fb1ac6925c4.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT files, version, edited_at, edited_by FROM workspace_shared_ui WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "files", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "edited_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "410718c2290d3d4688382fe28c9aca76edec85ce6f0b0ede908d0fb1ac6925c4" +} diff --git a/backend/.sqlx/query-77f9650e543821605d6cd5eb11af3a6d46e60d5288357f22faf5fcd5298923c3.json b/backend/.sqlx/query-77f9650e543821605d6cd5eb11af3a6d46e60d5288357f22faf5fcd5298923c3.json new file mode 100644 index 0000000000..08adf61674 --- /dev/null +++ b/backend/.sqlx/query-77f9650e543821605d6cd5eb11af3a6d46e60d5288357f22faf5fcd5298923c3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT version FROM workspace_shared_ui WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "77f9650e543821605d6cd5eb11af3a6d46e60d5288357f22faf5fcd5298923c3" +} diff --git a/backend/migrations/20260429151017_workspace_shared_ui.down.sql b/backend/migrations/20260429151017_workspace_shared_ui.down.sql new file mode 100644 index 0000000000..f8149616ea --- /dev/null +++ b/backend/migrations/20260429151017_workspace_shared_ui.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE IF EXISTS workspace_shared_ui; diff --git a/backend/migrations/20260429151017_workspace_shared_ui.up.sql b/backend/migrations/20260429151017_workspace_shared_ui.up.sql new file mode 100644 index 0000000000..0aeb924fe2 --- /dev/null +++ b/backend/migrations/20260429151017_workspace_shared_ui.up.sql @@ -0,0 +1,11 @@ +-- Add up migration script here +CREATE TABLE workspace_shared_ui ( + workspace_id VARCHAR(50) PRIMARY KEY REFERENCES workspace(id) ON DELETE CASCADE, + files JSONB NOT NULL DEFAULT '{}'::jsonb, + version BIGINT NOT NULL DEFAULT 0, + edited_at TIMESTAMPTZ NOT NULL DEFAULT now(), + edited_by VARCHAR(255) NOT NULL DEFAULT '' +); + +GRANT ALL ON workspace_shared_ui TO windmill_user; +GRANT ALL ON workspace_shared_ui TO windmill_admin; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 88bf67a261..48ff99984f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9660,6 +9660,131 @@ paths: items: $ref: "#/components/schemas/ListableRawApp" + /w/{workspace}/shared_ui/get: + get: + summary: get the workspace shared UI folder (full content) + operationId: getSharedUi + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: shared UI content + content: + application/json: + schema: + type: object + required: + - files + - version + - edited_at + - edited_by + properties: + files: + type: object + additionalProperties: + type: string + version: + type: integer + format: int64 + edited_at: + type: string + format: date-time + edited_by: + type: string + + /w/{workspace}/shared_ui/list: + get: + summary: list paths/sizes of the workspace shared UI folder + operationId: listSharedUi + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: shared UI listing + content: + application/json: + schema: + type: object + required: + - paths + - sizes + - version + - edited_at + - edited_by + properties: + paths: + type: array + items: + type: string + sizes: + type: object + additionalProperties: + type: integer + format: int64 + version: + type: integer + format: int64 + edited_at: + type: string + format: date-time + edited_by: + type: string + + /w/{workspace}/shared_ui/version: + get: + summary: get the current version of the workspace shared UI folder + operationId: getSharedUiVersion + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: shared UI version + content: + application/json: + schema: + type: object + required: + - version + properties: + version: + type: integer + format: int64 + + /w/{workspace}/shared_ui: + put: + summary: replace the workspace shared UI folder (admin only) + operationId: updateSharedUi + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - files + properties: + files: + type: object + additionalProperties: + type: string + responses: + "200": + description: updated + content: + text/plain: + schema: + type: string + /w/{workspace}/apps/get_data/v/{secretWithExtension}: get: summary: get raw app data by diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 6cebf7aa15..0fde5cacd1 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -151,6 +151,7 @@ mod smtp_server_oss; #[cfg(feature = "private")] pub mod teams_approvals_ee; mod teams_approvals_oss; +mod workspace_shared_ui; #[cfg(feature = "native_trigger")] pub mod native_triggers; @@ -616,6 +617,7 @@ pub async fn run_server( ) .nest("/raw_apps", raw_apps::workspaced_service()) .nest("/resources", resources::workspaced_service()) + .nest("/shared_ui", workspace_shared_ui::workspaced_service()) .nest("/schedules", windmill_api_schedule::workspaced_service()) .nest("/scripts", scripts::workspaced_service()) .nest("/trash", trash::workspaced_service()) diff --git a/backend/windmill-api/src/workspace_shared_ui.rs b/backend/windmill-api/src/workspace_shared_ui.rs new file mode 100644 index 0000000000..810ea3420f --- /dev/null +++ b/backend/windmill-api/src/workspace_shared_ui.rs @@ -0,0 +1,200 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2026 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +use crate::db::{ApiAuthed, DB}; +use axum::{ + extract::{Extension, Json, Path}, + routing::{get, put}, + Router, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::{ + db::UserDB, + error::{Error, JsonResult, Result}, + utils::require_admin, +}; +use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; + +pub fn workspaced_service() -> Router { + Router::new() + .route("/get", get(get_shared_ui)) + .route("/list", get(list_shared_ui)) + .route("/version", get(get_version)) + .route("/", put(update_shared_ui)) +} + +#[derive(Serialize, Deserialize)] +pub struct SharedUi { + pub files: HashMap, + pub version: i64, + pub edited_at: chrono::DateTime, + pub edited_by: String, +} + +#[derive(Serialize)] +pub struct SharedUiListing { + pub paths: Vec, + pub sizes: HashMap, + pub version: i64, + pub edited_at: chrono::DateTime, + pub edited_by: String, +} + +#[derive(Serialize)] +pub struct SharedUiVersion { + pub version: i64, +} + +#[derive(Deserialize)] +pub struct UpdateSharedUi { + pub files: HashMap, +} + +async fn get_shared_ui( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let row = sqlx::query!( + "SELECT files, version, edited_at, edited_by FROM workspace_shared_ui WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + + let result = match row { + Some(r) => SharedUi { + files: serde_json::from_value(r.files).unwrap_or_default(), + version: r.version, + edited_at: r.edited_at, + edited_by: r.edited_by, + }, + None => SharedUi { + files: HashMap::new(), + version: 0, + edited_at: chrono::Utc::now(), + edited_by: String::new(), + }, + }; + Ok(Json(result)) +} + +async fn list_shared_ui( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let row = sqlx::query!( + "SELECT files, version, edited_at, edited_by FROM workspace_shared_ui WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + + let (files, version, edited_at, edited_by) = match row { + Some(r) => { + let files: HashMap = + serde_json::from_value(r.files).unwrap_or_default(); + (files, r.version, r.edited_at, r.edited_by) + } + None => (HashMap::new(), 0, chrono::Utc::now(), String::new()), + }; + + let mut paths: Vec = files.keys().cloned().collect(); + paths.sort(); + let sizes: HashMap = files + .iter() + .map(|(k, v)| (k.clone(), v.len() as i64)) + .collect(); + + Ok(Json(SharedUiListing { + paths, + sizes, + version, + edited_at, + edited_by, + })) +} + +async fn get_version( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let version = sqlx::query_scalar!( + "SELECT version FROM workspace_shared_ui WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + + Ok(Json(SharedUiVersion { version: version.unwrap_or(0) })) +} + +async fn update_shared_ui( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(payload): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let files_json = serde_json::to_value(&payload.files) + .map_err(|e| Error::internal_err(format!("serializing files: {e}")))?; + + let mut tx = db.begin().await?; + sqlx::query!( + r#"INSERT INTO workspace_shared_ui (workspace_id, files, version, edited_at, edited_by) + VALUES ($1, $2, 1, now(), $3) + ON CONFLICT (workspace_id) DO UPDATE + SET files = EXCLUDED.files, + version = workspace_shared_ui.version + 1, + edited_at = now(), + edited_by = EXCLUDED.edited_by"#, + &w_id, + files_json, + &authed.username, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspace_shared_ui.update", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("file_count", &payload.files.len().to_string()[..])].into()), + ) + .await?; + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "shared_ui".to_string() }, + None, + false, + None, + ) + .await?; + + Ok(format!("Updated shared UI for workspace {}", &w_id)) +} diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index 0f82f7b8c4..e2f8553e22 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -12,6 +12,12 @@ export interface BundleOptions { sourcemap?: boolean; minify?: boolean; production?: boolean; + /** + * Absolute path to the workspace's shared `ui/` folder. When set, imports + * starting with `/ui/...` are resolved as files inside this directory. + * Allows raw apps to reuse components from the workspace-level shared folder. + */ + sharedUiDir?: string; } export interface BundleResult { @@ -235,6 +241,45 @@ export async function createBundle( }, }; + const sharedUiPlugins: any[] = []; + if (options.sharedUiDir && fs.existsSync(options.sharedUiDir)) { + const sharedUiDir = options.sharedUiDir; + sharedUiPlugins.push({ + name: "wmill-shared-ui", + setup(build: any) { + // Intercept imports of /ui/ and resolve to the workspace ui/ folder. + build.onResolve({ filter: /^\/ui\// }, (args: any) => { + const rel = args.path.slice("/ui/".length); + const candidates = [rel]; + if (!path.extname(rel)) { + candidates.push( + rel + ".tsx", + rel + ".ts", + rel + ".jsx", + rel + ".js", + rel + ".css", + path.join(rel, "index.tsx"), + path.join(rel, "index.ts"), + ); + } + for (const c of candidates) { + const full = path.join(sharedUiDir, c); + if (fs.existsSync(full)) { + return { path: full }; + } + } + return { + errors: [ + { + text: `Could not resolve shared UI import "${args.path}" in ${sharedUiDir}`, + }, + ], + }; + }); + }, + }); + } + const buildOptions = { ...DEFAULT_BUILD_OPTIONS, entryPoints: [entryPoint], @@ -244,7 +289,7 @@ export async function createBundle( define: { "process.env.NODE_ENV": production ? '"production"' : '"development"', }, - plugins: [...frameworkPlugins, wmillPlugin], + plugins: [...frameworkPlugins, wmillPlugin, ...sharedUiPlugins], }; log.info(colors.blue("📦 Building bundle...")); diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 4995c8d708..43c39ec9e4 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -417,10 +417,12 @@ export async function pushRawApp( ? "index.ts" : "index.tsx"; const entryPoint = localPath + entryFile; + const sharedUiDir = path.join(process.cwd(), "ui"); return await createBundle({ entryPoint: entryPoint, production: true, minify: true, + sharedUiDir, }); } // Build the value object, including data if present diff --git a/cli/src/commands/shared_ui.ts b/cli/src/commands/shared_ui.ts new file mode 100644 index 0000000000..e3f719d77f --- /dev/null +++ b/cli/src/commands/shared_ui.ts @@ -0,0 +1,122 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as log from "../core/log.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as wmill from "../../gen/services.gen.ts"; +import { readTextFile, readTextFileSync } from "../utils/utils.ts"; + +const SHARED_UI_DIR = "ui"; + +async function readDirRecursive( + dir: string, + rel: string = "", + out: Record = {}, +): Promise> { + if (!fs.existsSync(dir)) return out; + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + const r = rel ? rel + "/" + entry.name : entry.name; + if (entry.isDirectory()) { + await readDirRecursive(full, r, out); + } else if (entry.isFile()) { + out[r] = await readTextFile(full); + } + } + return out; +} + +/** + * Push the local /ui/ folder to the workspace's shared UI store. + * Returns true if a push was performed, false if the folder is missing or empty. + */ +export async function pushSharedUi(workspace: string): Promise { + const localDir = path.join(process.cwd(), SHARED_UI_DIR); + if (!fs.existsSync(localDir)) { + return false; + } + const files = await readDirRecursive(localDir); + + // Skip if no change + let remote: Record = {}; + try { + const got = await wmill.getSharedUi({ workspace }); + remote = got.files ?? {}; + } catch { + // If endpoint missing or unauthorized, just attempt the PUT + } + + if ( + Object.keys(remote).length === Object.keys(files).length && + Object.entries(files).every(([k, v]) => remote[k] === v) + ) { + log.info(colors.gray("Shared UI folder up to date")); + return false; + } + + await wmill.updateSharedUi({ + workspace, + requestBody: { files }, + }); + log.info( + colors.green( + `Pushed ${Object.keys(files).length} file(s) to shared UI folder`, + ), + ); + return true; +} + +/** + * Pull the workspace's shared UI store into /ui/. + * Files removed remotely are also removed locally. + */ +export async function pullSharedUi(workspace: string): Promise { + const localDir = path.join(process.cwd(), SHARED_UI_DIR); + let got; + try { + got = await wmill.getSharedUi({ workspace }); + } catch (e) { + log.debug(`Skipping shared UI pull: ${e}`); + return false; + } + const files = got?.files ?? {}; + + if (Object.keys(files).length === 0 && !fs.existsSync(localDir)) { + return false; + } + fs.mkdirSync(localDir, { recursive: true }); + + // Write/refresh files + for (const [rel, content] of Object.entries(files)) { + const full = path.join(localDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + let existing: string | undefined; + try { + existing = readTextFileSync(full); + } catch { + existing = undefined; + } + if (existing !== content) { + fs.writeFileSync(full, content as string, "utf-8"); + } + } + + // Delete locally-orphaned files + const known = new Set(Object.keys(files)); + const local = await readDirRecursive(localDir); + for (const rel of Object.keys(local)) { + if (!known.has(rel)) { + const full = path.join(localDir, rel); + try { + fs.unlinkSync(full); + } catch { + // ignore + } + } + } + + log.info( + colors.green(`Pulled ${Object.keys(files).length} file(s) into ui/`), + ); + return true; +} diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 101a1cbe05..edbf27d86b 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -24,6 +24,7 @@ import { } from "../../types.ts"; import { downloadZip } from "./pull.ts"; import { runLint, printReport, checkMissingLocks } from "../lint/lint.ts"; +import { pullSharedUi, pushSharedUi } from "../shared_ui.ts"; import { exts, @@ -1920,6 +1921,11 @@ const isNotWmillFile = (p: string, isDirectory: boolean) => { if (p.endsWith(SEP)) { return false; } + // The `ui/` folder is workspace-shared frontend components for raw apps. + // It's pushed/pulled separately from the diff machinery (see pushSharedUi/pullSharedUi). + if (p.startsWith("ui" + SEP)) { + return true; + } if (isDirectory) { return ( !p.startsWith("u" + SEP) && @@ -1966,6 +1972,7 @@ export const isWhitelisted = (p: string) => { p == "u" || p == "f" || p == "g" || + p == "ui" || p == "users" || p == "groups" || p == "dependencies" @@ -2634,6 +2641,11 @@ export async function pull( ); } + try { + await pullSharedUi(workspace.workspaceId); + } catch (e) { + log.warn(`Failed to pull shared UI folder: ${e}`); + } } function prettyChanges( @@ -3926,6 +3938,11 @@ export async function push( await Promise.race(pool); } } + try { + await pushSharedUi(workspace.workspaceId); + } catch (e) { + log.warn(`Failed to push shared UI folder: ${e}`); + } if (opts.jsonOutput) { const result = { success: true, @@ -3964,14 +3981,21 @@ export async function push( ), ); } - } else if (opts.jsonOutput) { - console.log( - JSON.stringify( - { success: true, message: "No changes to push", total: 0 }, - null, - 2, - ), - ); + } else { + try { + await pushSharedUi(workspace.workspaceId); + } catch (e) { + log.warn(`Failed to push shared UI folder: ${e}`); + } + if (opts.jsonOutput) { + console.log( + JSON.stringify( + { success: true, message: "No changes to push", total: 0 }, + null, + 2, + ), + ); + } } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 39d721e207..76af853d82 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -844,6 +844,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -855,6 +856,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -865,6 +867,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1354,6 +1357,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1510,6 +1514,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1526,6 +1531,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1542,6 +1548,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1558,6 +1565,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1574,6 +1582,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1590,6 +1599,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1606,6 +1616,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1622,6 +1633,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1638,6 +1650,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1654,6 +1667,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1670,6 +1684,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1686,6 +1701,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1702,6 +1718,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1718,6 +1735,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1734,6 +1752,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2039,6 +2058,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6814,7 +6834,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7313,6 +7333,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7333,6 +7354,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7353,6 +7375,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7373,6 +7396,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7393,6 +7417,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7413,6 +7438,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7433,6 +7459,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7453,6 +7480,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7473,6 +7501,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7493,6 +7522,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7513,6 +7543,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12081,6 +12112,21 @@ } } }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12811,7 +12857,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/scripts/untar_ui_builder.js b/frontend/scripts/untar_ui_builder.js index 0991589025..7b7e9da46d 100644 --- a/frontend/scripts/untar_ui_builder.js +++ b/frontend/scripts/untar_ui_builder.js @@ -20,7 +20,7 @@ console.log('Running postinstall for root project'); import { x } from 'tar' -const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-0e5c66f.tar.gz' +const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-3df005f.tar.gz' const outputTarPath = path.join(process.cwd(), 'ui_builder.tar.gz') const extractTo = path.join(process.cwd(), 'static/ui_builder/') diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 42fab104c1..8d9fa070af 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -272,6 +272,38 @@ let suppressIframeSetFiles = false let suppressTimer: ReturnType | undefined + let sharedUiFiles: Record = $state({}) + let sharedUiVersion = $state(0) + let sharedUiLoaded = $state(false) + + async function loadSharedUi() { + if (!$workspaceStore) return + try { + const res = (await WorkspaceService.getSharedUi({ + workspace: $workspaceStore + })) as { files?: Record; version?: number } + sharedUiFiles = res.files ?? {} + sharedUiVersion = res.version ?? 0 + } catch (e) { + console.warn('Failed to load shared UI for raw app editor:', e) + sharedUiFiles = {} + } finally { + sharedUiLoaded = true + } + } + + function setSharedUiInIframe() { + const filesSnap = $state.snapshot(sharedUiFiles) + iframe?.contentWindow?.postMessage( + { + type: 'setSharedUi', + files: filesSnap, + version: sharedUiVersion + }, + '*' + ) + } + function populateFiles() { if (files) { suppressSetActiveDocument = true @@ -330,6 +362,7 @@ aiChatManager.saveAndClear() aiChatManager.changeMode(AIMode.APP) rawAppLintStore.enable() + loadSharedUi() // Initialize aiChatManager.datatableCreationPolicy from stored data aiChatManager.datatableCreationPolicy = { @@ -752,6 +785,16 @@ $effect(() => { iframe && iframeLoaded && runnables && populateRunnables() }) + $effect(() => { + // Re-push the shared UI whenever the iframe (re)loads or the + // fetched files change. We gate on `sharedUiLoaded` so the empty + // initial state isn't sent before the API call resolves. + if (iframe && iframeLoaded && sharedUiLoaded) { + // Touch the version so reassignments after a refresh re-fire this. + void sharedUiVersion + untrack(() => setSharedUiInIframe()) + } + }) function clearInspectorSelection() { inspectorElement = undefined diff --git a/frontend/src/lib/components/raw_apps/RawAppSharedUiDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppSharedUiDrawer.svelte new file mode 100644 index 0000000000..eedf10239f --- /dev/null +++ b/frontend/src/lib/components/raw_apps/RawAppSharedUiDrawer.svelte @@ -0,0 +1,124 @@ + + + + (open = false)} + noPadding + > +
+ Read-only view of the workspace's ui/ folder. Imports of + /ui/<path> are bundled in when you push this raw app. Edits happen via + wmill sync. + {#if version} + Version {version}{#if editedBy}, by {editedBy}{/if}. + {/if} +
+ + {#if loading} +
Loading…
+ {:else if sortedPaths.length === 0} +
+
No shared UI files yet
+
+ Create a ui/ folder at the root of your sync directory, add files, then run + wmill sync push. +
+
+ {:else} + + +
+ {#each sortedPaths as p (p)} + + {/each} +
+
+ + {#if selected} + {#key selected} + + {/key} + {:else} +
+ Select a file to view +
+ {/if} +
+
+ {/if} +
+
diff --git a/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte b/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte index 859e9e5386..5187dce1fe 100644 --- a/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte @@ -12,6 +12,7 @@ import RawAppDataTableList from './RawAppDataTableList.svelte' import type { DataTableRef } from './dataTableRefUtils' import RawAppDataTableDrawer from './RawAppDataTableDrawer.svelte' + import RawAppSharedUiDrawer from './RawAppSharedUiDrawer.svelte' interface Props { runnables: Record @@ -55,6 +56,7 @@ let dataTableDrawer: RawAppDataTableDrawer | undefined = $state() let selectedDataTableIndex: number | undefined = $state(undefined) + let sharedUiDrawer: RawAppSharedUiDrawer | undefined = $state() function handleAddDataTable(ref: DataTableRef) { onDataTableRefsChange?.([...dataTableRefs, ref]) @@ -107,6 +109,15 @@ + {/snippet} + {#if historyManager && onHistorySelect && onManualSnapshot}
diff --git a/frontend/src/lib/components/workspaceSettings/SharedUiSettings.svelte b/frontend/src/lib/components/workspaceSettings/SharedUiSettings.svelte new file mode 100644 index 0000000000..3ed865f33a --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/SharedUiSettings.svelte @@ -0,0 +1,182 @@ + + +
+

Shared UI folder

+

+ Workspace-shared frontend files (components, styles, helpers) that the raw app bundler merges + under the /ui/ path. Raw apps can import shared components with + {`import { Button } from '/ui/Button'`}. +

+

+ Editing happens through the Windmill CLI (wmill sync writes/reads the + ui/ folder at the root of your sync directory). This page lets you inspect or + replace the entire folder. Changes do not retroactively rebuild deployed raw apps + — re-push affected raw apps to pick up updates. +

+
+ +
+ + + {#if isAdmin} + + + {/if} +
+ +{#if listing} +
+ Version {listing.version}{#if listing.edited_by} + · Last edited by {listing.edited_by} + {/if} +
+ {#if listing.paths.length === 0} +
+ The shared UI folder is empty. Create a ui/ directory next to your + f/ and u/ folders, add files, then run wmill sync push. +
+ {:else} +
+ {#each listing.paths as p (p)} +
+ {p} + {humanSize(listing.sizes[p] ?? 0)} +
+ {/each} +
+ {/if} +{:else if loading} +
Loading…
+{/if} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 018cd74619..6b61b71242 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -53,6 +53,7 @@ import { base } from '$lib/base' import ConnectionSection from '$lib/components/ConnectionSection.svelte' import AISettings from '$lib/components/workspaceSettings/AISettings.svelte' + import SharedUiSettings from '$lib/components/workspaceSettings/SharedUiSettings.svelte' import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte' import VolumeStorageSettings from '$lib/components/workspaceSettings/VolumeStorageSettings.svelte' import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte' @@ -292,6 +293,7 @@ | 'encryption' | 'dependencies' | 'rulesets' + | 'shared_ui' // Both 'slack' and 'teams' URLs map to 'slack' tab if (selectedTab === 'teams') { return 'slack' @@ -1181,6 +1183,12 @@ aiDescription: 'Apps workspace settings', isEE: true }, + { + id: 'shared_ui', + label: 'Shared UI folder', + aiId: 'workspace-settings-shared-ui', + aiDescription: 'Shared frontend folder usable by raw apps' + }, { id: 'dependencies', label: 'Dependencies', @@ -1932,6 +1940,8 @@ export async function main( saveLabel="Save & Re-encrypt workspace" disabled={!!encryptionKeyValidationError || workspaceReencryptionInProgress} /> + {:else if tab == 'shared_ui'} + {:else if tab == 'trashbin'}