feat: workspace-shared ui/ folder reusable across raw apps (#8974)

* feat: add workspace-shared ui/ folder reusable across raw apps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add shared ui/ drawer in raw app editor sidebar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: forward workspace shared ui/ to raw app editor iframe

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* all

* all

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-29 18:54:55 +00:00
committed by GitHub
parent 5861dcad58
commit de0b6b1528
20 changed files with 1041 additions and 13 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE IF EXISTS workspace_shared_ui;
@@ -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;
+125
View File
@@ -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
+2
View File
@@ -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())
@@ -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<String, String>,
pub version: i64,
pub edited_at: chrono::DateTime<chrono::Utc>,
pub edited_by: String,
}
#[derive(Serialize)]
pub struct SharedUiListing {
pub paths: Vec<String>,
pub sizes: HashMap<String, i64>,
pub version: i64,
pub edited_at: chrono::DateTime<chrono::Utc>,
pub edited_by: String,
}
#[derive(Serialize)]
pub struct SharedUiVersion {
pub version: i64,
}
#[derive(Deserialize)]
pub struct UpdateSharedUi {
pub files: HashMap<String, String>,
}
async fn get_shared_ui(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<SharedUi> {
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<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<SharedUiListing> {
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<String, String> =
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<String> = files.keys().cloned().collect();
paths.sort();
let sizes: HashMap<String, i64> = 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<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<SharedUiVersion> {
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<DB>,
Path(w_id): Path<String>,
Json(payload): Json<UpdateSharedUi>,
) -> Result<String> {
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))
}
+46 -1
View File
@@ -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/<file> 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..."));
+2
View File
@@ -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
+122
View File
@@ -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<string, string> = {},
): Promise<Record<string, string>> {
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 <cwd>/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<boolean> {
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<string, string> = {};
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 <cwd>/ui/.
* Files removed remotely are also removed locally.
*/
export async function pullSharedUi(workspace: string): Promise<boolean> {
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;
}
+32 -8
View File
@@ -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,
),
);
}
}
}
+48 -2
View File
@@ -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",
+1 -1
View File
@@ -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/')
@@ -272,6 +272,38 @@
let suppressIframeSetFiles = false
let suppressTimer: ReturnType<typeof setTimeout> | undefined
let sharedUiFiles: Record<string, string> = $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<string, string>; 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
@@ -0,0 +1,124 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Drawer from '../common/drawer/Drawer.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import Editor from '$lib/components/Editor.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
let open = $state(false)
let files: Record<string, string> = $state({})
let version: number = $state(0)
let editedBy: string = $state('')
let selected: string | undefined = $state(undefined)
let loading = $state(false)
export async function openDrawer() {
open = true
await load()
}
async function load() {
if (!$workspaceStore) return
loading = true
try {
const res = (await WorkspaceService.getSharedUi({ workspace: $workspaceStore })) as any
files = res.files ?? {}
version = res.version ?? 0
editedBy = res.edited_by ?? ''
const keys = Object.keys(files).sort()
if (selected === undefined || !(selected in files)) {
selected = keys[0]
}
} catch (e) {
sendUserToast(`Failed to load shared UI: ${e}`, true)
} finally {
loading = false
}
}
const sortedPaths = $derived(Object.keys(files).sort())
function langFromPath(p: string): any {
const ext = (p.split('.').pop() ?? '').toLowerCase()
if (ext === 'ts') return 'bun'
if (ext === 'tsx') return 'tsx'
if (ext === 'js') return 'bun'
if (ext === 'jsx') return 'jsx'
if (ext === 'css') return undefined
if (ext === 'json') return 'json'
if (ext === 'html') return undefined
if (ext === 'md') return undefined
return undefined
}
</script>
<Drawer bind:open size="900px">
<DrawerContent
title="Shared UI folder ({sortedPaths.length} file{sortedPaths.length === 1 ? '' : 's'})"
on:close={() => (open = false)}
noPadding
>
<div class="px-3 py-2 text-xs text-tertiary border-b">
Read-only view of the workspace's <code>ui/</code> folder. Imports of
<code>/ui/&lt;path&gt;</code> are bundled in when you push this raw app. Edits happen via
<code>wmill sync</code>.
{#if version}
Version {version}{#if editedBy}, by <code>{editedBy}</code>{/if}.
{/if}
</div>
{#if loading}
<div class="flex items-center justify-center h-full text-tertiary">Loading…</div>
{:else if sortedPaths.length === 0}
<div class="flex flex-col items-center justify-center h-full p-6 text-center text-tertiary">
<div class="font-semibold mb-1">No shared UI files yet</div>
<div class="text-sm">
Create a <code>ui/</code> folder at the root of your sync directory, add files, then run
<code>wmill sync push</code>.
</div>
</div>
{:else}
<Splitpanes class="!h-full">
<Pane size={28} minSize={15}>
<div class="overflow-auto h-full divide-y border-r">
{#each sortedPaths as p (p)}
<button
class="w-full text-left px-3 py-2 text-sm font-mono hover:bg-surface-hover {selected ===
p
? 'bg-surface-selected'
: ''}"
onclick={() => (selected = p)}
>
<div class="truncate">{p}</div>
<div class="text-2xs text-tertiary">
{(files[p]?.length ?? 0).toLocaleString()} chars
</div>
</button>
{/each}
</div>
</Pane>
<Pane size={72}>
{#if selected}
{#key selected}
<Editor
class="flex flex-1 grow h-full"
path={`/ui/${selected}`}
code={files[selected] ?? ''}
scriptLang={langFromPath(selected)}
disabled={true}
automaticLayout
fixedOverflowWidgets
/>
{/key}
{:else}
<div class="flex items-center justify-center h-full text-tertiary">
Select a file to view
</div>
{/if}
</Pane>
</Splitpanes>
{/if}
</DrawerContent>
</Drawer>
@@ -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<string, Runnable>
@@ -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 @@
<Plus size={12} />
<Folder size={12} />
</Button>
<Button
onClick={() => sharedUiDrawer?.openDrawer()}
title="Browse the workspace shared ui/ folder"
unifiedSize="xs"
variant="subtle"
btnClasses="px-1 gap-0.5 text-xs"
>
ui/
</Button>
</div>
{/snippet}
<FileExplorer
@@ -146,6 +157,7 @@
onAdd={handleAddDataTable}
existingRefs={dataTableRefs}
/>
<RawAppSharedUiDrawer bind:this={sharedUiDrawer} />
{#if historyManager && onHistorySelect && onManualSnapshot}
<div class="py-4"></div>
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte'
import JSZip from 'jszip'
import Button from '../common/button/Button.svelte'
import { workspaceStore, userStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { WorkspaceService } from '$lib/gen'
import { Download, Upload, RefreshCw } from 'lucide-svelte'
type Listing = {
paths: string[]
sizes: Record<string, number>
version: number
edited_at: string
edited_by: string
}
let listing: Listing | undefined = $state(undefined)
let loading: boolean = $state(false)
let uploading: boolean = $state(false)
let fileInput: HTMLInputElement | undefined = $state(undefined)
const isAdmin = $derived(!!$userStore?.is_admin)
async function loadListing() {
if (!$workspaceStore) return
loading = true
try {
listing = (await WorkspaceService.listSharedUi({
workspace: $workspaceStore
})) as Listing
} catch (e) {
sendUserToast(`Failed to load shared UI: ${e}`, true)
} finally {
loading = false
}
}
function humanSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
async function downloadZip() {
if (!$workspaceStore) return
try {
const got = await WorkspaceService.getSharedUi({ workspace: $workspaceStore })
const files = (got as any).files ?? {}
const zip = new JSZip()
for (const [path, content] of Object.entries(files)) {
zip.file(path, content as string)
}
const blob = await zip.generateAsync({ type: 'blob' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${$workspaceStore}-ui.zip`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
sendUserToast(`Failed to download shared UI: ${e}`, true)
}
}
async function onFileSelected(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file || !$workspaceStore) return
uploading = true
try {
const zip = await JSZip.loadAsync(await file.arrayBuffer())
const files: Record<string, string> = {}
const tasks: Promise<void>[] = []
zip.forEach((relativePath, entry) => {
if (entry.dir) return
tasks.push(
entry.async('string').then((content) => {
files[relativePath] = content
})
)
})
await Promise.all(tasks)
await WorkspaceService.updateSharedUi({
workspace: $workspaceStore,
requestBody: { files }
})
sendUserToast(`Replaced shared UI folder with ${Object.keys(files).length} file(s)`)
await loadListing()
} catch (e) {
sendUserToast(`Failed to replace shared UI: ${e}`, true)
} finally {
uploading = false
if (fileInput) {
fileInput.value = ''
}
}
}
onMount(() => {
loadListing()
})
</script>
<div class="mb-6">
<h2 class="text-lg font-semibold mb-1">Shared UI folder</h2>
<p class="text-sm text-secondary">
Workspace-shared frontend files (components, styles, helpers) that the raw app bundler merges
under the <code>/ui/</code> path. Raw apps can import shared components with
<code>{`import { Button } from '/ui/Button'`}</code>.
</p>
<p class="text-sm text-secondary mt-2">
Editing happens through the Windmill CLI (<code>wmill sync</code> writes/reads the
<code>ui/</code> folder at the root of your sync directory). This page lets you inspect or
replace the entire folder. Changes do <strong>not</strong> retroactively rebuild deployed raw apps
— re-push affected raw apps to pick up updates.
</p>
</div>
<div class="flex items-center gap-2 mb-4">
<Button
size="xs"
variant="default"
startIcon={{ icon: RefreshCw }}
on:click={loadListing}
disabled={loading}
>
Refresh
</Button>
<Button
size="xs"
variant="default"
startIcon={{ icon: Download }}
on:click={downloadZip}
disabled={!listing || listing.paths.length === 0}
>
Download as zip
</Button>
{#if isAdmin}
<Button
size="xs"
variant="default"
startIcon={{ icon: Upload }}
on:click={() => fileInput?.click()}
disabled={uploading}
>
{uploading ? 'Replacing…' : 'Replace from zip'}
</Button>
<input
bind:this={fileInput}
type="file"
accept=".zip"
style="display: none;"
onchange={onFileSelected}
/>
{/if}
</div>
{#if listing}
<div class="text-xs text-secondary mb-2">
Version {listing.version}{#if listing.edited_by}
· Last edited by <code>{listing.edited_by}</code>
{/if}
</div>
{#if listing.paths.length === 0}
<div class="rounded border border-dashed p-6 text-center text-sm text-secondary">
The shared UI folder is empty. Create a <code>ui/</code> directory next to your
<code>f/</code> and <code>u/</code> folders, add files, then run <code>wmill sync push</code>.
</div>
{:else}
<div class="rounded border divide-y">
{#each listing.paths as p (p)}
<div class="flex items-center justify-between px-3 py-2 text-sm font-mono">
<span class="truncate">{p}</span>
<span class="text-xs text-secondary">{humanSize(listing.sizes[p] ?? 0)}</span>
</div>
{/each}
</div>
{/if}
{:else if loading}
<div class="text-sm text-secondary">Loading…</div>
{/if}
@@ -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'}
<SharedUiSettings />
{:else if tab == 'trashbin'}
<SettingsPageHeader
title="Trashbin"
+1 -1
View File
@@ -7,7 +7,7 @@ else
IS_MAC=false
fi
cd ../../windmill-code-ui-builder
cd ~/windmill-code-ui-builder
HASH=$(git rev-parse --short HEAD)
HASH=${HASH::-1}