From a550af4e7f94bb437d5b210a291f12218601413c Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 22 May 2026 22:55:47 +0800 Subject: [PATCH] feat(figma): wire .fig binary import into the desktop host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add op-figma as a dependency and implement FileAction::ImportFigma — an rfd .fig picker parses the binary file via parse_fig_binary and re-seeds EditorState. run_action now returns a 3-state ActionOutcome instead of a bool: an import returns PathChangedUnsaved so it is treated as unsaved work (close still prompts) while the Git session is rebound to the now-pathless document. mark_document_saved is split so the rebind can run without refreshing the dirty baseline. --- Cargo.lock | 1 + crates/op-host-desktop/Cargo.toml | 3 + crates/op-host-desktop/src/app_handler.rs | 23 +++-- crates/op-host-desktop/src/main.rs | 22 ++-- crates/op-host-desktop/src/persistence.rs | 118 ++++++++++++++++++---- 5 files changed, 137 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5fec40a1e..b478ea0fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2619,6 +2619,7 @@ dependencies = [ "op-ai-skills", "op-editor-core", "op-editor-ui", + "op-figma", "op-git", "op-host-native", "op-i18n", diff --git a/crates/op-host-desktop/Cargo.toml b/crates/op-host-desktop/Cargo.toml index 7ba5217ce..f86507ad1 100644 --- a/crates/op-host-desktop/Cargo.toml +++ b/crates/op-host-desktop/Cargo.toml @@ -72,6 +72,9 @@ op-acp = { path = "../op-acp" } # `persistence.rs` keeps only the desktop-only `rfd` Save/Open + error # dialogs. op-pen-loader = { path = "../op-pen-loader" } +# Binary `.fig` importer — `persistence.rs` runs `parse_fig_binary` +# to turn a Figma file into a canonical `PenDocument`. +op-figma = { path = "../op-figma" } # In-app Git — `git_session.rs` binds an `op_git::GitRepo` to the # currently-open document so the Git panel can show branch / status # / history and drive commits. diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index 0f921ca25..4dc145de3 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -585,13 +585,22 @@ impl ApplicationHandler for DesktopApp { eui.export_dialog_open = true; self.host.mark_editor_state_dirty(); self.request_redraw(true); - } else if persistence::run_action( - action, - &mut self.host, - &mut self.current_path, - self.window.as_ref(), - ) { - self.mark_document_saved(); + } else { + match persistence::run_action( + action, + &mut self.host, + &mut self.current_path, + self.window.as_ref(), + ) { + persistence::ActionOutcome::Saved => self.mark_document_saved(), + // A Figma import changed the document path + // but left unsaved work — rebind Git only, + // keep the dirty baseline so close prompts. + persistence::ActionOutcome::PathChangedUnsaved => { + self.rebind_git_session_for_current_path() + } + persistence::ActionOutcome::Noop => {} + } } } if consumed { diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index b37c2237b..3aa03eae2 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -187,13 +187,22 @@ impl DesktopApp { /// Snapshot the current document as the saved baseline — called /// after every successful load / save / new so `document_is_dirty` - /// only reports edits made *since* that point. - /// - /// The document path may also have changed (New / Open / Save-As), - /// so this rebinds the Git session to the document's repository - /// and retitles the window with the active branch. + /// only reports edits made *since* that point. Also rebinds the + /// Git session (the document path may have changed). fn mark_document_saved(&mut self) { self.saved_doc_fingerprint = persistence::document_fingerprint(self.host.editor_state()); + self.rebind_git_session_for_current_path(); + } + + /// Rebind the Git session to `current_path`, retitle the window + /// and refresh an open Git panel — WITHOUT touching the + /// unsaved-changes baseline. `mark_document_saved` calls this + /// after a real save; a Figma import calls it directly: the + /// import changed the document path (so the old repo binding is + /// stale) but the imported design is unsaved work, so + /// `saved_doc_fingerprint` must stay put or close would skip the + /// save prompt. + fn rebind_git_session_for_current_path(&mut self) { let prev_repo = self.git_session.repo().map(|r| r.workdir().to_path_buf()); let prev_tracked = self.git_session.tracked_file().map(|p| p.to_path_buf()); self.git_session.rebind(self.current_path.as_deref()); @@ -517,7 +526,8 @@ impl DesktopApp { &mut self.host, &mut self.current_path, self.window.as_ref(), - ) { + ) == persistence::ActionOutcome::Saved + { self.mark_document_saved(); } true diff --git a/crates/op-host-desktop/src/persistence.rs b/crates/op-host-desktop/src/persistence.rs index ca2287515..97c230080 100644 --- a/crates/op-host-desktop/src/persistence.rs +++ b/crates/op-host-desktop/src/persistence.rs @@ -368,17 +368,48 @@ pub fn open_path( } } +/// Outcome of [`run_action`] — tells the desktop runner which +/// post-action bookkeeping to run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionOutcome { + /// The document now matches a file on disk (New / successful + /// Open / Save / Save-As / Open-Recent). The runner refreshes the + /// unsaved-changes baseline AND rebinds the Git session. + Saved, + /// The document's content + path changed but it does NOT match + /// any file on disk — a Figma import. The runner rebinds the Git + /// session + window title (the previously-open document's repo + /// binding is now stale) but must NOT refresh the unsaved-changes + /// baseline: the imported design is unsaved work and close must + /// still prompt. + PathChangedUnsaved, + /// Nothing to reconcile — export, recent-list edits, or a user + /// cancel / error. + Noop, +} + +impl ActionOutcome { + /// Map a save/open helper's `bool` (`true` = the document now + /// matches a file on disk) onto an outcome. + fn saved_or_noop(saved: bool) -> Self { + if saved { + ActionOutcome::Saved + } else { + ActionOutcome::Noop + } + } +} + /// Route a `FileAction` raised by the file-menu dispatcher to the -/// matching dialog flow. Returns `true` when the action left the -/// document matching disk — a New / successful Open / successful -/// Save — so the runner can refresh its unsaved-changes baseline; -/// export / import / recent-list actions return `false`. +/// matching dialog flow. The returned [`ActionOutcome`] tells the +/// runner which post-action bookkeeping to run — see its variant +/// docs. pub fn run_action( action: op_editor_core::editor_ui_state::FileAction, host: &mut WidgetHostNative, current_path: &mut Option, window: Option<&winit::window::Window>, -) -> bool { +) -> ActionOutcome { use op_editor_core::editor_ui_state::FileAction; match action { FileAction::New => { @@ -387,17 +418,19 @@ pub fn run_action( host.mark_editor_state_dirty(); *current_path = None; refresh_title(current_path, window); - true + ActionOutcome::Saved + } + FileAction::Open => ActionOutcome::saved_or_noop(handle_open(host, current_path, window)), + FileAction::Save => ActionOutcome::saved_or_noop(handle_save(host, current_path, window)), + FileAction::SaveAs => { + ActionOutcome::saved_or_noop(handle_save_as(host, current_path, window)) } - FileAction::Open => handle_open(host, current_path, window), - FileAction::Save => handle_save(host, current_path, window), - FileAction::SaveAs => handle_save_as(host, current_path, window), FileAction::ExportImage => { // main.rs intercepts ExportImage to open the picker; this // fallback keeps external callers working. host.editor_state_mut().editor_ui.export_dialog_open = true; host.mark_editor_state_dirty(); - false + ActionOutcome::Noop } FileAction::ExportImageConfirm => { use op_editor_core::editor_ui_state::ExportFormat as Fmt; @@ -455,11 +488,11 @@ pub fn run_action( show_error_dialog(host, ErrorKind::Export, Some(&path), &e); } } - false + ActionOutcome::Noop } FileAction::OpenRecent(i) => { let Some(entry) = host.editor_state().editor_ui.recent_files.get(i).cloned() else { - return false; + return ActionOutcome::Noop; }; let path = std::path::PathBuf::from(&entry.path); match load_into_host(host, &path) { @@ -467,7 +500,7 @@ pub fn run_action( crate::settings_io::touch_recent(host, &path); *current_path = Some(path); refresh_title(current_path, window); - true + ActionOutcome::Saved } Err(e) => { // File missing / parse failure → tell the user and @@ -479,22 +512,73 @@ pub fn run_action( .recent_files .retain(|r| r.path != entry.path); host.mark_editor_state_dirty(); - false + ActionOutcome::Noop } } } FileAction::ClearRecent => { host.editor_state_mut().editor_ui.recent_files.clear(); host.mark_editor_state_dirty(); - false + ActionOutcome::Noop } FileAction::ImportFigma => { - eprintln!("[file-action] {action:?} — not yet wired (UI only)"); - false + let path = match rfd::FileDialog::new() + .set_title(op_i18n::translate( + host.editor_state().editor_ui.locale, + "dialog.pickerOpenTitle", + )) + .add_filter("Figma", &["fig"]) + .pick_file() + { + Some(p) => p, + None => return ActionOutcome::Noop, + }; + match import_figma_into_host(host, &path) { + Ok(()) => { + // An imported `.fig` has no `.op` path of its own — + // the next Save routes through Save As. + *current_path = None; + refresh_title(current_path, window); + // `PathChangedUnsaved`, not `Saved`: an import does + // NOT leave the document matching disk. Reporting + // `Saved` would refresh the unsaved-changes + // baseline, so closing the app would silently + // discard the imported design with no save prompt. + // The runner still rebinds the Git session (the + // previously-open document's repo is now stale). + ActionOutcome::PathChangedUnsaved + } + Err(e) => { + eprintln!("[import-figma] {e}"); + show_error_dialog(host, ErrorKind::Open, Some(&path), &e); + ActionOutcome::Noop + } + } } } } +/// Read + parse a binary `.fig` file and swap the host's document +/// for the imported one. The heavy lifting lives in `op_figma`. +fn import_figma_into_host( + host: &mut WidgetHostNative, + path: &std::path::Path, +) -> Result<(), String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let file_name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Figma Import"); + let import = op_figma::parse_fig_binary(&bytes, file_name, op_figma::FigLayoutMode::OpenPencil) + .map_err(|e| e.to_string())?; + for warning in &import.warnings { + eprintln!("[import-figma] warning: {warning}"); + } + *host.editor_state_mut() = EditorState::from_document(import.document); + host.mark_editor_state_dirty(); + Ok(()) +} + fn refresh_title(current_path: &Option, window: Option<&winit::window::Window>) { let Some(window) = window else { return }; let title = match current_path.as_ref().and_then(|p| p.file_name()) {