@perspective-dev/viewer 5.2.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/cdn/perspective-viewer.js +8 -8
  2. package/dist/cdn/perspective-viewer.js.map +3 -3
  3. package/dist/css/blueprint.css +1 -0
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -0
  7. package/dist/css/gruvbox-dark.css +1 -1
  8. package/dist/css/gruvbox.css +1 -1
  9. package/dist/css/icons.css +1 -1
  10. package/dist/css/intl/de.css +1 -1
  11. package/dist/css/intl/es.css +1 -1
  12. package/dist/css/intl/fr.css +1 -1
  13. package/dist/css/intl/ja.css +1 -1
  14. package/dist/css/intl/pt.css +1 -1
  15. package/dist/css/intl/zh.css +1 -1
  16. package/dist/css/intl.css +1 -1
  17. package/dist/css/ledger.css +1 -0
  18. package/dist/css/monokai.css +1 -1
  19. package/dist/css/nord.css +1 -0
  20. package/dist/css/phosphor.css +1 -1
  21. package/dist/css/pro-dark.css +1 -1
  22. package/dist/css/pro.css +1 -1
  23. package/dist/css/solarized-dark.css +1 -1
  24. package/dist/css/solarized.css +1 -1
  25. package/dist/css/themes.css +1 -1
  26. package/dist/css/vaporwave.css +1 -1
  27. package/dist/css/velvet.css +1 -0
  28. package/dist/docs/perspective-docs.json +1 -1
  29. package/dist/esm/column-format.d.ts +16 -3
  30. package/dist/esm/perspective-viewer.d.ts +1 -0
  31. package/dist/esm/perspective-viewer.inline.js +9 -9
  32. package/dist/esm/perspective-viewer.inline.js.map +3 -3
  33. package/dist/esm/perspective-viewer.js +8 -8
  34. package/dist/esm/perspective-viewer.js.map +3 -3
  35. package/dist/esm/plugin.d.ts +10 -0
  36. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +1 -1
  37. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  38. package/dist/esm/ts-rs/ViewerConfig.d.ts +1 -1
  39. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  40. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +13 -3
  41. package/dist/wasm/perspective-viewer.d.ts +43 -36
  42. package/dist/wasm/perspective-viewer.js +198 -136
  43. package/dist/wasm/perspective-viewer.wasm +0 -0
  44. package/dist/wasm/perspective-viewer.wasm.d.ts +19 -17
  45. package/package.json +1 -1
  46. package/src/css/column-settings-panel.css +28 -5
  47. package/src/css/column-style.css +291 -31
  48. package/src/css/containers/tabs.css +13 -3
  49. package/src/css/dom/select.css +21 -9
  50. package/src/css/form/code-editor.css +4 -4
  51. package/src/css/plugin-selector.css +1 -0
  52. package/src/css/plugin-settings-panel.css +72 -0
  53. package/src/css/viewer.css +0 -5
  54. package/src/rust/agent/tools.rs +53 -19
  55. package/src/rust/components/column_selector/active_column.rs +2 -2
  56. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  57. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  58. package/src/rust/components/column_selector/config_selector.rs +8 -0
  59. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  60. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  61. package/src/rust/components/column_selector.rs +48 -4
  62. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  63. package/src/rust/components/column_settings_sidebar/style_tab.rs +330 -234
  64. package/src/rust/components/column_settings_sidebar.rs +88 -69
  65. package/src/rust/components/containers/control_group.rs +91 -0
  66. package/src/rust/components/containers/mod.rs +1 -0
  67. package/src/rust/components/containers/select.rs +8 -2
  68. package/src/rust/components/datetime_column_style/simple.rs +15 -4
  69. package/src/rust/components/datetime_column_style.rs +50 -10
  70. package/src/rust/components/editable_header.rs +85 -144
  71. package/src/rust/components/expression_editor.rs +9 -16
  72. package/src/rust/components/form/code_editor.rs +16 -6
  73. package/src/rust/components/form/debug.rs +1 -0
  74. package/src/rust/components/form/mod.rs +3 -1
  75. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  76. package/src/rust/components/form/named_value_picker.rs +86 -0
  77. package/src/rust/components/form/palette_selector.rs +440 -0
  78. package/src/rust/components/main_panel/msg.rs +2 -2
  79. package/src/rust/components/main_panel/reconcile.rs +4 -4
  80. package/src/rust/components/plugin_tab.rs +126 -112
  81. package/src/rust/components/settings_panel.rs +26 -20
  82. package/src/rust/components/status_bar.rs +0 -1
  83. package/src/rust/components/style_controls/number_string_format/digits_section.rs +22 -10
  84. package/src/rust/components/style_controls/number_string_format/misc_section.rs +6 -2
  85. package/src/rust/components/style_controls/number_string_format/style_section.rs +1 -0
  86. package/src/rust/components/style_controls/number_string_format/types.rs +24 -0
  87. package/src/rust/components/style_controls/number_string_format.rs +37 -14
  88. package/src/rust/components/viewer/msg.rs +46 -7
  89. package/src/rust/components/viewer/panels.rs +11 -9
  90. package/src/rust/components/viewer/render.rs +8 -3
  91. package/src/rust/components/viewer/settings.rs +163 -23
  92. package/src/rust/components/viewer/snapshots.rs +46 -21
  93. package/src/rust/components/viewer/wiring.rs +14 -25
  94. package/src/rust/components/viewer.rs +12 -8
  95. package/src/rust/config/column_config_schema.rs +435 -45
  96. package/src/rust/config/css_scale.rs +882 -0
  97. package/src/rust/config/datetime_column_style.rs +4 -21
  98. package/src/rust/config/mod.rs +2 -0
  99. package/src/rust/config/number_string_format.rs +211 -18
  100. package/src/rust/config/options.rs +10 -0
  101. package/src/rust/config/viewer_config.rs +49 -55
  102. package/src/rust/config/workspace_config.rs +132 -5
  103. package/src/rust/custom_elements/viewer.rs +214 -155
  104. package/src/rust/custom_events.rs +14 -1
  105. package/src/rust/js/regular_layout.rs +3 -3
  106. package/src/rust/lib.rs +2 -0
  107. package/src/rust/presentation/column_locator.rs +19 -5
  108. package/src/rust/presentation.rs +209 -36
  109. package/src/rust/queries/column_locator.rs +74 -44
  110. package/src/rust/queries/get_viewer_config.rs +0 -3
  111. package/src/rust/queries/mod.rs +2 -0
  112. package/src/rust/queries/palette_set.rs +82 -0
  113. package/src/rust/queries/plugin_column_styles.rs +6 -2
  114. package/src/rust/renderer/dispatch.rs +50 -12
  115. package/src/rust/renderer/plugin_config.rs +362 -53
  116. package/src/rust/renderer.rs +15 -42
  117. package/src/rust/session/column_defaults_update.rs +15 -8
  118. package/src/rust/session/metadata.rs +5 -1
  119. package/src/rust/session/props.rs +1 -0
  120. package/src/rust/session.rs +9 -6
  121. package/src/rust/tasks/create_panel.rs +44 -57
  122. package/src/rust/tasks/edit_expression.rs +19 -23
  123. package/src/rust/tasks/edit_window.rs +8 -10
  124. package/src/rust/tasks/eject.rs +4 -1
  125. package/src/rust/tasks/pipeline.rs +2 -2
  126. package/src/rust/tasks/presize_panels.rs +44 -5
  127. package/src/rust/tasks/reset_all.rs +4 -4
  128. package/src/rust/tasks/restore_and_render.rs +17 -44
  129. package/src/rust/tasks/restore_panel.rs +11 -13
  130. package/src/rust/tasks/set_edit_mode.rs +3 -1
  131. package/src/rust/tasks/sync_update_panels.rs +11 -1
  132. package/src/rust/tasks/update_theme.rs +23 -18
  133. package/src/rust/utils/css_vars.rs +26 -0
  134. package/src/rust/utils/mod.rs +2 -0
  135. package/src/rust/utils/spawn.rs +1 -1
  136. package/src/rust/workspace.rs +435 -272
  137. package/src/svg/lock.svg +7 -0
  138. package/src/themes/blueprint.css +190 -0
  139. package/src/themes/botanical.css +14 -17
  140. package/src/themes/defaults.css +4 -110
  141. package/src/themes/dracula.css +19 -14
  142. package/src/themes/eggplant.css +189 -0
  143. package/src/themes/gruvbox-dark.css +23 -15
  144. package/src/themes/gruvbox.css +11 -1
  145. package/src/themes/icons.css +2 -2
  146. package/src/themes/intl/de.css +23 -1
  147. package/src/themes/intl/es.css +23 -1
  148. package/src/themes/intl/fr.css +23 -1
  149. package/src/themes/intl/ja.css +23 -1
  150. package/src/themes/intl/pt.css +23 -1
  151. package/src/themes/intl/zh.css +23 -1
  152. package/src/themes/intl.css +26 -4
  153. package/src/themes/ledger.css +195 -0
  154. package/src/themes/monokai.css +24 -14
  155. package/src/themes/nord.css +186 -0
  156. package/src/themes/phosphor.css +7 -14
  157. package/src/themes/pro-dark.css +12 -16
  158. package/src/themes/pro.css +118 -0
  159. package/src/themes/solarized-dark.css +9 -16
  160. package/src/themes/solarized.css +7 -0
  161. package/src/themes/themes.css +10 -5
  162. package/src/themes/vaporwave.css +22 -14
  163. package/src/themes/velvet.css +192 -0
  164. package/src/ts/column-format.ts +52 -2
  165. package/src/ts/perspective-viewer.ts +1 -0
  166. package/src/ts/plugin.ts +14 -0
  167. package/src/ts/ts-rs/PanelViewerConfig.ts +1 -1
  168. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  169. package/src/ts/ts-rs/ViewerConfig.ts +1 -1
  170. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  171. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +11 -3
  172. package/src/rust/components/form/color_range_selector.rs +0 -137
  173. package/src/svg/dropdown-selector-light.svg +0 -3
  174. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline0.js +0 -0
  175. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline1.js +0 -0
  176. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline2.js +0 -0
  177. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline3.js +0 -0
  178. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline4.js +0 -0
  179. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline5.js +0 -0
@@ -12,8 +12,7 @@
12
12
 
13
13
  //! The multi-panel model backing a single `<perspective-viewer>`.
14
14
 
15
- use std::cell::RefCell;
16
- use std::collections::HashSet;
15
+ use std::cell::{Cell, RefCell};
17
16
  use std::rc::Rc;
18
17
 
19
18
  use perspective_client::Client;
@@ -51,12 +50,7 @@ impl From<&str> for PanelId {
51
50
  }
52
51
  }
53
52
 
54
- /// The element-level global filter state (master/detail cross-filter): an
55
- /// unattributed `restored` bucket (from whole-element `restore` — per-master
56
- /// attribution is not persisted) plus one contribution per master panel, in
57
- /// first-contribution order. The effective set is the ordered, deduped
58
- /// flattening of both. Pure data — factored out of [`WorkspaceData`] so the
59
- /// replace/dedup/removal semantics are unit-testable without engine handles.
53
+ /// The element-level global filter state (master/detail cross-filter.
60
54
  #[derive(Default)]
61
55
  struct GlobalFilterSet {
62
56
  restored: Vec<Filter>,
@@ -91,10 +85,7 @@ impl GlobalFilterSet {
91
85
  before != self.flatten()
92
86
  }
93
87
 
94
- /// Replace `id`'s contribution (a master's new selection REPLACES its
95
- /// prior one — no plugin-remembered remove-lists). A non-empty selection
96
- /// also drops the `restored` bucket, which is a stale snapshot of some
97
- /// pre-save master's selection. Empty removes the entry (deselect).
88
+ /// Replace `id`'s contribution.
98
89
  fn set_contribution(&mut self, id: &PanelId, filters: Vec<Filter>) -> bool {
99
90
  self.with_change(|s| {
100
91
  if filters.is_empty() {
@@ -109,11 +100,7 @@ impl GlobalFilterSet {
109
100
  })
110
101
  }
111
102
 
112
- /// Remove the flattened-view clause at `index` from EVERY bucket (a chip
113
- /// stands for the clause, not one bucket's copy — removing it from only
114
- /// one would resurface the duplicate). Returns whether the effective set
115
- /// changed and the OWNING master panels of the removed clause (for
116
- /// selection-state cleanup). Out-of-range is a no-op.
103
+ /// Remove the flattened-view clause at `index` from EVERY bucket.
117
104
  fn remove_clause(&mut self, index: usize) -> (bool, Vec<PanelId>) {
118
105
  let Some(clause) = self.flatten().get(index).cloned() else {
119
106
  return (false, Vec::new());
@@ -155,7 +142,7 @@ impl GlobalFilterSet {
155
142
  (changed, owners)
156
143
  }
157
144
 
158
- /// Whole-element `restore`: replace everything with an unattributed set.
145
+ /// `restoreWorkspace`: replace everything with an unattributed set.
159
146
  fn set_restored(&mut self, filters: Vec<Filter>) -> bool {
160
147
  self.with_change(|s| {
161
148
  s.contributions.clear();
@@ -164,19 +151,12 @@ impl GlobalFilterSet {
164
151
  }
165
152
  }
166
153
 
167
- /// A single, fully-independent viewer-like unit within a [`Workspace`]: its own
168
- /// [`Session`] (table binding + view config) and [`Renderer`] (active plugin).
154
+ /// A single, fully-independent viewer-like unit within a [`Workspace`].
169
155
  #[derive(Clone)]
170
156
  pub struct Panel {
171
157
  pub id: PanelId,
172
158
  pub session: Session,
173
159
  pub renderer: Renderer,
174
-
175
- /// Subscriptions owned for this panel's lifetime: its redraw subscription
176
- /// (`table_updated` → redraw) plus its custom-event fanout
177
- /// (`wire_panel_events`). Held here — not on the element — so they drop
178
- /// exactly when the panel is removed from the [`Workspace`], with no
179
- /// separate add/remove bookkeeping.
180
160
  _subs: Rc<Vec<Subscription>>,
181
161
  }
182
162
 
@@ -191,6 +171,120 @@ impl Panel {
191
171
  }
192
172
  }
193
173
 
174
+ /// A placed panel's layout phase.
175
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
176
+ pub enum PanelPhase {
177
+ Staging,
178
+ Placed,
179
+ }
180
+
181
+ /// A [`Panel`] as registered in the placed set.
182
+ struct PanelEntry {
183
+ panel: Panel,
184
+ phase: PanelPhase,
185
+ master: bool,
186
+ }
187
+
188
+ #[derive(Default)]
189
+ enum Reservation {
190
+ #[default]
191
+ Idle,
192
+ Pending(Panel),
193
+ Claimed {
194
+ has_table: bool,
195
+ },
196
+ }
197
+
198
+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
199
+ enum FlushState {
200
+ #[default]
201
+ Idle,
202
+ Queued {
203
+ layout: bool,
204
+ active: bool,
205
+ },
206
+ }
207
+
208
+ impl FlushState {
209
+ /// Merge newly-dirty events, returning the next state and whether a flush
210
+ /// task must be spawned.
211
+ fn mark(self, layout: bool, active: bool) -> (Self, bool) {
212
+ match self {
213
+ _ if !layout && !active => (self, false),
214
+ Self::Idle => (Self::Queued { layout, active }, true),
215
+ Self::Queued {
216
+ layout: l,
217
+ active: a,
218
+ } => (
219
+ Self::Queued {
220
+ layout: l || layout,
221
+ active: a || active,
222
+ },
223
+ false,
224
+ ),
225
+ }
226
+ }
227
+ }
228
+
229
+ #[derive(Default)]
230
+ struct LayoutEmitter {
231
+ state: Cell<FlushState>,
232
+ layout_changed: Rc<PubSub<Vec<PanelId>>>,
233
+ active_changed: Rc<PubSub<Option<PanelId>>>,
234
+ }
235
+
236
+ impl LayoutEmitter {
237
+ fn mark(&self, layout: bool, active: bool, workspace: &Workspace) {
238
+ let (next, spawn) = self.state.get().mark(layout, active);
239
+ self.state.set(next);
240
+ if spawn {
241
+ let workspace = workspace.clone();
242
+ spawn_owned("workspace_layout_flush", async move {
243
+ workspace.effects().settle().await;
244
+ workspace.flush_layout_events();
245
+ Ok(())
246
+ });
247
+ }
248
+ }
249
+ }
250
+
251
+ /// The loaded-[`Client`]s registry and the default-client designation. The
252
+ /// invariant "the default is always a registered client" holds in this one
253
+ /// impl: every default assignment registers first, and removal clears both.
254
+ #[derive(Default)]
255
+ struct ClientRegistry {
256
+ clients: Vec<Client>,
257
+ default: Option<Client>,
258
+ client_registered: Rc<PubSub<Client>>,
259
+ }
260
+
261
+ impl ClientRegistry {
262
+ /// Add `client` if a client with the same (globally unique) name isn't
263
+ /// already present, returning — for a genuinely-new client — the pubsub
264
+ /// for the caller to emit OUTSIDE its borrow.
265
+ fn register(&mut self, client: Client) -> Option<Rc<PubSub<Client>>> {
266
+ if self
267
+ .clients
268
+ .iter()
269
+ .any(|c| c.get_name() == client.get_name())
270
+ {
271
+ return None;
272
+ }
273
+
274
+ self.clients.push(client);
275
+ Some(self.client_registered.clone())
276
+ }
277
+
278
+ /// Drop the client named `name`, clearing the default designation if it
279
+ /// referred to it.
280
+ fn remove(&mut self, name: &str) {
281
+ self.clients.retain(|c| c.get_name() != name);
282
+ if self.default.as_ref().is_some_and(|c| c.get_name() == name) {
283
+ self.default = None;
284
+ }
285
+ }
286
+ }
287
+
194
288
  /// The multi-panel model backing a single `<perspective-viewer>`. See the
195
289
  /// module docs for the Phase 1 (single-panel) invariants.
196
290
  #[derive(Clone)]
@@ -203,62 +297,39 @@ impl PartialEq for Workspace {
203
297
  }
204
298
 
205
299
  struct WorkspaceData {
206
- /// Panels in insertion order.
207
- panels: Vec<Panel>,
300
+ /// Panels in insertion order, each with its per-panel attributes
301
+ /// (placement phase, master role).
302
+ panels: Vec<PanelEntry>,
208
303
 
209
304
  /// The currently active/selected panel (a live panel in `panels`).
210
305
  active: Option<PanelId>,
211
306
 
212
- /// The first [`Client`] loaded via the element's `load()`.
213
- default_client: Option<Client>,
214
-
215
- /// Every [`Client`] ever loaded into this element (registration order.
216
- clients: Vec<Client>,
307
+ /// The element-level global filters.
308
+ filters: GlobalFilterSet,
309
+ filters_changed: Rc<PubSub<()>>,
217
310
 
218
- /// Fires once per genuinely-new [`Client`] registration (post-dedup) —
219
- /// the element subscribes each new client's hosted-tables updates for
220
- /// reactive table binding (`tasks::table_lifecycle`).
221
- client_registered: Rc<PubSub<Client>>,
311
+ /// The loaded-clients registry + default designation.
312
+ clients: ClientRegistry,
222
313
 
223
314
  /// Monotonic counter backing [`Workspace::generate_id`].
224
315
  next_id: usize,
225
316
 
226
- /// Panels designated as master/detail filter sources.
227
- masters: HashSet<PanelId>,
317
+ /// The `load()` reservation slot (see [`Reservation`]).
318
+ reservation: Reservation,
228
319
 
229
- /// The element-level global filters.
230
- filters: GlobalFilterSet,
231
- filters_changed: Rc<PubSub<()>>,
232
-
233
- /// A layout tree staged by whole-element `restore`.
320
+ /// A layout tree staged by `restoreWorkspace`.
234
321
  pending_layout: Option<crate::js::Layout>,
235
322
 
236
- /// Freshly-created panels withheld from the `<regular-layout>`.
237
- staged: HashSet<PanelId>,
238
- staged_changed: Rc<PubSub<()>>,
239
-
240
- /// The RESERVED first panel of a pending `load()`.
241
- reserved: Option<Panel>,
242
-
243
323
  /// In-flight effects (public mutators + scheduled internal flows),
244
- /// drained by `flush()` — see [`EffectLedger`].
324
+ /// drained by `flush()`.
245
325
  effects: EffectLedger,
246
326
 
247
- /// The PLACED panel set changed since the last emit. Mutation sites set
248
- /// this and emit NOTHING; the coalescing flush task owns delivery (see
249
- /// [`Workspace::schedule_layout_flush`]).
250
- layout_dirty: bool,
251
- layout_changed: Rc<PubSub<Vec<PanelId>>>,
252
-
253
- /// The active panel changed since the last emit. A separate channel from
254
- /// `layout_dirty`: "which panel is selected" and "which panels exist" are
255
- /// distinct facts, and one event may not mean both.
256
- active_dirty: bool,
257
- active_changed: Rc<PubSub<Option<PanelId>>>,
327
+ /// Fires on every [`PanelPhase`] transition.
328
+ staged_changed: Rc<PubSub<()>>,
258
329
 
259
- /// A flush task is already queued — the flag that makes N mutations
260
- /// within one operation schedule ONE task rather than N.
261
- flush_scheduled: bool,
330
+ /// Coalesced `layout_changed`/`active_changed` delivery (see
331
+ /// [`LayoutEmitter`]).
332
+ emitter: LayoutEmitter,
262
333
  }
263
334
 
264
335
  impl Default for Workspace {
@@ -273,23 +344,15 @@ impl Workspace {
273
344
  Self(Rc::new(RefCell::new(WorkspaceData {
274
345
  panels: Vec::new(),
275
346
  active: None,
276
- default_client: None,
277
- clients: Vec::new(),
278
- client_registered: Rc::new(PubSub::default()),
279
- next_id: 0,
280
- masters: HashSet::new(),
281
347
  filters: GlobalFilterSet::default(),
282
348
  filters_changed: Rc::new(PubSub::default()),
349
+ clients: ClientRegistry::default(),
350
+ next_id: 0,
351
+ reservation: Reservation::Idle,
283
352
  pending_layout: None,
284
- staged: HashSet::new(),
285
- staged_changed: Rc::new(PubSub::default()),
286
- reserved: None,
287
353
  effects: EffectLedger::default(),
288
- layout_dirty: false,
289
- layout_changed: Rc::new(PubSub::default()),
290
- active_dirty: false,
291
- active_changed: Rc::new(PubSub::default()),
292
- flush_scheduled: false,
354
+ staged_changed: Rc::new(PubSub::default()),
355
+ emitter: LayoutEmitter::default(),
293
356
  })))
294
357
  }
295
358
 
@@ -299,63 +362,43 @@ impl Workspace {
299
362
  }
300
363
 
301
364
  pub fn layout_changed(&self) -> Rc<PubSub<Vec<PanelId>>> {
302
- self.0.borrow().layout_changed.clone()
365
+ self.0.borrow().emitter.layout_changed.clone()
303
366
  }
304
367
 
305
368
  pub fn active_changed(&self) -> Rc<PubSub<Option<PanelId>>> {
306
- self.0.borrow().active_changed.clone()
369
+ self.0.borrow().emitter.active_changed.clone()
307
370
  }
308
371
 
309
- /// Queue the coalescing layout-event flush, if anything is dirty and no
310
- /// flush is already pending.
311
- fn schedule_layout_flush(&self) {
312
- let schedule = {
313
- let mut data = self.0.borrow_mut();
314
- let dirty = data.layout_dirty || data.active_dirty;
315
- let queued = data.flush_scheduled;
316
- data.flush_scheduled |= dirty;
317
- dirty && !queued
318
- };
319
-
320
- if !schedule {
321
- return;
322
- }
323
-
324
- let effects = self.effects();
325
- let this = self.clone();
326
- spawn_owned("workspace_layout_flush", async move {
327
- effects.settle().await;
328
- this.flush_layout_events();
329
- Ok(())
330
- });
331
- }
332
-
333
- /// Emit whatever is dirty, outside any borrow.
372
+ /// Deliver whatever the queued flush recorded, outside any borrow — the
373
+ /// body of the task [`LayoutEmitter::mark`] spawns.
334
374
  fn flush_layout_events(&self) {
335
- let (layout, active, panels, active_id, layout_pubsub, active_pubsub) = {
336
- let mut data = self.0.borrow_mut();
337
- data.flush_scheduled = false;
375
+ let (state, panels, active_id, layout_pubsub, active_pubsub) = {
376
+ let data = self.0.borrow();
338
377
  (
339
- std::mem::take(&mut data.layout_dirty),
340
- std::mem::take(&mut data.active_dirty),
341
- data.panels.iter().map(|p| p.id.clone()).collect::<Vec<_>>(),
378
+ data.emitter.state.replace(FlushState::Idle),
379
+ data.panels
380
+ .iter()
381
+ .map(|p| p.panel.id.clone())
382
+ .collect::<Vec<_>>(),
342
383
  data.active.clone(),
343
- data.layout_changed.clone(),
344
- data.active_changed.clone(),
384
+ data.emitter.layout_changed.clone(),
385
+ data.emitter.active_changed.clone(),
345
386
  )
346
387
  };
347
388
 
348
- if layout {
349
- layout_pubsub.emit(panels);
350
- }
389
+ if let FlushState::Queued { layout, active } = state {
390
+ if layout {
391
+ layout_pubsub.emit(panels);
392
+ }
351
393
 
352
- if active {
353
- active_pubsub.emit(active_id);
394
+ if active {
395
+ active_pubsub.emit(active_id);
396
+ }
354
397
  }
355
398
  }
356
399
 
357
400
  /// Stage a layout tree for `MainPanel` to apply at its next `rendered`
358
- /// pass (see `WorkspaceData::pending_layout`).
401
+ /// pass (see [`WorkspaceData::pending_layout`]).
359
402
  pub fn set_pending_layout(&self, layout: crate::js::Layout) {
360
403
  self.0.borrow_mut().pending_layout = Some(layout);
361
404
  }
@@ -371,38 +414,42 @@ impl Workspace {
371
414
  self.0.borrow().staged_changed.clone()
372
415
  }
373
416
 
374
- /// Mark a freshly-created panel STAGED (see [`WorkspaceData::staged`]).
375
- /// Emits `staged_changed` — outside the borrow.
376
- pub fn stage_panel(&self, id: &PanelId) {
377
- let pubsub = {
378
- let mut data = self.0.borrow_mut();
379
- data.staged.insert(id.clone());
380
- data.staged_changed.clone()
381
- };
382
-
383
- pubsub.emit(());
384
- }
385
-
386
417
  /// Promote a staged panel toward layout insertion, returning whether it
387
418
  /// was still staged — restore completion and the staging deadline RACE
388
419
  /// to promote, and only the winner proceeds. Emits `staged_changed` —
389
- /// outside the borrow — iff the set changed.
390
- pub fn clear_staged(&self, id: &PanelId) -> bool {
391
- let (removed, pubsub) = {
420
+ /// outside the borrow — iff the phase changed.
421
+ pub fn promote(&self, id: &PanelId) -> bool {
422
+ let (promoted, pubsub) = {
392
423
  let mut data = self.0.borrow_mut();
393
- (data.staged.remove(id), data.staged_changed.clone())
424
+ let promoted = data
425
+ .panels
426
+ .iter_mut()
427
+ .find(|p| &p.panel.id == id)
428
+ .map(|entry| {
429
+ matches!(
430
+ std::mem::replace(&mut entry.phase, PanelPhase::Placed),
431
+ PanelPhase::Staging
432
+ )
433
+ })
434
+ .unwrap_or(false);
435
+
436
+ (promoted, data.staged_changed.clone())
394
437
  };
395
438
 
396
- if removed {
439
+ if promoted {
397
440
  pubsub.emit(());
398
441
  }
399
442
 
400
- removed
443
+ promoted
401
444
  }
402
445
 
403
446
  /// Whether `id` is a staged (created, not yet layout-inserted) panel.
404
447
  pub fn is_staged(&self, id: &PanelId) -> bool {
405
- self.0.borrow().staged.contains(id)
448
+ self.0
449
+ .borrow()
450
+ .panels
451
+ .iter()
452
+ .any(|p| &p.panel.id == id && p.phase == PanelPhase::Staging)
406
453
  }
407
454
 
408
455
  /// The EFFECTIVE element-level global filters (master/detail
@@ -435,10 +482,7 @@ impl Workspace {
435
482
  payload
436
483
  }
437
484
 
438
- /// Replace the global filter set with an UNATTRIBUTED (restored) bucket,
439
- /// dropping every master contribution — the whole-element `restore`
440
- /// entry point. Callers push the new set into panel sessions via
441
- /// `tasks::apply_global_filters`.
485
+ /// Replace the global filter set.
442
486
  pub fn set_global_filters(&self, filters: Vec<Filter>) {
443
487
  self.mutate_filters(|s| (s.set_restored(filters), ()));
444
488
  }
@@ -454,52 +498,61 @@ impl Workspace {
454
498
  self.mutate_filters(|s| (s.set_contribution(id, Vec::new()), ()));
455
499
  }
456
500
 
457
- /// Remove the effective-set clause at `index` (the `GlobalFilterBar`
458
- /// chip ×), returning the OWNING master panels of the removed clause so
459
- /// the caller can clear their selection state
460
- /// (`tasks::clear_master_selections`).
501
+ /// Remove the effective-set clause at `index`.
461
502
  pub fn remove_global_filter(&self, index: usize) -> Vec<PanelId> {
462
503
  self.mutate_filters(|s| s.remove_clause(index))
463
504
  }
464
505
 
465
506
  /// Drop the entire global filter set (the `GlobalFilterBar` "Clear" /
466
- /// element `reset()`), returning the contribution owners for
467
- /// selection-state cleanup. Master ROLES are untouched — like the
468
- /// layout, they are workspace structure, not filter state.
507
+ /// element `reset()`).
469
508
  pub fn clear_global_filters(&self) -> Vec<PanelId> {
470
509
  self.mutate_filters(GlobalFilterSet::clear)
471
510
  }
472
511
 
473
- /// The master (filter-source) panel ids, SORTED — `save()` serializes
474
- /// this, and a `HashSet`'s per-instance iteration order would make
475
- /// consecutive `save()` outputs byte-unstable (cf. the `panels`
476
- /// `BTreeMap`).
512
+ /// The master (filter-source) panel ids.
477
513
  pub fn masters(&self) -> Vec<PanelId> {
478
- let mut masters: Vec<_> = self.0.borrow().masters.iter().cloned().collect();
514
+ let mut masters: Vec<_> = self
515
+ .0
516
+ .borrow()
517
+ .panels
518
+ .iter()
519
+ .filter(|p| p.master)
520
+ .map(|p| p.panel.id.clone())
521
+ .collect();
522
+
479
523
  masters.sort();
480
524
  masters
481
525
  }
482
526
 
483
- /// Replace the master role set — the whole-element `restore` entry point
484
- /// (ids already remapped to the fresh panel ids).
527
+ /// Replace the master role set — the `restoreWorkspace` entry point
528
+ /// (ids already remapped to the fresh panel ids). Ids naming no panel are
529
+ /// dropped (the caller has already warned on them).
485
530
  pub fn set_masters(&self, ids: Vec<PanelId>) {
486
- self.0.borrow_mut().masters = ids.into_iter().collect();
531
+ let mut data = self.0.borrow_mut();
532
+ for entry in data.panels.iter_mut() {
533
+ entry.master = ids.contains(&entry.panel.id);
534
+ }
487
535
  }
488
536
 
489
537
  /// Whether `id` is a master (filter-source) panel.
490
538
  pub fn is_master(&self, id: &PanelId) -> bool {
491
- self.0.borrow().masters.contains(id)
539
+ self.0
540
+ .borrow()
541
+ .panels
542
+ .iter()
543
+ .any(|p| &p.panel.id == id && p.master)
492
544
  }
493
545
 
494
546
  /// Toggle `id`'s master/detail role, returning the new state (`true` =
495
- /// master).
547
+ /// master). `false` (no role) if `id` names no panel.
496
548
  pub fn toggle_master(&self, id: &PanelId) -> bool {
497
549
  let mut data = self.0.borrow_mut();
498
- if data.masters.remove(id) {
499
- false
500
- } else {
501
- data.masters.insert(id.clone());
502
- true
550
+ match data.panels.iter_mut().find(|p| &p.panel.id == id) {
551
+ Some(entry) => {
552
+ entry.master = !entry.master;
553
+ entry.master
554
+ },
555
+ None => false,
503
556
  }
504
557
  }
505
558
 
@@ -521,7 +574,10 @@ impl Workspace {
521
574
  pub fn active_panel(&self) -> Option<Panel> {
522
575
  let data = self.0.borrow();
523
576
  let active = data.active.as_ref()?;
524
- data.panels.iter().find(|p| &p.id == active).cloned()
577
+ data.panels
578
+ .iter()
579
+ .find(|p| &p.panel.id == active)
580
+ .map(|p| p.panel.clone())
525
581
  }
526
582
 
527
583
  /// The active panel's [`Session`], or `None` with zero panels.
@@ -536,7 +592,12 @@ impl Workspace {
536
592
 
537
593
  /// Look up a [`Panel`] by id.
538
594
  pub fn panel(&self, id: &PanelId) -> Option<Panel> {
539
- self.0.borrow().panels.iter().find(|p| &p.id == id).cloned()
595
+ self.0
596
+ .borrow()
597
+ .panels
598
+ .iter()
599
+ .find(|p| &p.panel.id == id)
600
+ .map(|p| p.panel.clone())
540
601
  }
541
602
 
542
603
  /// Resolve a panel by id, or the active panel when `id` is `None` — the
@@ -552,17 +613,23 @@ impl Workspace {
552
613
  /// fan-out source (fan-outs collect per-panel results; they never
553
614
  /// sequential-abort on one panel's error).
554
615
  pub fn panels(&self) -> Vec<Panel> {
555
- self.0.borrow().panels.to_vec()
616
+ self.0
617
+ .borrow()
618
+ .panels
619
+ .iter()
620
+ .map(|p| p.panel.clone())
621
+ .collect()
556
622
  }
557
623
 
558
624
  /// The number of PLACED panels (panels minus staged) — the single
559
625
  /// source for panel-count chrome (`single`/`multi`, closable,
560
626
  /// draggable, `only-child`).
561
627
  pub fn placed_count(&self) -> usize {
562
- let data = self.0.borrow();
563
- data.panels
628
+ self.0
629
+ .borrow()
630
+ .panels
564
631
  .iter()
565
- .filter(|p| !data.staged.contains(&p.id))
632
+ .filter(|p| p.phase == PanelPhase::Placed)
566
633
  .count()
567
634
  }
568
635
 
@@ -572,7 +639,7 @@ impl Workspace {
572
639
  .borrow()
573
640
  .panels
574
641
  .iter()
575
- .map(|p| p.id.clone())
642
+ .map(|p| p.panel.id.clone())
576
643
  .collect()
577
644
  }
578
645
 
@@ -586,56 +653,119 @@ impl Workspace {
586
653
  self.0.borrow().panels.is_empty()
587
654
  }
588
655
 
589
- /// Append a [`Panel`]. When the element had zero panels, the inserted panel
590
- /// becomes the active one (there is no other candidate).
591
- pub fn insert_panel(&self, panel: Panel) {
592
- {
656
+ /// Append a [`Panel`] at `phase`. When the element had zero panels, the
657
+ /// inserted panel becomes the active one (there is no other candidate).
658
+ /// A [`PanelPhase::Staging`] insert emits `staged_changed` — outside the
659
+ /// borrow — so the phase and its announcement are one operation.
660
+ pub fn insert_panel(&self, panel: Panel, phase: PanelPhase) {
661
+ let staged_pubsub = {
593
662
  let mut data = self.0.borrow_mut();
594
- if data.active.is_none() {
663
+ let activated = if data.active.is_none() {
595
664
  data.active = Some(panel.id.clone());
596
- data.active_dirty = true;
597
- }
665
+ true
666
+ } else {
667
+ false
668
+ };
598
669
 
599
670
  panel
600
671
  .renderer
601
672
  .set_active_flag(data.active.as_ref() == Some(&panel.id));
602
- data.panels.push(panel);
603
- data.layout_dirty = true;
673
+ data.panels.push(PanelEntry {
674
+ panel,
675
+ phase,
676
+ master: false,
677
+ });
678
+
604
679
  Self::sync_solo_flags(&data);
605
- }
680
+ data.emitter.mark(true, activated, self);
681
+ (phase == PanelPhase::Staging).then(|| data.staged_changed.clone())
682
+ };
606
683
 
607
- self.schedule_layout_flush();
684
+ if let Some(pubsub) = staged_pubsub {
685
+ pubsub.emit(());
686
+ }
608
687
  }
609
688
 
610
- /// Hold `panel` in the reservation slot (see [`WorkspaceData::reserved`]):
689
+ /// Hold `panel` in the reservation slot ([`Reservation::Pending`]):
611
690
  /// NOT placed, invisible to every placed-panel consumer, awaiting a
612
691
  /// pending `load()`'s payload classification or an interim claimant.
692
+ /// Resets any prior claim record — the slot serves one `load()` cycle at
693
+ /// a time.
613
694
  pub fn reserve_panel(&self, panel: Panel) {
614
- self.0.borrow_mut().reserved = Some(panel);
695
+ let mut data = self.0.borrow_mut();
696
+ debug_assert!(
697
+ !matches!(data.reservation, Reservation::Pending(_)),
698
+ "reservation overwritten while pending — adopt via `reserved_panel` first"
699
+ );
700
+
701
+ data.reservation = Reservation::Pending(panel);
615
702
  }
616
703
 
617
704
  /// The reservation slot's current occupant (shared handles), without a
618
705
  /// transfer — a second `load()` on a still-empty element adopts the same
619
706
  /// reservation rather than creating a competing one.
620
707
  pub fn reserved_panel(&self) -> Option<Panel> {
621
- self.0.borrow().reserved.clone()
708
+ match &self.0.borrow().reservation {
709
+ Reservation::Pending(panel) => Some(panel.clone()),
710
+ _ => None,
711
+ }
622
712
  }
623
713
 
624
- /// PLACE the reserved panel: transfer it out of the reservation slot into
714
+ /// CLAIM the reserved panel: transfer it out of the reservation slot into
625
715
  /// the placed set ([`Self::insert_panel`] — auto-activating on an empty
626
- /// element). `None` when the slot is empty (no reservation, or the other
627
- /// actor transferred first).
628
- pub fn claim_reserved(&self) -> Option<Panel> {
629
- let panel = self.0.borrow_mut().reserved.take()?;
630
- self.insert_panel(panel.clone());
716
+ /// element), recording `has_table` — whether the claimant carried a
717
+ /// `table` — atomically with the transfer ([`Reservation::Claimed`]).
718
+ /// `None` when nothing is pending (no reservation, or the other actor
719
+ /// transferred first).
720
+ pub fn claim_reserved(&self, has_table: bool) -> Option<Panel> {
721
+ let panel = {
722
+ let mut data = self.0.borrow_mut();
723
+ match std::mem::take(&mut data.reservation) {
724
+ Reservation::Pending(panel) => {
725
+ data.reservation = Reservation::Claimed { has_table };
726
+ Some(panel)
727
+ },
728
+ other => {
729
+ data.reservation = other;
730
+ None
731
+ },
732
+ }
733
+ }?;
734
+
735
+ self.insert_panel(panel.clone(), PanelPhase::Placed);
631
736
  Some(panel)
632
737
  }
633
738
 
634
739
  /// DISCARD the reserved panel: transfer it out of the reservation slot
635
740
  /// WITHOUT placing it, for disposal (an inert `Client` payload with no
636
- /// claimant, or teardown draining). `None` when the slot is empty.
741
+ /// claimant, or teardown draining). `None` when nothing is pending; a
742
+ /// claim record is left intact for [`Self::resolve_claim`].
637
743
  pub fn take_reserved(&self) -> Option<Panel> {
638
- self.0.borrow_mut().reserved.take()
744
+ let mut data = self.0.borrow_mut();
745
+ match std::mem::take(&mut data.reservation) {
746
+ Reservation::Pending(panel) => Some(panel),
747
+ other => {
748
+ data.reservation = other;
749
+ None
750
+ },
751
+ }
752
+ }
753
+
754
+ /// ONE-SHOT read of the claim record: `Some(has_table)` if the
755
+ /// reservation was claimed ([`Reservation::Claimed`]) — resetting the
756
+ /// slot to [`Reservation::Idle`] — or `None` if no claim happened.
757
+ /// Consuming closes the stale-flag window the former boolean had —
758
+ /// `load()`'s inert-`Client` epilogue is the only reader, and reads it
759
+ /// exactly once (eviction arms on `Some(false)`).
760
+ pub fn resolve_claim(&self) -> Option<bool> {
761
+ let mut data = self.0.borrow_mut();
762
+ match data.reservation {
763
+ Reservation::Claimed { has_table } => {
764
+ data.reservation = Reservation::Idle;
765
+ Some(has_table)
766
+ },
767
+ _ => None,
768
+ }
639
769
  }
640
770
 
641
771
  /// Sync every panel renderer's solo (lone-panel) flag with the current
@@ -644,83 +774,79 @@ impl Workspace {
644
774
  /// locked plugin dispatch — see `Renderer::stamp_active`).
645
775
  fn sync_solo_flags(data: &WorkspaceData) {
646
776
  let is_solo = data.panels.len() == 1;
647
- for panel in data.panels.iter() {
648
- panel.renderer.set_solo_flag(is_solo);
777
+ for entry in data.panels.iter() {
778
+ entry.panel.renderer.set_solo_flag(is_solo);
649
779
  }
650
780
  }
651
781
 
652
- /// Remove a [`Panel`] by id, returning it if present.
653
- /// Remove a [`Panel`] by id, returning it if present. Model cleanup is
654
- /// structural: EVERY removal path (close, whole-element restore's batch
655
- /// replacement) drops the panel's master role and its global-filter
656
- /// contribution here, so neither can outlive the panel.
782
+ /// Remove a [`Panel`] by id, returning it if present. The entry's
783
+ /// attributes (phase, master role) leave with it structurally; its
784
+ /// global-filter contribution is dropped here, so neither can outlive
785
+ /// the panel on ANY removal path (close, `restoreWorkspace`'s batch
786
+ /// replacement).
657
787
  pub fn remove_panel(&self, id: &PanelId) -> Option<Panel> {
658
- let (removed, changed, pubsub, staged_removed, staged_pubsub) = {
788
+ let (removed, changed, filters_pubsub, was_staged, staged_pubsub) = {
659
789
  let mut data = self.0.borrow_mut();
660
- data.masters.remove(id);
661
- let staged_removed = data.staged.remove(id);
662
790
  let changed = data.filters.set_contribution(id, Vec::new());
663
- let removed = data
664
- .panels
665
- .iter()
666
- .position(|p| &p.id == id)
667
- .map(|idx| data.panels.remove(idx));
668
-
669
- if data.active.as_ref() == Some(id) {
791
+ let (removed, was_staged) = match data.panels.iter().position(|p| &p.panel.id == id) {
792
+ Some(idx) => {
793
+ let entry = data.panels.remove(idx);
794
+ (Some(entry.panel), entry.phase == PanelPhase::Staging)
795
+ },
796
+ None => (None, false),
797
+ };
798
+
799
+ let deactivated = data.active.as_ref() == Some(id);
800
+ if deactivated {
670
801
  data.active = None;
671
- data.active_dirty = true;
672
802
  }
673
803
 
674
- data.layout_dirty |= removed.is_some();
675
804
  Self::sync_solo_flags(&data);
805
+ data.emitter.mark(removed.is_some(), deactivated, self);
676
806
  (
677
807
  removed,
678
808
  changed,
679
809
  data.filters_changed.clone(),
680
- staged_removed,
810
+ was_staged,
681
811
  data.staged_changed.clone(),
682
812
  )
683
813
  };
684
814
 
685
815
  if changed {
686
- pubsub.emit(());
816
+ filters_pubsub.emit(());
687
817
  }
688
818
 
689
- if staged_removed {
819
+ if was_staged {
690
820
  staged_pubsub.emit(());
691
821
  }
692
822
 
693
- self.schedule_layout_flush();
694
823
  removed
695
824
  }
696
825
 
697
826
  /// Set the active panel. Returns `false` (no-op) if `id` is not a known
698
827
  /// panel.
699
828
  pub fn set_active(&self, id: PanelId) -> bool {
700
- let known = {
701
- let mut data = self.0.borrow_mut();
702
- if data.panels.iter().any(|p| p.id == id) {
703
- data.active_dirty |= data.active.as_ref() != Some(&id);
704
- data.active = Some(id);
705
- for panel in data.panels.iter() {
706
- panel
707
- .renderer
708
- .set_active_flag(data.active.as_ref() == Some(&panel.id));
709
- }
710
-
711
- true
712
- } else {
713
- false
829
+ let mut data = self.0.borrow_mut();
830
+ if data.panels.iter().any(|p| p.panel.id == id) {
831
+ let changed = data.active.as_ref() != Some(&id);
832
+ data.active = Some(id);
833
+ for entry in data.panels.iter() {
834
+ entry
835
+ .panel
836
+ .renderer
837
+ .set_active_flag(data.active.as_ref() == Some(&entry.panel.id));
714
838
  }
715
- };
716
839
 
717
- self.schedule_layout_flush();
718
- known
840
+ data.emitter.mark(false, changed, self);
841
+ true
842
+ } else {
843
+ false
844
+ }
719
845
  }
720
846
 
721
847
  /// The default [`Client`], if one has been loaded.
722
848
  pub fn default_client(&self) -> Option<Client> {
723
- self.0.borrow().default_client.clone()
849
+ self.0.borrow().clients.default.clone()
724
850
  }
725
851
 
726
852
  /// The active panel's bound [`Client`], if any — the default target of a
@@ -736,8 +862,13 @@ impl Workspace {
736
862
  .borrow()
737
863
  .panels
738
864
  .iter()
739
- .filter(|p| p.session.get_client().is_some_and(|c| c.get_name() == name))
740
- .map(|p| p.id.clone())
865
+ .filter(|p| {
866
+ p.panel
867
+ .session
868
+ .get_client()
869
+ .is_some_and(|c| c.get_name() == name)
870
+ })
871
+ .map(|p| p.panel.id.clone())
741
872
  .collect()
742
873
  }
743
874
 
@@ -747,26 +878,27 @@ impl Workspace {
747
878
  /// [`Workspace::panels_for_client`]) — `clients()` unions in live panel
748
879
  /// sessions, so a lingering panel would resurrect it.
749
880
  pub fn remove_client(&self, name: &str) {
750
- let mut data = self.0.borrow_mut();
751
- data.clients.retain(|c| c.get_name() != name);
752
- if data
753
- .default_client
754
- .as_ref()
755
- .is_some_and(|c| c.get_name() == name)
756
- {
757
- data.default_client = None;
758
- }
881
+ self.0.borrow_mut().clients.remove(name);
759
882
  }
760
883
 
761
884
  /// Record the default [`Client`] if not already set (first-wins, matching
762
885
  /// the "first `Client` passed to `load()` is the default" rule). Always
763
886
  /// registers the client (see [`Workspace::register_client`]) — first-wins
764
- /// applies only to the *default* designation.
887
+ /// applies only to the *default* designation, which is assigned BEFORE
888
+ /// the registration event fires, so listeners observe both together.
765
889
  pub fn set_default_client(&self, client: Client) {
766
- self.register_client(client.clone());
767
- let mut data = self.0.borrow_mut();
768
- if data.default_client.is_none() {
769
- data.default_client = Some(client);
890
+ let pubsub = {
891
+ let mut data = self.0.borrow_mut();
892
+ let pubsub = data.clients.register(client.clone());
893
+ if data.clients.default.is_none() {
894
+ data.clients.default = Some(client.clone());
895
+ }
896
+
897
+ pubsub
898
+ };
899
+
900
+ if let Some(pubsub) = pubsub {
901
+ pubsub.emit(client);
770
902
  }
771
903
  }
772
904
 
@@ -774,27 +906,15 @@ impl Workspace {
774
906
  /// same (globally unique) name isn't already present. Emits
775
907
  /// `client_registered` — outside the borrow — for a genuinely-new client.
776
908
  pub fn register_client(&self, client: Client) {
777
- let pubsub = {
778
- let mut data = self.0.borrow_mut();
779
- if data
780
- .clients
781
- .iter()
782
- .any(|c| c.get_name() == client.get_name())
783
- {
784
- return;
785
- }
786
-
787
- data.clients.push(client.clone());
788
- data.client_registered.clone()
789
- };
790
-
791
- pubsub.emit(client);
909
+ let pubsub = self.0.borrow_mut().clients.register(client.clone());
910
+ if let Some(pubsub) = pubsub {
911
+ pubsub.emit(client);
912
+ }
792
913
  }
793
914
 
794
- /// A handle to the `client_registered` PubSub (see
795
- /// [`WorkspaceData::client_registered`]).
915
+ /// A handle to the `client_registered` PubSub (see [`ClientRegistry`]).
796
916
  pub fn client_registered(&self) -> Rc<PubSub<Client>> {
797
- self.0.borrow().client_registered.clone()
917
+ self.0.borrow().clients.client_registered.clone()
798
918
  }
799
919
 
800
920
  /// All loaded [`Client`]s: the registry, unioned with every panel
@@ -802,9 +922,9 @@ impl Workspace {
802
922
  /// bypasses registration), deduped by name in registration order.
803
923
  pub fn clients(&self) -> Vec<Client> {
804
924
  let data = self.0.borrow();
805
- let mut clients = data.clients.clone();
806
- for panel in &data.panels {
807
- if let Some(client) = panel.session.get_client()
925
+ let mut clients = data.clients.clients.clone();
926
+ for entry in &data.panels {
927
+ if let Some(client) = entry.panel.session.get_client()
808
928
  && !clients.iter().any(|c| c.get_name() == client.get_name())
809
929
  {
810
930
  clients.push(client);
@@ -935,4 +1055,47 @@ mod tests {
935
1055
  assert_eq!(s.flatten(), Vec::<Filter>::new());
936
1056
  assert_eq!(s.clear(), (false, Vec::new()));
937
1057
  }
1058
+
1059
+ #[test]
1060
+ fn flush_state_spawns_exactly_once_per_cycle() {
1061
+ let (state, spawn) = FlushState::Idle.mark(true, false);
1062
+ assert!(spawn);
1063
+ assert_eq!(state, FlushState::Queued {
1064
+ layout: true,
1065
+ active: false
1066
+ });
1067
+
1068
+ // Subsequent marks merge without re-spawning.
1069
+ let (state, spawn) = state.mark(false, true);
1070
+ assert!(!spawn);
1071
+ assert_eq!(state, FlushState::Queued {
1072
+ layout: true,
1073
+ active: true
1074
+ });
1075
+
1076
+ let (state, spawn) = state.mark(true, true);
1077
+ assert!(!spawn);
1078
+ assert_eq!(state, FlushState::Queued {
1079
+ layout: true,
1080
+ active: true
1081
+ });
1082
+ }
1083
+
1084
+ #[test]
1085
+ fn flush_state_ignores_empty_marks() {
1086
+ // A no-event mark neither queues nor spawns — `Queued{false,false}`
1087
+ // is unconstructible through `mark`.
1088
+ let (state, spawn) = FlushState::Idle.mark(false, false);
1089
+ assert!(!spawn);
1090
+ assert_eq!(state, FlushState::Idle);
1091
+
1092
+ let queued = FlushState::Queued {
1093
+ layout: true,
1094
+ active: false,
1095
+ };
1096
+
1097
+ let (state, spawn) = queued.mark(false, false);
1098
+ assert!(!spawn);
1099
+ assert_eq!(state, queued);
1100
+ }
938
1101
  }