openpencil/crates/op-host-desktop/src/codegen_export.rs
Kayshen-X 88a571c028 refactor(host): rename op-web-daemon crate to op-host-services
The extracted headless crate is consumed by BOTH op-host-desktop (the GUI binary, for its
embedded --serve-web/MCP/chat/export) AND op-host-web-server — so 'web-daemon' was misleading.
Renamed crate dir + package + lib (op_web_daemon -> op_host_services) across all consumer files
(114 refs in 24 files) + the 4 Cargo.toml deps + Dockerfile/guard comments; identity docs
rewritten (it's the GUI-free host backend — daemon/MCP/AI/export/persistence — not web-specific).
No behavior change. (Lock renamed accordingly; the concurrent actor's op-host-web serde line excluded.)
2026-06-19 22:35:22 +08:00

297 lines
11 KiB
Rust

//! Desktop file export for the Code panel: Download (code file / zip) and
//! Export AI Bundle (structure-bundle zip). Drained each frame.
//!
//! Both actions are raised as `pending_*` flags on `editor_state.codegen`
//! (set in the native property dispatch); this pass clears the flag, pops a
//! native `rfd` save dialog, and writes the bytes — mirroring the Save / Open
//! flow in `persistence.rs`. The rfd dialogs are modal/blocking, which is
//! acceptable here (the same as `persistence::save_as_dialog`).
use std::io::Write;
use op_codegen::ai::types::AssetFile;
use op_host_native::WidgetHostNative;
use crate::codegen_session::CodegenResult;
/// Drain pending Download / Export-Bundle requests from the Code panel.
/// Returns true when either action ran (the cleared flag changed state, so
/// the caller repaints the button's reset state).
pub fn drain_codegen_file_actions(
host: &mut WidgetHostNative,
last_result: &Option<CodegenResult>,
) -> bool {
let mut ran = false;
if host.editor_state().codegen.pending_download {
host.editor_state_mut().codegen.pending_download = false;
ran = true;
handle_download(host, last_result);
}
if host.editor_state().codegen.pending_export_bundle {
host.editor_state_mut().codegen.pending_export_bundle = false;
ran = true;
handle_export_bundle(host);
}
ran
}
/// Save the generated code: a single file when there are no assets, or a
/// `.zip` (`component.<ext>` + `assets/<...>`) when image assets came back.
fn handle_download(host: &WidgetHostNative, last_result: &Option<CodegenResult>) {
let Some(result) = last_result else { return };
if result.code.is_empty() {
return;
}
if result.assets.is_empty() {
let Some(path) = rfd::FileDialog::new()
.set_file_name(format!("component.{}", result.framework_ext))
.add_filter("code", &[result.framework_ext.as_str()])
.save_file()
else {
return;
};
if let Err(e) = std::fs::write(&path, &result.code) {
show_save_error(host, Some(&path), &e.to_string());
}
} else {
let Some(path) = rfd::FileDialog::new()
.set_file_name("component.zip")
.add_filter("zip", &["zip"])
.save_file()
else {
return;
};
let bytes = build_code_zip(&result.code, &result.framework_ext, &result.assets);
if let Err(e) = std::fs::write(&path, bytes) {
show_save_error(host, Some(&path), &e.to_string());
}
}
}
/// Save the AI structure bundle (`manifest.json` + raw/sanitized views +
/// assets) as `bundle.zip`. The bundle is built FRESH from the live
/// selection (or the active page when nothing is selected) at click time —
/// TS parity (`handleDownloadStructureBundle` calls `getTargetNodes()`);
/// no completed generation is required and a stale generation-time
/// selection is never exported.
fn handle_export_bundle(host: &WidgetHostNative) {
let Some(bundle) = build_live_structure_bundle(host.editor_state()) else {
// Nothing to bundle (empty page / unresolvable selection) — the
// TS handler returns silently on `nodes.length === 0`.
return;
};
let Some(path) = rfd::FileDialog::new()
.set_file_name("bundle.zip")
.add_filter("zip", &["zip"])
.save_file()
else {
return;
};
let bytes = build_bundle_zip(&bundle.files);
if let Err(e) = std::fs::write(&path, bytes) {
show_save_error(host, Some(&path), &e.to_string());
}
}
/// Build the structure bundle from the LIVE editor state: the selection's
/// subtrees, else the active page's children (the same input-builder the
/// Generate launch uses), asset-sanitized here because the pipeline isn't
/// involved. `None` when there is nothing to bundle.
fn build_live_structure_bundle(
state: &op_editor_core::EditorState,
) -> Option<op_codegen::ai::bundle::StructureBundle> {
let (_input, raw) = crate::codegen_input::build_codegen_input(state)?;
let scope = if state.selection.is_empty() {
op_codegen::ai::bundle::BundleScope::Page
} else {
op_codegen::ai::bundle::BundleScope::Selection
};
let (sanitized, assets) = op_codegen::ai::assets::extract_codegen_assets(&raw);
Some(op_codegen::ai::bundle::build_structure_bundle(
&raw, &sanitized, &assets, scope,
))
}
/// Pop the shared native Save error dialog.
fn show_save_error(host: &WidgetHostNative, path: Option<&std::path::Path>, detail: &str) {
crate::persistence::show_error_dialog_public(
host,
op_host_services::doc_io::ErrorKind::Save,
path,
detail,
);
}
/// Build a Download zip carrying `component.<ext>` (the generated code) plus
/// one `assets/<...>` entry per asset. Stored (uncompressed) so the bytes are
/// byte-stable and fast to write. Asset paths reuse each asset's `zip_path`,
/// which already lives under `assets/`.
fn build_code_zip(code: &str, ext: &str, assets: &[AssetFile]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
// Best-effort: a zip write failure on an in-memory cursor is effectively
// impossible, so we ignore the Result rather than thread it back to the UI.
let _ = writer.start_file(format!("component.{ext}"), opts);
let _ = writer.write_all(code.as_bytes());
for asset in assets {
let _ = writer.start_file(asset.zip_path.clone(), opts);
let _ = writer.write_all(&asset.bytes);
}
writer
.finish()
.map(|cursor| cursor.into_inner())
.unwrap_or_default()
}
/// Build a zip from a structure bundle's `(in-zip path, bytes)` entries.
/// Stored (uncompressed).
fn build_bundle_zip(files: &[(String, Vec<u8>)]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (path, bytes) in files {
let _ = writer.start_file(path.clone(), opts);
let _ = writer.write_all(bytes);
}
writer
.finish()
.map(|cursor| cursor.into_inner())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn asset(zip_path: &str, bytes: &[u8]) -> AssetFile {
AssetFile {
id: "a".into(),
relative_path: format!("./{zip_path}"),
zip_path: zip_path.into(),
mime_type: "image/png".into(),
bytes: bytes.to_vec(),
source_node_id: "n1".into(),
}
}
fn entry_names(bytes: &[u8]) -> Vec<String> {
let archive = zip::ZipArchive::new(Cursor::new(bytes.to_vec())).expect("valid zip");
archive.file_names().map(|s| s.to_string()).collect()
}
#[test]
fn code_zip_without_assets_has_only_the_component() {
let bytes = build_code_zip("export default function X(){}", "tsx", &[]);
let names = entry_names(&bytes);
assert_eq!(names, vec!["component.tsx".to_string()]);
}
#[test]
fn code_zip_with_assets_includes_each_asset_path() {
let assets = vec![
asset("assets/img-1.png", &[1, 2, 3]),
asset("assets/img-2.png", &[4, 5, 6]),
];
let bytes = build_code_zip("code", "vue", &assets);
let names = entry_names(&bytes);
assert!(names.contains(&"component.vue".to_string()));
assert!(names.contains(&"assets/img-1.png".to_string()));
assert!(names.contains(&"assets/img-2.png".to_string()));
assert_eq!(names.len(), 3);
}
fn rect_node(id: &str) -> jian_ops_schema::node::PenNode {
use jian_ops_schema::node::{ContainerProps, PenNode, PenNodeBase, RectangleNode};
use jian_ops_schema::sizing::SizingBehavior;
PenNode::Rectangle(RectangleNode {
base: PenNodeBase {
id: id.to_string(),
name: Some(id.to_string()),
x: Some(0.0),
y: Some(0.0),
..Default::default()
},
container: ContainerProps {
width: Some(SizingBehavior::Number(10.0)),
height: Some(SizingBehavior::Number(10.0)),
..Default::default()
},
children: None,
state: None,
bindings: None,
events: None,
lifecycle: None,
semantics: None,
gestures: None,
route: None,
})
}
/// The bundle bytes for a named in-zip path, as UTF-8.
fn file_text(bundle: &op_codegen::ai::bundle::StructureBundle, path: &str) -> String {
let bytes = bundle
.files
.iter()
.find(|(p, _)| p == path)
.map(|(_, b)| b.clone())
.unwrap_or_else(|| panic!("bundle missing {path}"));
String::from_utf8(bytes).expect("utf8")
}
/// Export AI Bundle works PRE-generation, against the live selection —
/// no completed run is required (TS parity, gap #40).
#[test]
fn live_bundle_builds_from_selection_without_generation() {
let mut state = op_editor_core::EditorState::new();
state.doc.children = vec![rect_node("n1"), rect_node("n2")];
state.set_single_selection(op_editor_core::NodeId::new("n1"));
let bundle = super::build_live_structure_bundle(&state).expect("bundle");
let raw = file_text(&bundle, "views/raw.json");
assert!(raw.contains("n1"), "live selected node in the raw view");
assert!(!raw.contains("n2"), "unselected sibling stays out");
assert!(file_text(&bundle, "manifest.json").contains("\"scope\": \"selection\""));
}
/// With nothing selected the bundle covers the active page's children
/// (the same fallback the Generate input-builder uses) at page scope.
#[test]
fn live_bundle_falls_back_to_page_children_without_selection() {
let mut state = op_editor_core::EditorState::new();
state.doc.children = vec![rect_node("n1"), rect_node("n2")];
state.clear_selection();
let bundle = super::build_live_structure_bundle(&state).expect("bundle");
let raw = file_text(&bundle, "views/raw.json");
assert!(raw.contains("n1") && raw.contains("n2"));
assert!(file_text(&bundle, "manifest.json").contains("\"scope\": \"page\""));
}
/// An empty page with no selection has nothing to bundle (the TS
/// handler returns silently on zero nodes).
#[test]
fn live_bundle_is_none_on_empty_page() {
let state = op_editor_core::EditorState::new();
assert!(super::build_live_structure_bundle(&state).is_none());
}
#[test]
fn bundle_zip_contains_every_file_entry() {
let files = vec![
("manifest.json".to_string(), b"{}".to_vec()),
("views/raw.json".to_string(), b"[]".to_vec()),
("views/sanitized.json".to_string(), b"[]".to_vec()),
("assets/img-1.png".to_string(), vec![9, 9, 9]),
];
let bytes = build_bundle_zip(&files);
let names = entry_names(&bytes);
assert!(names.contains(&"manifest.json".to_string()));
assert!(names.contains(&"views/raw.json".to_string()));
assert!(names.contains(&"views/sanitized.json".to_string()));
assert!(names.contains(&"assets/img-1.png".to_string()));
assert_eq!(names.len(), 4);
}
}