mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
+16
@@ -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"
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
+22
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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..."));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+48
-2
@@ -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",
|
||||
|
||||
@@ -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/<path></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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user