plugin: announce DocumentOpened once per load, not per tab switch

The guard remembered only the last announced (tab, generation), so
switching A -> B -> A announced A's load again. Each tab now carries
its own pending flag, set when a document is installed and cleared by
the boundary dispatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-14 23:49:17 +03:00
1 parent 68330ce39e
commit 4d66a58374
7 files changed
+28 -51

No files matched your search

+2 -2
View File
@@ -427,8 +427,8 @@ and `SelectionChanged` (the plain selection set) at the fingerprint-guarded mess
the v4 bridge already uses (`notify_plugins_selection_changed` / `notify_plugins_document_changed`,
plus the automation post-op boundary), and a new additive `DocumentOpened { tab_id }` variant
(discriminant 10, the counterpart of the existing `DocumentTabClosed`) that fires once per whole
document install — file open, template new, system open — keyed on a new per-tab document
generation. Boundary rule: dispatch happens after the mutation settles and pending history scopes
document install — file open, template new, system open — keyed on a per-tab pending flag, so
neither an edit nor a tab switch repeats it. Boundary rule: dispatch happens after the mutation settles and pending history scopes
closed, never mid-undo/mid-pick, and history (undo/redo) replay itself emits nothing — the
boundary is the only channel; plugin-initiated writes publish immediately after settling, like
the drawing-changed event, with the boundary fingerprint deduplicating. Handlers may call HostApi
+6 -7
View File
@@ -729,8 +729,8 @@ impl OpenCADStudio {
// The headless session starts on the welcome (Start) tab, which
// blocks drawing commands; turn it into a real drawing.
self.tabs[i].is_start = false;
// A fresh document became resident (API v9): mint a new
// generation so the post-op boundary announces `DocumentOpened`.
// A fresh document became resident (API v9): the post-op
// boundary announces `DocumentOpened`.
self.tabs[i].note_document_loaded();
self.entity_summary()
}
@@ -770,9 +770,8 @@ impl OpenCADStudio {
self.tabs[i].current_path = Some(path_buf);
self.tabs[i].is_start = false;
self.tabs[i].scene.rebuild_derived_caches();
// The opened document settled (API v9): mint a new
// generation so the post-op boundary announces
// `DocumentOpened` once for this load.
// The opened document settled (API v9): the post-op
// boundary announces `DocumentOpened` once for this load.
self.tabs[i].note_document_loaded();
let mut summary = self.entity_summary();
if dropped > 0 {
@@ -1855,7 +1854,7 @@ mod tests {
"a new document announces exactly one open: {log:?}"
);
// A follow-up op does not repeat it: generation-keyed dispatch.
// A follow-up op does not repeat it: the tab's pending flag cleared.
app.automation_op(r#"{"op":"query","type":"Line"}"#);
let log = plugin_dispatch_log();
assert!(
@@ -1917,7 +1916,7 @@ mod tests {
// The next message boundary does not repeat it.
app.notify_plugins_document_opened();
let log = plugin_dispatch_log();
assert_eq!(log.len(), 0, "generation-keyed dispatch dedupes: {log:?}");
assert_eq!(log.len(), 0, "the pending flag dedupes: {log:?}");
let _ = std::fs::remove_file(&path);
}
+2 -2
View File
@@ -466,8 +466,8 @@ impl OpenCADStudio {
self.tabs[i].current_path = None;
self.tabs[i].is_start = false;
self.tabs[i].dirty = true;
// A whole document just became resident in the tab (API v9): mint a
// fresh generation so the boundary announces `DocumentOpened` once.
// A whole document just became resident in the tab (API v9): the
// boundary announces `DocumentOpened` once.
self.tabs[i].note_document_loaded();
Ok(purged)
}
+11 -23
View File
@@ -18,17 +18,6 @@ use std::sync::Arc;
static NEXT_DOCUMENT_TAB_ID: AtomicU64 = AtomicU64::new(1);
/// Monotonic source for `DocumentTab::document_generation` (API v9): every
/// minted value is process-unique, so any freshly installed document — file
/// open, template new, system open — differs from every generation a plugin
/// has been told about, and the opened-notification guard fires exactly once
/// per load.
static NEXT_DOCUMENT_GENERATION: AtomicU64 = AtomicU64::new(1);
fn next_document_generation() -> u64 {
NEXT_DOCUMENT_GENERATION.fetch_add(1, Ordering::Relaxed)
}
// ── Dynamic input ──────────────────────────────────────────────────────────
/// One quantity shown in the dynamic-input overlay near the cursor.
@@ -158,13 +147,12 @@ pub(super) struct DocumentTab {
/// Monotonic committed-edit/undo/redo revision. Background save completion
/// uses it with scene epochs so an older snapshot never clears newer work.
pub(super) edit_revision: u64,
/// Monotonic identity of the document currently resident in this tab: a
/// fresh value is minted whenever a whole document replaces the tab's
/// content (file open, template new, system open), and edits leave it
/// alone. The plugin-opened notification guard
/// (`OpenCADStudio::notify_plugins_document_opened`, API v9) keys on it
/// so `DocumentOpened` dispatches exactly once per load.
pub(super) document_generation: u64,
/// A whole document replaced this tab's content (file open, template new,
/// system open) and native plugins have not been told yet: the
/// message boundary (`OpenCADStudio::notify_plugins_document_opened`,
/// API v9) dispatches `DocumentOpened` once and clears it, so edits and
/// switching between tabs never announce the same load again.
pub(super) document_opened_pending: bool,
pub(super) tab_title: String,
pub(super) properties: PropertiesPanel,
pub(super) layers: LayerPanel,
@@ -648,7 +636,7 @@ impl DocumentTab {
dirty: false,
recovery_save_as_required: false,
edit_revision: 0,
document_generation: next_document_generation(),
document_opened_pending: true,
prev_selection: Vec::new(),
tab_title: format!("Drawing{}", n),
properties: PropertiesPanel::empty(),
@@ -723,11 +711,11 @@ impl DocumentTab {
}
/// A whole document just replaced this tab's content (file open, template
/// new, system open): mint a fresh generation so the message-boundary
/// dispatch announces `DocumentOpened` exactly once for this load (API
/// v9). Call after the document and its derived caches have settled.
/// new, system open): the next message boundary announces
/// `DocumentOpened` once for this load (API v9). Call after the document
/// and its derived caches have settled.
pub(super) fn note_document_loaded(&mut self) {
self.document_generation = next_document_generation();
self.document_opened_pending = true;
}
pub(super) fn tab_display_name(&self) -> String {
-8
View File
@@ -1153,12 +1153,6 @@ pub(super) struct OpenCADStudio {
/// boundaries as well as after plugin-initiated writes.
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document: Option<(u64, u64)>,
/// `(tab id, document generation)` last dispatched as `DocumentOpened` —
/// a tab's generation changes only when a whole document replaced its
/// content, so the opened notification fires once per load, never per
/// edit (API v9).
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document_opened: Option<(u64, u64)>,
/// External add-on packages found in the plugins folder, refreshed when the
/// Plugin Manager opens.
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
@@ -4838,8 +4832,6 @@ impl OpenCADStudio {
last_plugin_selection: None,
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document: None,
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document_opened: None,
external_plugins: Vec::new(),
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
plugin_load_errors: rustc_hash::FxHashMap::default(),
+3 -3
View File
@@ -2276,9 +2276,9 @@ impl OpenCADStudio {
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
}
self.opening.take();
// The document and its derived caches have settled: mark the tab's
// generation so the next message boundary announces `DocumentOpened`
// to native plugins exactly once for this load (API v9).
// The document and its derived caches have settled: the next message
// boundary announces `DocumentOpened` to native plugins exactly once
// for this load (API v9).
self.tabs[i].note_document_loaded();
// The tab list just changed (and the active tab with it): remember
// it for the next boot's restore flow (web session memory).
+4 -6
View File
@@ -344,9 +344,9 @@ impl OpenCADStudio {
/// Announce that a whole document became resident in the active tab —
/// opened from file, created from a template, or loaded by a system open
/// (API v9). Keyed on `(tab id, document generation)`, which changes only
/// when a tab's content is wholesale replaced, so an edit can never fire
/// it twice and a reload fires it exactly once. Part of the same
/// (API v9). Each tab carries its own pending flag, set only when its
/// content is wholesale replaced, so neither an edit nor a tab switch can
/// fire it twice and a reload fires it exactly once. Part of the same
/// post-message boundary as the selection and document dispatches: runs
/// after the mutation settles and pending history scopes closed, never
/// inside history replay or interactive picking.
@@ -359,11 +359,9 @@ impl OpenCADStudio {
if self.tabs[i].is_start {
return;
}
let key = (self.tabs[i].id, self.tabs[i].document_generation);
if self.last_plugin_document_opened == Some(key) {
if !std::mem::take(&mut self.tabs[i].document_opened_pending) {
return;
}
self.last_plugin_document_opened = Some(key);
crate::plugin::v4_support::publish_document_opened(self.tabs[i].id);
}