From 7fdb196abec3a260160b49f5f4e1c534c48a7344 Mon Sep 17 00:00:00 2001 From: JDonaghy Date: Thu, 16 Jul 2026 22:30:12 +0000 Subject: [PATCH] fix(#572): route OpenFileDialog/SaveWorkspaceAsDialog through PlatformServices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Msg::OpenFileDialog and Msg::SaveWorkspaceAsDialog now call backend.services().show_file_open_dialog()/show_file_save_dialog() (quadraui#427, now shipped) instead of constructing gtk4::FileDialog in-line. dispatch() has no `backend` in scope, so the request is stashed in a new PendingFileDialog Cell and drained in tick(), which does receive the runner-owned `backend: &mut dyn quadraui::Backend` — the same instance whose PlatformServices pump_depth is wired to the runner's own event controllers, unlike App's separate self.backend field (used for modal-stack/drag-state), which must NOT be used for this (unshared pump_depth would reintroduce the #427 double-borrow panic). Msg::OpenFolderDialog stays on direct gtk4::FileDialog for now: quadraui::PlatformServices has no folder-select dialog mode yet (only file-open/file-save). That gap needs a new quadraui issue before the third call site can migrate. Co-Authored-By: Claude Sonnet 5 --- src/gtk/mod.rs | 116 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 88 insertions(+), 28 deletions(-) diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index 9b965cab..decb01dc 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -444,6 +444,10 @@ struct App { /// Set to true in update() whenever a draw is needed; cleared by the #[watch] block. /// This prevents the 20/sec SearchPollTick timer from unconditionally calling queue_draw(). draw_needed: Rc>, + /// A file dialog requested this frame, drained by the next `tick()` + /// call (which has the `backend` handle `PlatformServices` needs). + /// See [`PendingFileDialog`] (#572). + pending_file_dialog: Cell>, /// DrawingArea for the file explorer sidebar (Phase A.2b-2: native /// `gtk4::TreeView` replaced by a single DrawingArea rendering via /// `draw_explorer_panel`). @@ -899,6 +903,33 @@ struct WindowScrollbars { cursor_indicator: gtk4::DrawingArea, } +/// A native file dialog requested by `Msg::OpenFileDialog` / +/// `Msg::SaveWorkspaceAsDialog`, deferred to the next `tick()` (#572). +/// +/// `dispatch()` — where those `Msg` variants are handled — has no +/// `backend: &mut dyn quadraui::Backend` in scope, but `PlatformServices` +/// (and the re-entrancy-guarded nested-mainloop pump backing it, see +/// `quadraui::gtk::services` #427) is only reachable through that +/// runner-owned `backend` parameter. `tick()` receives it every frame, so +/// the request is stashed here and drained there instead of threading +/// `backend` through the whole `dispatch`/`dispatch_engine_action`/ +/// `handle_menu_msg` call graph. +/// +/// Deliberately **not** routed through `self.backend` (`App`'s own +/// `Rc>`, used for modal-stack/drag-state handles) — +/// that is a separate `GtkBackend` instance from the one +/// `quadraui::gtk::run::run` constructs internally and passes as the +/// trait-object `backend` param. Its `PlatformServices` has its own, +/// unshared `pump_depth` counter, so pumping the nested main loop through +/// it would not signal the runner's own event controllers to skip their +/// `backend.borrow_mut()` calls — reintroducing the double-borrow panic +/// #427's guard exists to prevent. +#[derive(Debug, Clone, Copy)] +enum PendingFileDialog { + OpenFile, + SaveWorkspaceAs, +} + #[derive(Debug)] #[allow(dead_code)] // Variants used in later phases enum Msg { @@ -1378,6 +1409,7 @@ impl App { App { engine, draw_needed: Rc::new(Cell::new(false)), + pending_file_dialog: Cell::new(None), explorer_sidebar_da_ref: Rc::new(RefCell::new(None)), activity_bar_da_ref: Rc::new(RefCell::new(None)), explorer_row_height_cell: Rc::new(Cell::new(28.0)), @@ -1474,6 +1506,44 @@ impl App { } impl App { + /// Run a file dialog requested via [`PendingFileDialog`] (#572), using + /// the runner-owned `backend`'s `PlatformServices` — `show_file_open_dialog` + /// / `show_file_save_dialog` block (via quadraui's nested-mainloop pump, + /// #427) until the user picks or cancels, then this returns synchronously. + fn run_pending_file_dialog( + &mut self, + req: PendingFileDialog, + backend: &mut dyn quadraui::Backend, + ) { + use quadraui::FileDialogOptions; + match req { + PendingFileDialog::OpenFile => { + let path = backend.services().show_file_open_dialog(FileDialogOptions { + title: Some("Open File".to_string()), + ..Default::default() + }); + if let Some(path) = path { + let _ = self + .engine + .borrow_mut() + .open_file_with_mode(&path, crate::core::engine::OpenMode::Permanent); + self.dispatch(Msg::RefreshFileTree); + } + } + PendingFileDialog::SaveWorkspaceAs => { + let path = backend.services().show_file_save_dialog(FileDialogOptions { + title: Some("Save Workspace As".to_string()), + initial_filename: Some(".vimcode-workspace".to_string()), + ..Default::default() + }); + if let Some(path) = path { + self.engine.borrow_mut().save_workspace_as(&path); + } + } + } + self.draw_needed.set(true); + } + fn dispatch(&mut self, msg: Msg) { // Track if this is a scrollbar change to avoid syncing feedback loop let is_scrollbar_msg = matches!( @@ -7442,25 +7512,17 @@ impl App { } } Msg::OpenFileDialog => { - let engine = self.engine.clone(); - let sender2 = self.sender.clone(); - let dialog = gtk4::FileDialog::new(); - dialog.set_title("Open File"); - let win = self.window.clone(); - dialog.open(win.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { - if let Ok(file) = result { - if let Some(path) = gtk4::prelude::FileExt::path(&file) { - let _ = engine.borrow_mut().open_file_with_mode( - &path, - crate::core::engine::OpenMode::Permanent, - ); - sender2.send(Msg::RefreshFileTree).ok(); - } - } - }); + // Deferred to tick(), which has the runner-owned `backend` + // handle PlatformServices needs — see PendingFileDialog (#572). + self.pending_file_dialog + .set(Some(PendingFileDialog::OpenFile)); self.draw_needed.set(true); } Msg::OpenFolderDialog => { + // #572: still direct gtk4::FileDialog — quadraui::PlatformServices + // has no folder-select mode yet (only show_file_open_dialog / + // show_file_save_dialog, both file pickers). Needs a new + // quadraui issue (folder-select support) before this can move. let engine = self.engine.clone(); let sender2 = self.sender.clone(); let dialog = gtk4::FileDialog::new(); @@ -7485,18 +7547,9 @@ impl App { self.draw_needed.set(true); } Msg::SaveWorkspaceAsDialog => { - let engine = self.engine.clone(); - let dialog = gtk4::FileDialog::new(); - dialog.set_title("Save Workspace As"); - dialog.set_initial_name(Some(".vimcode-workspace")); - let win = self.window.clone(); - dialog.save(win.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { - if let Ok(file) = result { - if let Some(path) = gtk4::prelude::FileExt::path(&file) { - engine.borrow_mut().save_workspace_as(&path); - } - } - }); + // Deferred to tick() — see PendingFileDialog (#572). + self.pending_file_dialog + .set(Some(PendingFileDialog::SaveWorkspaceAs)); self.draw_needed.set(true); } Msg::OpenRecentDialog => { @@ -8606,6 +8659,13 @@ impl quadraui::ShellApp for App { self.dispatch(msg); } + // Run a file dialog requested this frame — needs the runner-owned + // `backend` handle for `PlatformServices` (#572). See + // `PendingFileDialog` for why this can't happen in `dispatch()`. + if let Some(req) = self.pending_file_dialog.take() { + self.run_pending_file_dialog(req, backend); + } + // Periodic background work: LSP, DAP, git, search, etc. self.handle_poll_tick();