feat(desktop): open and drop .html files via background import session

This commit is contained in:
Kayshen-X 2026-07-19 20:45:03 +08:00
parent 35f12652cd
commit ceb7ea59cc
4 changed files with 276 additions and 6 deletions

View file

@ -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<DesktopEvent> 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<DesktopEvent> 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<DesktopEvent> 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<DesktopEvent> 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<DesktopEvent> 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()

View file

@ -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<Result<PreparedImport, String>>,
}
/// 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<PreparedImport, String> {
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<Vec<u8>> {
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<HtmlImportSession>,
current_path: &mut Option<PathBuf>,
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<HtmlImportSession>) {
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());
}
}

View file

@ -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<figma_import_session::FigmaImportSession>,
/// In-flight `.html` import — same worker/pump lifecycle as the
/// Figma session above.
current_html_import: Option<html_import_session::HtmlImportSession>,
/// In-flight Figma CLIPBOARD paste decode (Cmd+V) — worker sends
/// the parsed nodes; the redraw path pumps + inserts them.
pending_figma_paste: Option<std::sync::mpsc::Receiver<Vec<jian_ops_schema::node::PenNode>>>,
@ -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<PathBuf> {
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()
})
}

View file

@ -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.