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:
parent
fb5542e778
commit
a550af4e7f
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2619,6 +2619,7 @@ dependencies = [
|
|||
"op-ai-skills",
|
||||
"op-editor-core",
|
||||
"op-editor-ui",
|
||||
"op-figma",
|
||||
"op-git",
|
||||
"op-host-native",
|
||||
"op-i18n",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue