forked from 1b-agent/opencadstudio-upstream
feat(open): open drawings in the running editor
Double-clicking a drawing started a whole second copy of the app. Now it lands as a tab in the editor already open, and that window comes forward. The election is the bind itself: every GUI launch tries a deterministic per-user loopback port, and the OS grants it to exactly one process and releases it on any death, SIGKILL included. Nothing to reap, no PID to probe, no stale lock after a crash. The winner serves the port from an iced subscription; the loser checks it is talking to a matching editor, hands the paths over and exits. Every surprise — no answer, a stranger on the port, a timeout — boots a normal window, i.e. the old behaviour: the feature can degrade, but it cannot lose the file. The gate in main is positional, after every headless mode has returned. The plugin runner is this same binary re-spawning itself, so a flag list would rot the first time a mode is added; a position cannot. Handing a drawing over that is already open switches to its tab instead of loading a second copy, comparing resolved paths so a symlink or a `..` hop is still recognised as the same file. `opening` is a single slot that a second open would overwrite, and on_file_opened drops any result arriving once it is clear — so drawings handed over at once would silently vanish. They queue in pending_opens, drained from every path that clears the slot, error and cancel included. Exec becomes %F so one launch takes the whole selection; the CLI takes a list and boot fans it out through the same door. --new-instance forces a separate process; --read-only / --script / --new always get their own. Not reused: the automation server. It dispatches arbitrary commands and writes arbitrary paths, and its open replaces the active tab's document in place — publishing that on a port every local process can reach would be unforced privilege escalation. Only its JSON-lines framing is shared. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
1490fe4ea9
commit
b3bd93d8c5
11 files changed
+665
-19
No files matched your search
@@ -541,6 +541,94 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handing_over_an_already_open_drawing_switches_to_its_tab() {
|
||||
// Double-clicking a drawing that is already open should land on the tab
|
||||
// showing it, not load a second copy of the same file.
|
||||
use crate::app::Message;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
let path = std::env::temp_dir().join("ocs_already_open.dwg");
|
||||
std::fs::write(&path, b"x").unwrap();
|
||||
let canon = std::fs::canonicalize(&path).unwrap();
|
||||
|
||||
// Two tabs, the second holding the drawing; leave the first active.
|
||||
app.tabs
|
||||
.push(crate::app::document::DocumentTab::new_drawing(99));
|
||||
let target = app.tabs.len() - 1;
|
||||
app.tabs[target].current_path = Some(canon.clone());
|
||||
app.active_tab = 0;
|
||||
|
||||
let _ = app.update(Message::OpenExternal(canon.clone()));
|
||||
assert_eq!(app.active_tab, target, "should have switched to the tab");
|
||||
assert!(
|
||||
app.opening.is_none(),
|
||||
"an already-open drawing must not start a load"
|
||||
);
|
||||
assert!(app.pending_opens.is_empty(), "and must not queue one either");
|
||||
|
||||
// The same file spelled differently (a `..` hop) is still the same file.
|
||||
let indirect = canon.parent().unwrap().join("..").join(
|
||||
canon
|
||||
.strip_prefix(canon.parent().unwrap().parent().unwrap())
|
||||
.unwrap(),
|
||||
);
|
||||
app.active_tab = 0;
|
||||
let _ = app.update(Message::OpenExternal(indirect));
|
||||
assert_eq!(
|
||||
app.active_tab, target,
|
||||
"an unresolved spelling of the same path must still match the tab"
|
||||
);
|
||||
assert!(app.opening.is_none(), "still no second load");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_handoff_queues_instead_of_displacing_the_first() {
|
||||
// `opening` is one slot, and `on_file_opened` drops any result that
|
||||
// arrives once it is clear — so without the queue, two drawings handed
|
||||
// over at the same moment (select several files in a file manager: one
|
||||
// process each, all arriving together) would leave one tab and silently
|
||||
// lose the rest.
|
||||
use crate::app::Message;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
|
||||
// Any existing file will do: OpenRecent only stats it, and the actual
|
||||
// load is an async Task this test drops.
|
||||
let dir = std::env::temp_dir();
|
||||
let (a, b) = (dir.join("ocs_si_a.dwg"), dir.join("ocs_si_b.dwg"));
|
||||
std::fs::write(&a, b"x").unwrap();
|
||||
std::fs::write(&b, b"x").unwrap();
|
||||
|
||||
let _ = app.update(Message::OpenExternal(a.clone()));
|
||||
assert!(
|
||||
app.opening.is_some(),
|
||||
"first handoff should start an open, not queue"
|
||||
);
|
||||
assert!(app.pending_opens.is_empty(), "nothing to queue yet");
|
||||
|
||||
let _ = app.update(Message::OpenExternal(b.clone()));
|
||||
assert_eq!(
|
||||
app.pending_opens.len(),
|
||||
1,
|
||||
"second handoff arriving mid-open must queue, not be dropped"
|
||||
);
|
||||
assert_eq!(app.pending_opens.front(), Some(&b));
|
||||
|
||||
// A drawing that fails to parse must still release the queue behind it.
|
||||
let _ = app.update(Message::FileOpened(Err("boom".into())));
|
||||
assert!(
|
||||
app.pending_opens.is_empty(),
|
||||
"a failed open must drain the queue, not strand it"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&a);
|
||||
let _ = std::fs::remove_file(&b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_open_round_trips() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
+26
-8
@@ -685,6 +685,13 @@ pub(super) struct OpenCADStudio {
|
||||
/// `Some` while a CAD file is loading — drives the modal overlay.
|
||||
/// Cleared when the load finishes, errors, or the user cancels.
|
||||
pub(super) opening: Option<OpenProgress>,
|
||||
/// Drawings handed to us by other launches while `opening` was busy.
|
||||
/// `opening` is a single slot that a second `OpenPathPicked` would
|
||||
/// overwrite, and `on_file_opened` drops any result arriving once it is
|
||||
/// clear — so overlapping opens silently lose documents. Selecting several
|
||||
/// drawings in a file manager produces exactly that (one process per file,
|
||||
/// all arriving at once), which makes this queue load-bearing, not polish.
|
||||
pub(super) pending_opens: std::collections::VecDeque<PathBuf>,
|
||||
|
||||
// ── Unsaved-changes dialog ────────────────────────────────────────────
|
||||
/// Set when the user tries to close a tab or quit while there are unsaved changes.
|
||||
@@ -1230,6 +1237,9 @@ pub enum Message {
|
||||
/// Open a path from the Start tab's recent-documents list (skips the
|
||||
/// file picker; the path is already known).
|
||||
OpenRecent(PathBuf),
|
||||
/// A second launch handed us a drawing to open (single instance). Queued
|
||||
/// behind any open already in flight — see `pending_opens`.
|
||||
OpenExternal(PathBuf),
|
||||
/// Open a URL in the system browser (start-page intro video, links).
|
||||
OpenUrl(String),
|
||||
/// Select which section a narrow (tabbed) Start page shows.
|
||||
@@ -2260,6 +2270,7 @@ impl OpenCADStudio {
|
||||
plot_dialog: crate::ui::window::plot::PlotDialogState::default(),
|
||||
plot_prev: None,
|
||||
opening: None,
|
||||
pending_opens: std::collections::VecDeque::new(),
|
||||
pending_close: None,
|
||||
save_dialog_format: "DWG 2018".to_string(),
|
||||
save_dialog_filename: "drawing.dwg".to_string(),
|
||||
@@ -2500,16 +2511,23 @@ impl OpenCADStudio {
|
||||
Message::UpdateCheckResult,
|
||||
);
|
||||
let focus_cmd = s.focus_cmd_input();
|
||||
// Startup configuration from the command line (see `cli`). A file
|
||||
// argument — also how the OS file association launches us when a .dwg
|
||||
// is double-clicked — opens via `OpenRecent`, which existence-checks
|
||||
// the path and reports a clean error if it is bogus. `--new` opens a
|
||||
// fresh drawing tab instead of the welcome screen. `--read-only`
|
||||
// disables saving. `--script` queues command lines to run once up.
|
||||
// Startup configuration from the command line (see `cli`). File
|
||||
// arguments — also how the OS file association launches us when
|
||||
// drawings are double-clicked — go through `OpenExternal`, the same
|
||||
// door a second launch hands files to: it existence-checks each path,
|
||||
// skips one already open, and queues the rest behind the load in
|
||||
// flight. Handing them straight to `OpenRecent` would let each
|
||||
// overwrite the single `opening` slot and silently drop all but one.
|
||||
// `--new` opens a fresh drawing tab instead of the welcome screen.
|
||||
// `--read-only` disables saving. `--script` queues command lines.
|
||||
let cfg = crate::cli::gui_config();
|
||||
s.read_only = cfg.read_only;
|
||||
let cli_open: Task<Message> = if let Some(p) = cfg.file {
|
||||
Task::done(Message::OpenRecent(p))
|
||||
let cli_open: Task<Message> = if !cfg.files.is_empty() {
|
||||
Task::batch(
|
||||
cfg.files
|
||||
.into_iter()
|
||||
.map(|p| Task::done(Message::OpenExternal(p))),
|
||||
)
|
||||
} else if cfg.new {
|
||||
Task::done(Message::TabNew)
|
||||
} else {
|
||||
|
||||
+30
-1
@@ -350,6 +350,35 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of a tab already showing `path`, or `None`.
|
||||
///
|
||||
/// Compares resolved paths, so the same drawing reached through a symlink,
|
||||
/// a `..` segment or a different relative spelling is recognised as the one
|
||||
/// already open rather than loaded a second time. A path that cannot be
|
||||
/// resolved (deleted since) matches nothing and falls through to the normal
|
||||
/// open, which reports the miss.
|
||||
pub(in crate::app) fn tab_showing(&self, path: &std::path::Path) -> Option<usize> {
|
||||
let want = std::fs::canonicalize(path).ok()?;
|
||||
self.tabs.iter().position(|t| {
|
||||
t.current_path
|
||||
.as_deref()
|
||||
.and_then(|p| std::fs::canonicalize(p).ok())
|
||||
.is_some_and(|p| p == want)
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the next drawing a second launch handed us, if any.
|
||||
///
|
||||
/// Must be called from EVERY path that clears `opening` — completion, error
|
||||
/// and cancel alike. Draining only the success path would strand the queue
|
||||
/// forever the first time a file fails to parse.
|
||||
pub(in crate::app) fn drain_pending_open(&mut self) -> Task<Message> {
|
||||
match self.pending_opens.pop_front() {
|
||||
Some(p) => Task::done(Message::OpenExternal(p)),
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument, caches: crate::scene::DerivedCaches) -> Task<Message> {
|
||||
// If the user clicked Cancel while the parser was running, the
|
||||
// overlay state was cleared and we silently drop the result.
|
||||
@@ -558,7 +587,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
self.tabs[i].dirty = false;
|
||||
self.tabs[i].history = crate::app::document::HistoryState::default();
|
||||
self.refresh_selected_grips();
|
||||
Task::none()
|
||||
self.drain_pending_open()
|
||||
}
|
||||
|
||||
pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf) -> Task<Message> {
|
||||
|
||||
+43
-2
@@ -272,6 +272,45 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
Message::OpenExternal(path) => {
|
||||
// A second launch forwarded this drawing. Route it through
|
||||
// `OpenRecent` so the redirect and a cold start share one path:
|
||||
// it stats the file and reports a missing one visibly, instead
|
||||
// of a boot that appears to do nothing.
|
||||
//
|
||||
// Raising is best-effort and cannot be made reliable from here.
|
||||
// `gain_focus` reaches winit's `focus_window`, which on Wayland
|
||||
// has an empty body — it is `request_user_attention` that walks
|
||||
// the xdg-activation path, and it mints its token without a seat
|
||||
// serial, which a compositor may refuse to honour. So expect an
|
||||
// attention mark rather than a raise on Wayland; X11 does raise.
|
||||
// A real raise needs the activation token from the launching
|
||||
// process, and neither iced 0.14 nor winit 0.30 can apply one to
|
||||
// an existing window.
|
||||
let raise = match self.main_window {
|
||||
Some(id) => Task::batch([
|
||||
iced::window::gain_focus(id),
|
||||
iced::window::request_user_attention(
|
||||
id,
|
||||
Some(iced::window::UserAttention::Critical),
|
||||
),
|
||||
]),
|
||||
None => Task::none(),
|
||||
};
|
||||
// Already open → go to that tab rather than load a second copy
|
||||
// of the same drawing. Checked before the queue: switching is
|
||||
// instant and needs no load slot.
|
||||
if let Some(idx) = self.tab_showing(&path) {
|
||||
return Task::batch([raise, self.update(Message::TabSwitch(idx))]);
|
||||
}
|
||||
if self.opening.is_some() {
|
||||
self.pending_opens.push_back(path);
|
||||
raise
|
||||
} else {
|
||||
Task::batch([raise, self.update(Message::OpenRecent(path))])
|
||||
}
|
||||
}
|
||||
|
||||
Message::RecentRemove(path) => {
|
||||
self.remove_recent(&path);
|
||||
Task::none()
|
||||
@@ -318,7 +357,7 @@ impl OpenCADStudio {
|
||||
self.command_line
|
||||
.push_info(&format!("Open cancelled: \"{}\"", p.name));
|
||||
}
|
||||
Task::none()
|
||||
self.drain_pending_open()
|
||||
}
|
||||
|
||||
Message::FileOpened(Ok((name, path, doc, caches))) => {
|
||||
@@ -332,7 +371,9 @@ impl OpenCADStudio {
|
||||
if was_open && e != "Cancelled" {
|
||||
self.command_line.push_error(&format!("Open failed: {e}"));
|
||||
}
|
||||
Task::none()
|
||||
// A drawing that fails to parse must not strand the ones queued
|
||||
// behind it.
|
||||
self.drain_pending_open()
|
||||
}
|
||||
|
||||
Message::ImagePick => {
|
||||
|
||||
@@ -1677,6 +1677,12 @@ impl OpenCADStudio {
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let autosave = Subscription::none();
|
||||
// Drawings handed over by a second launch (single instance). Inert in a
|
||||
// process that lost the port election, so it costs nothing there.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let single_instance = Subscription::none();
|
||||
iced::Subscription::batch([
|
||||
frames,
|
||||
history_tick,
|
||||
@@ -1686,6 +1692,7 @@ impl OpenCADStudio {
|
||||
caret_blink,
|
||||
web_fonts,
|
||||
autosave,
|
||||
single_instance,
|
||||
event::listen_with(|ev, status, win_id| {
|
||||
use iced::event::Status;
|
||||
match ev {
|
||||
|
||||
Reference in new issue
Block a user