From ceb7ea59cce636bbeede5552dbb83d641521b001 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 19 Jul 2026 20:45:03 +0800 Subject: [PATCH] feat(desktop): open and drop .html files via background import session --- crates/op-host-desktop/src/app_handler.rs | 40 +++- .../src/html_import_session.rs | 193 ++++++++++++++++++ crates/op-host-desktop/src/main.rs | 30 ++- crates/op-host-services/src/doc_io.rs | 19 ++ 4 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 crates/op-host-desktop/src/html_import_session.rs diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index 59b1e17a9..ebd4ba47c 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -3,8 +3,8 @@ use crate::{ a11y, chat_attachment, chat_session, codegen_session, cursor_icon, design_session, - figma_import_session, frame, git_jobs, menu, persistence, window_state, DesktopApp, - DesktopEvent, INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W, + figma_import_session, frame, git_jobs, html_import_session, menu, persistence, window_state, + DesktopApp, DesktopEvent, INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W, }; use op_host_native::{NativeBackend, ProviderError, SharedSkiaContext, SharedSkiaError}; use std::time::{Duration, Instant}; @@ -297,8 +297,14 @@ impl ApplicationHandler for DesktopApp { if let Some(path) = self.initial_file.take() { if op_host_services::doc_io::is_supported_figma_import(&path) { figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + html_import_session::cancel(&mut self.host, &mut self.current_html_import); self.current_figma_import = Some(figma_import_session::spawn(&mut self.host, path)); self.request_redraw(true); + } else if op_host_services::doc_io::is_supported_html_import(&path) { + figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + html_import_session::cancel(&mut self.host, &mut self.current_html_import); + self.current_html_import = Some(html_import_session::spawn(&mut self.host, path)); + self.request_redraw(true); } else if persistence::open_path( &mut self.host, path, @@ -514,9 +520,16 @@ impl ApplicationHandler for DesktopApp { // stray drop can't disrupt the current document. if op_host_services::doc_io::is_supported_figma_import(&path) { figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + html_import_session::cancel(&mut self.host, &mut self.current_html_import); self.current_figma_import = Some(figma_import_session::spawn(&mut self.host, path)); self.request_redraw(true); + } else if op_host_services::doc_io::is_supported_html_import(&path) { + figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + html_import_session::cancel(&mut self.host, &mut self.current_html_import); + self.current_html_import = + Some(html_import_session::spawn(&mut self.host, path)); + self.request_redraw(true); } else if op_host_services::doc_io::is_supported_document(&path) { if persistence::open_path( &mut self.host, @@ -529,7 +542,7 @@ impl ApplicationHandler for DesktopApp { } } else { eprintln!( - "openpencil-desktop: ignored dropped file (not .op / .pen / .fig): {}", + "openpencil-desktop: ignored dropped file (not .op / .pen / .fig / .html): {}", path.display() ); } @@ -702,6 +715,25 @@ impl ApplicationHandler for DesktopApp { figma_import_session::PumpOutcome::StillPending | figma_import_session::PumpOutcome::Idle => {} } + match html_import_session::pump( + &mut self.host, + &mut self.current_html_import, + &mut self.current_path, + self.window.as_ref(), + ) { + figma_import_session::PumpOutcome::CompletedOk => { + self.rebind_git_session_for_current_path(); + // Same fresh-EditorState reasoning as the Figma + // pump above: drop stale image-search state. + self.image_search.reset(); + self.redraw_dirty = true; + } + figma_import_session::PumpOutcome::CompletedErr => { + self.redraw_dirty = true; + } + figma_import_session::PumpOutcome::Idle + | figma_import_session::PumpOutcome::StillPending => {} + } // A failed subtask row's "Retry" click raised // `chat.pending_subtask_retry` — launch the single-subtask // worker (failed-subtask remediation, manual layer) before @@ -914,6 +946,7 @@ impl ApplicationHandler for DesktopApp { Instant::now() + Duration::from_millis(33), )); } else if self.current_figma_import.is_some() + || self.current_html_import.is_some() || self.pending_figma_paste.is_some() || self.pending_html_paste.is_some() { @@ -1551,6 +1584,7 @@ impl DesktopApp { || self.current_design_md.is_some() || !self.sub_agents.is_empty() || self.current_figma_import.is_some() + || self.current_html_import.is_some() || self.pending_figma_paste.is_some() || self.pending_html_paste.is_some() || self.host.next_animation_deadline_ms().is_some() diff --git a/crates/op-host-desktop/src/html_import_session.rs b/crates/op-host-desktop/src/html_import_session.rs new file mode 100644 index 000000000..d0e877fe2 --- /dev/null +++ b/crates/op-host-desktop/src/html_import_session.rs @@ -0,0 +1,193 @@ +//! Background `.html` import session — mirrors +//! `figma_import_session`: moves the op-html parse (CSS cascade + +//! node mapping + local resource embedding) off the main thread so +//! the editor UI keeps repainting while a page converts. +//! +//! Reuses `figma_import_session::{PreparedImport, PumpOutcome}` and +//! the same `figma_import_in_progress` overlay flag so the paint +//! side needs no new UI state. + +use op_editor_core::EditorState; +use op_host_native::WidgetHostNative; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, TryRecvError}; +use std::thread; + +use crate::figma_import_session::{PreparedImport, PumpOutcome}; +use crate::persistence::show_error_dialog_public; +use op_host_services::doc_io::ErrorKind; + +/// One in-flight `.html` parse — the source path (for the error +/// dialog) plus the worker-thread receiver. +pub struct HtmlImportSession { + path: PathBuf, + rx: Receiver>, +} + +/// Spawn a worker thread that reads `path`, converts it with +/// `op_html::import_html_document` (resolving same-directory +/// relative resources from disk), and posts the result back through +/// a channel. Returns the session handle. +pub fn spawn(host: &mut WidgetHostNative, path: PathBuf) -> HtmlImportSession { + let (tx, rx) = mpsc::channel(); + // Same overlay flag + same "no dirty-mark" rationale as the + // Figma session: the import replaces `editor_state` whole-cloth, + // so rebuilding the old layout would be wasted work. + host.editor_state_mut().editor_ui.figma_import_in_progress = true; + + let path_for_thread = path.clone(); + thread::Builder::new() + .name("op-html-import".into()) + .spawn(move || { + let result = parse_path(&path_for_thread); + let _ = tx.send(result); + }) + .expect("spawn op-html-import worker"); + + HtmlImportSession { path, rx } +} + +fn parse_path(path: &Path) -> Result { + let source = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + let file_name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("HTML Import"); + let base_dir = path.parent().map(Path::to_path_buf).unwrap_or_default(); + // Same-directory relative resources (stylesheets / images) load + // from disk; remote URLs stay unfetched (the importer records a + // warning) — file import never touches the network. + let fetcher = move |href: &str| local_resource_fetch(&base_dir, href); + // Down-scale embedded bitmaps exactly like the Figma import path. + let transform = + |bytes: &[u8]| crate::image_downscale::maybe_downscale(bytes).map(|(_mime, out)| out); + let opts = op_html::HtmlImportOptions { + document_name: Some(file_name.to_string()), + ..Default::default() + }; + let result = op_html::import_html_document(&source, &opts, Some(&fetcher), Some(&transform)); + if result.document.children.is_empty() { + return Err(result + .warnings + .first() + .cloned() + .unwrap_or_else(|| "no importable content".to_string())); + } + let state = EditorState::from_document(result.document); + Ok(PreparedImport { + state, + warnings: result.warnings, + }) +} + +/// Resolve a relative resource reference against the HTML file's +/// directory. Refuses anything that is not a plain same-tree +/// relative path: remote URLs, `data:`, absolute paths, and `..` +/// escapes all return `None` (the importer degrades with a warning). +pub(crate) fn local_resource_fetch(dir: &Path, href: &str) -> Option> { + if href.starts_with("http://") + || href.starts_with("https://") + || href.starts_with("//") + || href.starts_with("data:") + || Path::new(href).is_absolute() + { + return None; + } + let candidate = dir.join(href); + let resolved = candidate.canonicalize().ok()?; + let dir_resolved = dir.canonicalize().ok()?; + if !resolved.starts_with(&dir_resolved) { + return None; + } + std::fs::read(resolved).ok() +} + +/// Non-blocking drain — same contract as +/// `figma_import_session::pump`. +pub fn pump( + host: &mut WidgetHostNative, + session: &mut Option, + current_path: &mut Option, + window: Option<&winit::window::Window>, +) -> PumpOutcome { + let Some(sess) = session.as_mut() else { + return PumpOutcome::Idle; + }; + match sess.rx.try_recv() { + Ok(Ok(prepared)) => { + for warning in &prepared.warnings { + eprintln!("[import-html] warning: {warning}"); + } + host.install_imported_state(prepared.state); + // Imported docs have no `.op` path; next Save routes via + // Save As — matches the Figma import behaviour. + *current_path = None; + refresh_title(window); + *session = None; + PumpOutcome::CompletedOk + } + Ok(Err(e)) => { + eprintln!("[import-html] {e}"); + show_error_dialog_public(host, ErrorKind::Open, Some(&sess.path), &e); + host.editor_state_mut().editor_ui.figma_import_in_progress = false; + host.mark_editor_state_dirty(); + *session = None; + PumpOutcome::CompletedErr + } + Err(TryRecvError::Empty) => PumpOutcome::StillPending, + Err(TryRecvError::Disconnected) => { + eprintln!("[import-html] worker thread terminated without sending a result"); + let detail = "HTML import worker exited unexpectedly"; + show_error_dialog_public(host, ErrorKind::Open, Some(&sess.path), detail); + host.editor_state_mut().editor_ui.figma_import_in_progress = false; + host.mark_editor_state_dirty(); + *session = None; + PumpOutcome::CompletedErr + } + } +} + +/// Drop the active session (if any) and clear the in-progress flag — +/// called when another document-replacing action starts while an +/// HTML import is still parsing. Mirrors +/// `figma_import_session::cancel`. +pub fn cancel(host: &mut WidgetHostNative, session: &mut Option) { + if session.is_some() { + eprintln!("[import-html] cancelling in-flight session — superseded"); + *session = None; + if host.editor_state().editor_ui.figma_import_in_progress { + host.editor_state_mut().editor_ui.figma_import_in_progress = false; + host.mark_editor_state_dirty(); + } + } +} + +fn refresh_title(window: Option<&winit::window::Window>) { + let Some(window) = window else { return }; + window.set_title("OpenPencil"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_fetch_confines_to_directory() { + let dir = std::env::temp_dir().join("op_html_fetch_test"); + let _ = std::fs::create_dir_all(dir.join("sub")); + std::fs::write(dir.join("a.css"), b"x").unwrap(); + std::fs::write(dir.join("sub").join("b.css"), b"y").unwrap(); + assert_eq!( + local_resource_fetch(&dir, "a.css").as_deref(), + Some(b"x".as_ref()) + ); + assert_eq!( + local_resource_fetch(&dir, "sub/b.css").as_deref(), + Some(b"y".as_ref()) + ); + assert!(local_resource_fetch(&dir, "../outside.css").is_none()); + assert!(local_resource_fetch(&dir, "/etc/hosts").is_none()); + assert!(local_resource_fetch(&dir, "https://a.dev/x.css").is_none()); + assert!(local_resource_fetch(&dir, "data:text/css,x").is_none()); + } +} diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index 423bda76b..ec2e48bd1 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -37,6 +37,7 @@ mod git_jobs; mod git_overflow_host; mod git_session; mod git_ssh_host; +mod html_import_session; mod iconify_host; mod image_downscale; mod image_generate_host; @@ -198,6 +199,9 @@ struct DesktopApp { /// in `RedrawRequested` swaps in the parsed document when the /// worker finishes. current_figma_import: Option, + /// In-flight `.html` import — same worker/pump lifecycle as the + /// Figma session above. + current_html_import: Option, /// In-flight Figma CLIPBOARD paste decode (Cmd+V) — worker sends /// the parsed nodes; the redraw path pumps + inserts them. pending_figma_paste: Option>>, @@ -413,6 +417,7 @@ impl DesktopApp { #[cfg(test)] design_md_test_provider: None, current_figma_import: None, + current_html_import: None, pending_figma_paste: None, pending_html_paste: None, model_probe, @@ -480,6 +485,7 @@ impl DesktopApp { // drop the session here so the worker's `send` becomes a // silent no-op when it finishes. figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + html_import_session::cancel(&mut self.host, &mut self.current_html_import); self.image_search.reset(); self.saved_doc_fingerprint = op_host_services::doc_io::document_fingerprint(self.host.editor_state()); @@ -590,7 +596,8 @@ impl DesktopApp { for path in winit::platform::macos::drain_opened_file_urls() { let is_op = op_host_services::doc_io::is_supported_document(&path); let is_fig = op_host_services::doc_io::is_supported_figma_import(&path); - if !is_op && !is_fig { + let is_html = op_host_services::doc_io::is_supported_html_import(&path); + if !is_op && !is_fig && !is_html { continue; } if is_fig @@ -617,10 +624,18 @@ impl DesktopApp { // still pending; pump applies it when the worker // finishes). figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + html_import_session::cancel(&mut self.host, &mut self.current_html_import); self.current_figma_import = Some(figma_import_session::spawn(&mut self.host, path)); self.request_redraw(true); opened = true; + } else if is_html { + figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + html_import_session::cancel(&mut self.host, &mut self.current_html_import); + self.current_html_import = + Some(html_import_session::spawn(&mut self.host, path)); + self.request_redraw(true); + opened = true; } else if persistence::open_path( &mut self.host, path, @@ -652,7 +667,8 @@ impl DesktopApp { for path in paths { let is_op = op_host_services::doc_io::is_supported_document(&path); let is_fig = op_host_services::doc_io::is_supported_figma_import(&path); - if (!is_op && !is_fig) || !path.is_file() { + let is_html = op_host_services::doc_io::is_supported_html_import(&path); + if (!is_op && !is_fig && !is_html) || !path.is_file() { continue; } // Single-window editor: the first forwarded document wins, the @@ -662,9 +678,16 @@ impl DesktopApp { } if is_fig { figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + html_import_session::cancel(&mut self.host, &mut self.current_html_import); self.current_figma_import = Some(figma_import_session::spawn(&mut self.host, path)); self.request_redraw(true); opened = true; + } else if is_html { + figma_import_session::cancel(&mut self.host, &mut self.current_figma_import); + html_import_session::cancel(&mut self.host, &mut self.current_html_import); + self.current_html_import = Some(html_import_session::spawn(&mut self.host, path)); + self.request_redraw(true); + opened = true; } else if persistence::open_path( &mut self.host, path, @@ -962,7 +985,8 @@ impl DesktopApp { fn initial_file_from_argv() -> Option { std::env::args_os().skip(1).map(PathBuf::from).find(|p| { (op_host_services::doc_io::is_supported_document(p) - || op_host_services::doc_io::is_supported_figma_import(p)) + || op_host_services::doc_io::is_supported_figma_import(p) + || op_host_services::doc_io::is_supported_html_import(p)) && p.is_file() }) } diff --git a/crates/op-host-services/src/doc_io.rs b/crates/op-host-services/src/doc_io.rs index 0ebd5e68e..8f4feef68 100644 --- a/crates/op-host-services/src/doc_io.rs +++ b/crates/op-host-services/src/doc_io.rs @@ -327,6 +327,16 @@ pub fn is_supported_figma_import(path: &std::path::Path) -> bool { .is_some_and(|ext| ext.eq_ignore_ascii_case("fig")) } +/// True for HTML pages (`.html` / `.htm`) — routed through the +/// desktop `html_import_session` (op-html structured import) rather +/// than the `.op`-only `open_path`. Case-insensitive like the other +/// extension filters. +pub fn is_supported_html_import(path: &std::path::Path) -> bool { + path.extension() + .and_then(|s| s.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm")) +} + /// Outcome of the desktop residual's `run_action` — tells the desktop /// runner which post-action bookkeeping to run. Lives here (not on the /// desktop side) so the headless daemon can name it too. @@ -372,6 +382,15 @@ pub enum ErrorKind { #[cfg(test)] mod tests { + #[test] + fn html_import_extensions() { + use std::path::Path; + assert!(is_supported_html_import(Path::new("a.html"))); + assert!(is_supported_html_import(Path::new("A.HTM"))); + assert!(!is_supported_html_import(Path::new("a.svg"))); + assert!(!is_supported_html_import(Path::new("html"))); + } + use super::*; /// A unique temp path under the OS temp dir for a round-trip test.