feat(desktop): open and drop .html files via background import session
This commit is contained in:
parent
35f12652cd
commit
ceb7ea59cc
|
|
@ -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()
|
||||
|
|
|
|||
193
crates/op-host-desktop/src/html_import_session.rs
Normal file
193
crates/op-host-desktop/src/html_import_session.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue