feat(figma): wire .fig binary import into the desktop host

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.
This commit is contained in:
Kayshen-X 2026-05-22 22:55:47 +08:00
parent fb5542e778
commit a550af4e7f
5 changed files with 137 additions and 30 deletions

1
Cargo.lock generated
View file

@ -2619,6 +2619,7 @@ dependencies = [
"op-ai-skills",
"op-editor-core",
"op-editor-ui",
"op-figma",
"op-git",
"op-host-native",
"op-i18n",

View file

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

View file

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

View file

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

View file

@ -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<PathBuf>,
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<PathBuf>, window: Option<&winit::window::Window>) {
let Some(window) = window else { return };
let title = match current_path.as_ref().and_then(|p| p.file_name()) {