← Commit history

#133: eagle import dialog handling

Colby Knox ·a31bb70a5f ·12d ago ·parent b82ce39
3 files changed +93−7
CHANGELOG.md+12
@@ -3,6 +3,18 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.39 (2026-10-30)++- Issue #133 (Andy Engevold, AI Flow): `fusion_new_electronics_from_eagle` left Fusion's native Open dialog up, empty,+  and replied that it had cancelled it, while Fusion stayed blocked.+  - The File name box is set by three selectors in turn (its name, the common dialog's control id 1148, the first+    Edit), each submitted, until the dialog takes the path.+  - A dialog that will not take it is closed by its Cancel button, then WM_CLOSE, and the reply says it was closed only+    when it is gone (`dialogClosed`, `closedBy`); otherwise it says the dialog is still open, with its hwnd.+  - The empty Untitled documents the failed import opened are closed (`untitledClosed`).+- `fusion_dismiss_blocking_dialogs` closes a leftover native Open/Save dialog (class #32770) too, with Cancel, and+  proves it gone: fusion_check_dialogs listed it while dismiss said no dialog was open.+ ## 1.13.38 (2026-10-30)  - Issue #131 (from hydrogen#152): the bridge's polled reads cancelled the user's Fusion command in progress (a MOVE
rust/bridge/src/orchestrations.rs+32−7
@@ -1232,6 +1232,17 @@ pub fn eagle_import_status(ctx: &Ctx, args: &Value) -> Value {     } } +/// Close the Untitled documents a failed import opened (more Untitled than before it started); a+/// never-saved Untitled holds nothing of the user's. Returns how many were closed.+fn close_new_untitled(before: &[String], pass: &dyn Fn(&str, Value) -> Value) -> usize {+    let now: Vec<String> = pass("get_app_state", json!({}))["data"]["documents"].as_array().cloned().unwrap_or_default().iter().filter_map(|d| d["name"].as_str().map(str::to_string)).collect();+    let count = |v: &[String]| v.iter().filter(|n| n.starts_with("Untitled")).count();+    let extra = count(&now).saturating_sub(count(before));+    let mut closed = 0;+    for _ in 0..extra { if pass("close_document", json!({"name": "Untitled"}))["success"].as_bool() == Some(true) { closed += 1; } }+    closed+}+ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     // Async by contract (issue #90): the dialog-driven import outlives ab's relay budget, so the     // default returns a runId at once and the work runs on this exe's own thread; the caller@@ -1291,7 +1302,7 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V         })     };     let crashed = |dlg: Value, open_dialog: Option<i64>, stage: &str, picked: &[String]| -> Value {-        if let Some(h) = open_dialog { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10); }+        if let Some(h) = open_dialog { let _ = crate::small_verbs::close_native_dialog(&ab, &ctx.caller, h); }         crate::fusion_prefs::action_ledger(&ctx.state_dir, "eagle_import_crash_dialog", json!({"stage": stage, "dialog": dlg}));         crate::display::annotate(json!({"success": false, "errorCode": if dlg["category"] == "fusion_gpu_device_lost" { "fusion_graphics_error" } else { "blocked_by_dialog" },             "stage": stage, "imported": picked, "dialogs": [dlg.clone()], "openDialogCancelled": open_dialog.is_some(),@@ -1301,7 +1312,9 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V     let find_open_dialog = || -> Option<i64> { ab.call("desktop_list_windows", json!({}), &ctx.caller, 10).ok().and_then(|v| v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned()).unwrap_or_default().iter().find(|w| w["title"].as_str().unwrap_or("").trim().to_lowercase() == "open").and_then(|w| w["hwnd"].as_i64()) };     let (sch_base, brd_base) = (sch.rsplit('/').next().unwrap_or("").to_string(), brd.rsplit('/').next().unwrap_or("").to_string());     let mut stale_cancelled = false;-    if let Some(h) = find_open_dialog() { let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10); stale_cancelled = true; std::thread::sleep(Duration::from_secs(1)); }+    if let Some(h) = find_open_dialog() { stale_cancelled = crate::small_verbs::close_native_dialog(&ab, &ctx.caller, h).0; }+    // the documents open before the import: a failed import closes only the empty Untitled ones it made+    let docs_before: Vec<String> = pass("get_app_state", json!({}))["data"]["documents"].as_array().cloned().unwrap_or_default().iter().filter_map(|d| d["name"].as_str().map(str::to_string)).collect();     // QUEUE the import, never run it inline: the command opens a modal Open dialog, so a blocking     // `Commands.Start` holds the add-in's main thread until the dialog closes; the call then     // timed out and the timeout path swept the very dialog this verb was about to drive@@ -1339,7 +1352,16 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V         // role Edit: the bare name also matches the combo box, which has no ValuePattern. A value         // set by UIA is not committed by the Open button, so submit (Enter in the field; ab puts         // the user's foreground window back afterwards)-        let typed = ab.call("desktop_ui_set", json!({"hwnd": h, "name": "File name:", "role": "Edit", "text": full, "submit": true}), &ctx.caller, 20).map(|v| v["success"].as_bool() == Some(true) || v["data"]["set"].as_bool() == Some(true)).unwrap_or(false);+        // the File name Edit by its name, then by the common dialog's control id (1148), then as the+        // dialog's first Edit: Windows 11 24H2 named it differently and the name alone set nothing (#133)+        let set_ok = |sel: Value| -> bool {+            let mut a = sel; a["hwnd"] = json!(h); a["text"] = json!(full); a["submit"] = json!(true);+            ab.call("desktop_ui_set", a, &ctx.caller, 20).map(|v| v["success"].as_bool() == Some(true) || v["data"]["set"].as_bool() == Some(true)).unwrap_or(false)+        };+        let mut typed = false;+        for sel in [json!({"name": "File name:", "role": "Edit"}), json!({"automationId": "1148"}), json!({"role": "Edit", "nth": 0})] {+            if set_ok(sel) { typed = true; std::thread::sleep(Duration::from_millis(800)); if find_open_dialog().map(|x| x != h).unwrap_or(true) { break; } }+        }         if !typed { let _ = ab.call("desktop_ui_click", json!({"hwnd": h, "name": base}), &ctx.caller, 15); std::thread::sleep(Duration::from_secs(1)); let _ = ab.call("desktop_ui_click", json!({"hwnd": h, "name": "Open"}), &ctx.caller, 15); }         // the dialog must be gone now; if the file was not in the folder the dialog showed, the         // click missed and the dialog stays. Never leave it on the user's screen.@@ -1347,10 +1369,13 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V         for _ in 0..8 { std::thread::sleep(Duration::from_secs(1)); if find_open_dialog().map(|x| x != h).unwrap_or(true) { gone = true; break; } }         if !gone { if let Some(c) = crash_dialog() { return crashed(c, Some(h), &format!("submitting the {want} path"), &picked); } }         if !gone {-            let _ = ab.call("desktop_close_window", json!({"hwnd": h}), &ctx.caller, 10);-            crate::fusion_prefs::action_ledger(&ctx.state_dir, "eagle_import_dialog_cancelled", json!({"wanted": base, "kind": want}));-            return json!({"success": false, "errorCode": "open_dialog_stuck", "wanted": base, "imported": picked,-                "error": format!("Fusion's Open dialog did not accept '{base}' (it was not in the folder the dialog showed, or the name did not match); the bridge cancelled the dialog so nothing is left on screen."),+            let (closed, how) = crate::small_verbs::close_native_dialog(&ab, &ctx.caller, h);+            let cleaned = close_new_untitled(&docs_before, pass);+            crate::fusion_prefs::action_ledger(&ctx.state_dir, "eagle_import_dialog_cancelled", json!({"wanted": base, "kind": want, "typed": typed, "closed": closed, "how": how, "untitledClosed": cleaned}));+            return json!({"success": false, "errorCode": "open_dialog_stuck", "wanted": base, "imported": picked, "typed": typed,+                "dialogClosed": closed, "dialogHwnd": h, "closedBy": how, "untitledClosed": cleaned,+                "error": if closed { format!("Fusion's Open dialog did not take '{base}' ({}); the bridge closed the dialog ({how}) and the empty documents the import had opened.", if typed { "the path was typed and submitted, but the dialog stayed" } else { "the File name box could not be set" }) }+                         else { format!("Fusion's Open dialog did not take '{base}', and it is STILL OPEN (hwnd {h}): Cancel and WM_CLOSE did not close it. Fusion stays blocked until it is closed.") },                 "_hint": "Nothing was imported. Put the .sch and .brd together in one space-free folder, pass both absolute paths, and retry; or use the dialog-free routes: fusion_schematic {action:\"open\", schPath, withBoard:false} for the schematic alone, fusion_open_board {filePath} for a .brd with placed parts."});         }         picked.push(base.clone()); answered = Some(h);
rust/bridge/src/small_verbs.rs+49
@@ -439,6 +439,38 @@ pub fn classify_blocking_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fu     out } +/// Is this window still on screen (a visible top-level window with that hwnd)?+fn window_present(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> bool {+    ab.call("desktop_list_windows", json!({}), caller, 10).ok()+        .and_then(|v| v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned())+        .map(|ws| ws.iter().any(|w| w["hwnd"].as_i64() == Some(hwnd) && w["visible"].as_bool() != Some(false)))+        .unwrap_or(false)+}++/// Close a native Windows file dialog (class #32770: Open, Save As) and PROVE it is gone (issue #133):+/// its Cancel button first (a common dialog always honours IDCANCEL, in the background by UI+/// Automation), then WM_CLOSE. Returns (closed, how).+pub fn close_native_dialog(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> (bool, &'static str) {+    let gone_within = |secs: u64| -> bool { for _ in 0..(secs * 2) { std::thread::sleep(std::time::Duration::from_millis(500)); if !window_present(ab, caller, hwnd) { return true; } } false };+    if !window_present(ab, caller, hwnd) { return (true, "already gone"); }+    let _ = ab.call("desktop_ui_click", json!({"hwnd": hwnd, "name": "Cancel", "role": "Button"}), caller, 15);+    if gone_within(4) { return (true, "Cancel button"); }+    let _ = ab.call("desktop_close_window", json!({"hwnd": hwnd}), caller, 15);+    if gone_within(4) { return (true, "WM_CLOSE"); }+    (false, "still open after Cancel and WM_CLOSE")+}++/// Native file dialogs (class #32770) open on Fusion's processes: an import that failed can leave its+/// Open dialog, which blocks Fusion's main thread like any modal (issue #133).+pub fn native_file_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Vec<Value> {+    let mut args = json!({});+    if !fusion_pids.is_empty() { args["pids"] = json!(fusion_pids); }+    ab.call("desktop_enum_windows", args, caller, 10).ok()+        .map(|v| v["windows"].as_array().or_else(|| v["data"]["windows"].as_array()).cloned().unwrap_or_default()).unwrap_or_default()+        .into_iter().filter(|w| w["className"].as_str().or(w["class"].as_str()).map(|c| c.contains("#32770")).unwrap_or(false) && w["visible"].as_bool() != Some(false))+        .collect()+}+ /// `fusion_dismiss_blocking_dialogs`: answer every blocking dialog SAFELY in the background /// (WM_CLOSE = No/Cancel, never Yes), reporting what each one was. pub fn dismiss_blocking_dialogs(state_dir: &Path, args: &Value, caller: &fusion_core::ab::Caller, fusion_pids: &[u32], taskbar: &dyn Fn(Option<f64>)) -> Value {@@ -449,6 +481,22 @@ pub fn dismiss_blocking_dialogs(state_dir: &Path, args: &Value, caller: &fusion_     let mut classified = classify_blocking_dialogs(&ab, caller, fusion_pids);     let want: Vec<String> = args["categories"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())).collect()).unwrap_or_default();     if !want.is_empty() { classified.retain(|d| d["category"].as_str().map(|c| want.iter().any(|w| w == c)).unwrap_or(false)); }+    // native Open/Save dialogs are not Qt dialogs, but they block Fusion all the same: fusion_check_dialogs+    // lists them, so this answers them too, with Cancel, and proves each is gone (issue #133)+    let mut native_answered = Vec::new();+    if want.is_empty() || want.iter().any(|w| w == "file_dialog") {+        for w in native_file_dialogs(&ab, caller, fusion_pids) {+            let Some(h) = w["hwnd"].as_i64() else { continue };+            let (closed, how) = close_native_dialog(&ab, caller, h);+            crate::fusion_prefs::action_ledger(state_dir, "dialog_dismissed", json!({"title": w["title"], "category": "file_dialog", "hwnd": h, "closed": closed, "how": how}));+            native_answered.push(json!({"title": w["title"], "category": "file_dialog", "answeredWith": format!("Cancel ({how})"), "closed": closed, "hwnd": h}));+        }+    }+    if classified.is_empty() && !native_answered.is_empty() {+        let all_closed = native_answered.iter().all(|d| d["closed"] == true);+        return json!({"success": all_closed, "count": native_answered.len(), "dismissed": native_answered,+            "_hint": if all_closed { "Closed the leftover native Open/Save dialog(s) with Cancel; nothing was opened or saved." } else { "A native Open/Save dialog did not close with Cancel or WM_CLOSE: it is still on screen (dismissed[].hwnd)." }});+    }     if classified.is_empty() {         return json!({"success": true, "dismissed": [], "count": 0, "_hint": if want.is_empty() { "No blocking Fusion dialog is open." } else { "No blocking dialog in the requested categories." }});     }@@ -476,6 +524,7 @@ pub fn dismiss_blocking_dialogs(state_dir: &Path, args: &Value, caller: &fusion_     if upload_active { hint.push_str(" One was a Hub-UPLOAD 'lose these changes?' prompt: it was declined (No), but the upload is STILL in flight - poll app.hasActiveJobs (fusion_state) until it drains before any close/saveAs, or you WILL lose the 3D package."); }     taskbar(if upload_active { Some(1.0) } else { None });     if !left_open.is_empty() { hint.push_str(&format!(" LEFT OPEN {} window(s) no rule knows that do not look like a dialog (see leftOpen[].why): WM_CLOSE on a main, document or EAGLE editor window closes documents and can exit Fusion. fusion_check_dialogs shows their text; decide from that.", left_open.len())); }+    answered.extend(native_answered);     json!({"success": true, "count": answered.len(), "dismissed": answered, "leftOpen": left_open, "uploadStillActive": upload_active, "_hint": hint}) }