@perspective-dev/viewer 5.4.0 → 5.5.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 (142) hide show
  1. package/dist/cdn/perspective-viewer.js +4 -4
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/blueprint.css +1 -1
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -1
  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/ar.css +1 -1
  11. package/dist/css/intl/de.css +1 -1
  12. package/dist/css/intl/es.css +1 -1
  13. package/dist/css/intl/fr.css +1 -1
  14. package/dist/css/intl/ja.css +1 -1
  15. package/dist/css/intl/pt.css +1 -1
  16. package/dist/css/intl/zh.css +1 -1
  17. package/dist/css/intl.css +1 -1
  18. package/dist/css/ledger.css +1 -1
  19. package/dist/css/monokai.css +1 -1
  20. package/dist/css/nord.css +1 -1
  21. package/dist/css/phosphor.css +1 -1
  22. package/dist/css/pro-dark.css +1 -1
  23. package/dist/css/pro.css +1 -1
  24. package/dist/css/solarized-dark.css +1 -1
  25. package/dist/css/solarized.css +1 -1
  26. package/dist/css/themes.css +1 -1
  27. package/dist/css/vaporwave.css +1 -1
  28. package/dist/css/velvet.css +1 -1
  29. package/dist/docs/perspective-docs.json +1 -1
  30. package/dist/esm/perspective-viewer.d.ts +2 -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 +4 -4
  34. package/dist/esm/perspective-viewer.js.map +2 -2
  35. package/dist/esm/plugin.d.ts +9 -0
  36. package/dist/esm/ts-rs/AddPanelOptions.d.ts +9 -0
  37. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -1
  38. package/dist/esm/ts-rs/GetViewMode.d.ts +4 -0
  39. package/dist/esm/ts-rs/GetViewOptions.d.ts +8 -0
  40. package/dist/esm/ts-rs/RestoreOptions.d.ts +6 -0
  41. package/dist/esm/ts-rs/RestoreWorkspaceOptions.d.ts +9 -0
  42. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +30 -22
  43. package/dist/wasm/perspective-viewer.d.ts +102 -47
  44. package/dist/wasm/perspective-viewer.js +233 -142
  45. package/dist/wasm/perspective-viewer.wasm +0 -0
  46. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -18
  47. package/package.json +7 -7
  48. package/src/css/column-selector.css +11 -3
  49. package/src/css/column-style.css +162 -0
  50. package/src/css/config-selector.css +1 -1
  51. package/src/css/form/debug.css +9 -0
  52. package/src/css/status-bar.css +11 -0
  53. package/src/css/ui/containers/tabs.css +2 -1
  54. package/src/rust/agent/tools.rs +80 -7
  55. package/src/rust/components/column_selector/active_column.rs +9 -0
  56. package/src/rust/components/column_selector.rs +11 -0
  57. package/src/rust/components/column_settings_sidebar/style_tab/alignment_field.rs +90 -0
  58. package/src/rust/components/column_settings_sidebar/style_tab/font_field.rs +267 -0
  59. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +1 -1
  60. package/src/rust/components/column_settings_sidebar/style_tab.rs +68 -9
  61. package/src/rust/components/debug_panel.rs +107 -50
  62. package/src/rust/components/export_dropdown.rs +6 -0
  63. package/src/rust/components/main_panel/msg.rs +4 -0
  64. package/src/rust/components/main_panel.rs +14 -0
  65. package/src/rust/components/mod.rs +0 -1
  66. package/src/rust/components/plugin_tab.rs +57 -3
  67. package/src/rust/components/settings_panel.rs +6 -0
  68. package/src/rust/components/status_bar.rs +12 -3
  69. package/src/rust/components/status_indicator.rs +4 -0
  70. package/src/rust/components/viewer/filters.rs +2 -2
  71. package/src/rust/components/viewer/msg.rs +4 -0
  72. package/src/rust/components/viewer/panels.rs +6 -3
  73. package/src/rust/components/viewer/render.rs +1 -0
  74. package/src/rust/components/viewer/wiring.rs +14 -2
  75. package/src/rust/components/viewer.rs +2 -2
  76. package/src/rust/config/column_config_schema.rs +312 -8
  77. package/src/rust/config/datetime_column_style.rs +0 -11
  78. package/src/rust/config/export_method.rs +24 -0
  79. package/src/rust/config/mod.rs +0 -2
  80. package/src/rust/config/options.rs +50 -0
  81. package/src/rust/config/workspace_config.rs +69 -24
  82. package/src/rust/custom_elements/viewer.rs +214 -113
  83. package/src/rust/js/plugin.rs +3 -1
  84. package/src/rust/lib.rs +7 -3
  85. package/src/rust/queries/plugin_column_styles.rs +6 -5
  86. package/src/rust/renderer/plugin_config.rs +200 -41
  87. package/src/rust/renderer/plugin_store.rs +2 -1
  88. package/src/rust/renderer/props.rs +5 -0
  89. package/src/rust/renderer.rs +9 -0
  90. package/src/rust/session/metadata.rs +5 -10
  91. package/src/rust/session/props.rs +4 -0
  92. package/src/rust/session.rs +273 -195
  93. package/src/rust/tasks/copy_export.rs +67 -0
  94. package/src/rust/tasks/create_panel.rs +16 -13
  95. package/src/rust/tasks/pipeline.rs +26 -8
  96. package/src/rust/tasks/restore_panel.rs +129 -30
  97. package/src/rust/tasks/send_column_config.rs +3 -0
  98. package/src/rust/tasks/sync_update_panels.rs +148 -68
  99. package/src/rust/tasks/table_lifecycle.rs +7 -1
  100. package/src/rust/ui/containers/tab_list.rs +1 -1
  101. package/src/rust/ui/form/intl_label.rs +4 -23
  102. package/src/rust/ui/portal.rs +1 -0
  103. package/src/rust/utils/css_vars.rs +13 -0
  104. package/src/rust/{config/datetime_column_style/color_mode.rs → utils/in_flight.rs} +47 -35
  105. package/src/rust/utils/local_fonts.rs +395 -0
  106. package/src/rust/utils/mod.rs +4 -0
  107. package/src/rust/utils/pending_effects.rs +6 -30
  108. package/src/rust/workspace.rs +12 -0
  109. package/src/svg/status_pending.svg +3 -0
  110. package/src/themes/icons.css +1 -0
  111. package/src/themes/intl/ar.css +20 -7
  112. package/src/themes/intl/de.css +20 -7
  113. package/src/themes/intl/es.css +20 -7
  114. package/src/themes/intl/fr.css +20 -7
  115. package/src/themes/intl/ja.css +20 -7
  116. package/src/themes/intl/pt.css +20 -7
  117. package/src/themes/intl/zh.css +20 -7
  118. package/src/themes/intl.css +20 -7
  119. package/src/themes/monokai.css +1 -1
  120. package/src/themes/vaporwave.css +1 -1
  121. package/src/ts/perspective-viewer.ts +2 -0
  122. package/src/ts/plugin.ts +9 -0
  123. package/src/ts/ts-rs/AddPanelOptions.ts +10 -0
  124. package/src/ts/ts-rs/ExportMethod.ts +1 -1
  125. package/src/ts/ts-rs/{DatetimeColorMode.ts → GetViewMode.ts} +4 -1
  126. package/src/ts/ts-rs/GetViewOptions.ts +7 -0
  127. package/src/ts/ts-rs/RestoreOptions.ts +7 -1
  128. package/src/ts/ts-rs/RestoreWorkspaceOptions.ts +10 -0
  129. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +31 -20
  130. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +0 -1
  131. package/dist/esm/ts-rs/FormatMode.d.ts +0 -1
  132. package/dist/esm/ts-rs/StringColorMode.d.ts +0 -1
  133. package/src/rust/components/string_column_style.rs +0 -120
  134. package/src/rust/config/string_column_style.rs +0 -135
  135. package/src/ts/ts-rs/FormatMode.ts +0 -3
  136. package/src/ts/ts-rs/StringColorMode.ts +0 -3
  137. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline0.js +0 -0
  138. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline1.js +0 -0
  139. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline2.js +0 -0
  140. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline3.js +0 -0
  141. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline4.js +0 -0
  142. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline5.js +0 -0
@@ -21,7 +21,7 @@ use std::cell::{Cell, Ref, RefCell};
21
21
  use std::collections::{HashMap, HashSet};
22
22
  use std::future::Future;
23
23
  use std::ops::Deref;
24
- use std::rc::Rc;
24
+ use std::rc::{Rc, Weak};
25
25
 
26
26
  use perspective_client::config::*;
27
27
  use perspective_client::proto::ViewDimensionsResp;
@@ -71,12 +71,8 @@ pub struct SessionHandle {
71
71
  pub view_config_changed: PubSub<()>,
72
72
  pub title_changed: PubSub<Option<String>>,
73
73
 
74
- /// Count of in-flight CONFIG-DRIVEN pipeline runs.
75
- in_flight_config_runs: Cell<u32>,
76
-
77
- /// Fires with the ABSOLUTE [`Self::in_flight_config_runs`] count after
78
- /// every change.
79
- pub run_state_changed: PubSub<u32>,
74
+ /// Account of in-flight CONFIG-DRIVEN pipeline runs.
75
+ pub config_runs: InFlight,
80
76
 
81
77
  /// Fires when the user clicks the status indicator while in
82
78
  /// [`StatusIconState::Normal`].
@@ -100,31 +96,22 @@ pub struct SessionHandle {
100
96
  /// copying the config.
101
97
  pub last_dispatched_config: RefCell<Option<std::rc::Rc<crate::config::ViewerConfig>>>,
102
98
 
103
- /// Monotone load counter, bumped once per `load()` call
104
- /// ([`Session::begin_pending_load`]). Identifies the LATEST load so a
105
- /// stale async classification — React binds `load` to a prop and fires it
106
- /// repeatedly, unawaited and out of order — no-ops instead of clobbering a
107
- /// newer load ([`Session::is_current_load`]).
108
- load_generation: Cell<u32>,
109
-
110
99
  /// Open between a `load()` call and its payload's classification as
111
- /// `Table`/`Client` ([`PendingLoad`]).
112
- pending_load: RefCell<Option<PendingLoad>>,
100
+ /// `Table`/`Client` ([`LoadWindow`]), held weakly so its [`LoadGuard`] is
101
+ /// the sole owner.
102
+ pending_load: RefCell<Weak<LoadWindow>>,
113
103
 
114
104
  /// Coalesces `view_config_changed`: multiple synchronous commits in one
115
105
  /// task emit ONE event on the next microtask — the cadence the deleted
116
106
  /// `is_clean` flag provided by accident, now deliberate.
117
107
  config_event_scheduled: Cell<bool>,
118
108
 
119
- /// Count of in-flight `perspective-config-update` dispatch tasks for this
120
- /// panel (see [`Session::track_dispatch`]). `flush()` joins these via
109
+ /// Account of in-flight `perspective-config-update` dispatch tasks for
110
+ /// this panel (see [`Session::track_dispatch`]). `flush()` joins these via
121
111
  /// [`Session::settle_dispatches`], so the "config-update fires before
122
112
  /// `flush()` resolves" contract holds by construction instead of by
123
113
  /// microtask luck.
124
- pending_dispatches: Cell<u32>,
125
-
126
- /// Fires when [`SessionHandle::pending_dispatches`] returns to zero.
127
- dispatches_settled: PubSub<()>,
114
+ dispatches: InFlight,
128
115
 
129
116
  /// Fires when [`SessionHandle::column_stats`] is updated (insert or
130
117
  /// clear). Subscribers re-render and re-query the schema with the
@@ -152,6 +139,51 @@ impl Deref for SessionHandle {
152
139
  }
153
140
  }
154
141
 
142
+ /// What an un-hosted `table` name means to a bind, per the `wait_for_table`
143
+ /// option.
144
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
145
+ pub enum MissingTable {
146
+ /// A hard error that leaves the panel as it was.
147
+ Error,
148
+
149
+ /// The panel waits unbound and binds when the name is hosted.
150
+ Pend,
151
+ }
152
+
153
+ impl MissingTable {
154
+ /// Decode the `wait_for_table` option (absent = `false` = `Error`).
155
+ pub fn from_wait(wait_for_table: Option<bool>) -> Self {
156
+ if wait_for_table.unwrap_or_default() {
157
+ Self::Pend
158
+ } else {
159
+ Self::Error
160
+ }
161
+ }
162
+ }
163
+
164
+ /// Open `name` on `client`, returning `Ok(None)` when the name is not hosted
165
+ /// and `missing` is [`MissingTable::Pend`].
166
+ pub(crate) async fn probe_table(
167
+ client: &Client,
168
+ name: &str,
169
+ missing: MissingTable,
170
+ ) -> ApiResult<Option<perspective_client::Table>> {
171
+ match client.open_table(name.to_owned()).await {
172
+ Ok(table) => Ok(Some(table)),
173
+ Err(e) => {
174
+ let hosted = client.get_hosted_table_names().await.unwrap_or_default();
175
+ if hosted.iter().any(|n| n == name) {
176
+ return Err(e.into());
177
+ }
178
+
179
+ match missing {
180
+ MissingTable::Pend => Ok(None),
181
+ MissingTable::Error => Err(ApiError::new(format!("Unknown table \"{name}\""))),
182
+ }
183
+ },
184
+ }
185
+ }
186
+
155
187
  /// Mutable state for `Session`.
156
188
  #[derive(Default)]
157
189
  pub struct SessionData {
@@ -166,6 +198,11 @@ pub struct SessionData {
166
198
  is_loading: bool,
167
199
  is_paused: bool,
168
200
 
201
+ /// Terminal: set by [`Session::mark_disposed`] when the owning panel is
202
+ /// ejected, never cleared. Distinct from an `Ejected` reset, which a
203
+ /// suspended or element-ejected session may legitimately rebind after.
204
+ disposed: Option<Disposal>,
205
+
169
206
  /// Memo for [`Session::validate_snapshot`]: the expression set validated
170
207
  /// by the last successful server round trip; an equal snapshot skips the
171
208
  /// round trip. Written only under the draw lock; cleared on table
@@ -210,6 +247,19 @@ impl TableErrorState {
210
247
  }
211
248
  }
212
249
 
250
+ /// How a terminal disposal reports itself to a public run still in flight on
251
+ /// the ejected panel.
252
+ #[derive(Clone, Copy, PartialEq, Eq)]
253
+ pub enum Disposal {
254
+ /// A separate call destroyed the panel, so a racing public run rejects
255
+ /// rather than reporting a success it never achieved.
256
+ Reject,
257
+
258
+ /// The ejecting call reports the eviction on its own promise, so a racing
259
+ /// public run resolves rather than surfacing it twice.
260
+ Resolve,
261
+ }
262
+
213
263
  #[derive(Debug, Default)]
214
264
  pub enum TableIntermediateState {
215
265
  #[default]
@@ -217,13 +267,52 @@ pub enum TableIntermediateState {
217
267
  Reloaded,
218
268
  }
219
269
 
220
- /// The open-load window state (see [`SessionHandle::pending_load`]): the
221
- /// generation that opened it (for supersession detection) and the raw config
222
- /// deltas committed while it was open, in commit order, awaiting replay over a
223
- /// reset base if the payload classifies as a `Table`.
224
- pub struct PendingLoad {
225
- generation: u32,
226
- journal: Vec<ViewConfigUpdate>,
270
+ /// The raw config deltas committed while a `load()` classifies its payload, in
271
+ /// commit order, awaiting replay over a reset base if it classifies as a
272
+ /// `Table` — identified by [`Rc`] identity rather than a generation counter.
273
+ #[derive(Default)]
274
+ pub struct LoadWindow {
275
+ journal: RefCell<Vec<ViewConfigUpdate>>,
276
+ }
277
+
278
+ /// A `load()` call site's handle on THE window it opened, closing that window
279
+ /// on `Drop` so no exit can strand `is_loading`.
280
+ pub struct LoadGuard {
281
+ session: Session,
282
+ window: Rc<LoadWindow>,
283
+ }
284
+
285
+ impl LoadGuard {
286
+ /// Close this window and take its journal for replay, or `None` when a
287
+ /// later `load()` superseded it — "abandon this classification, hold the
288
+ /// frame".
289
+ pub fn claim(&self) -> Option<Vec<ViewConfigUpdate>> {
290
+ self.close_current().then(|| self.window.journal.take())
291
+ }
292
+
293
+ /// Close this window, discarding its journal; idempotent, and a no-op once
294
+ /// superseded.
295
+ pub fn close(&self) {
296
+ self.close_current();
297
+ }
298
+
299
+ fn close_current(&self) -> bool {
300
+ let mut slot = self.session.pending_load.borrow_mut();
301
+ if !slot.upgrade().is_some_and(|x| Rc::ptr_eq(&x, &self.window)) {
302
+ return false;
303
+ }
304
+
305
+ *slot = Weak::new();
306
+ drop(slot);
307
+ self.session.borrow_mut().is_loading = false;
308
+ true
309
+ }
310
+ }
311
+
312
+ impl Drop for LoadGuard {
313
+ fn drop(&mut self) {
314
+ self.close();
315
+ }
227
316
  }
228
317
 
229
318
  /// Options for [`Session::reset`]
@@ -248,19 +337,6 @@ pub struct ResetOptions {
248
337
  #[derive(Clone)]
249
338
  pub struct Session(Rc<SessionHandle>);
250
339
 
251
- /// RAII spinner accounting for one config-driven pipeline run.
252
- pub struct ConfigRunToken(Session);
253
-
254
- impl Drop for ConfigRunToken {
255
- fn drop(&mut self) {
256
- let count = self.0.0.in_flight_config_runs.get();
257
- debug_assert!(count > 0, "ConfigRunToken underflow");
258
- let count = count.saturating_sub(1);
259
- self.0.0.in_flight_config_runs.set(count);
260
- self.0.run_state_changed.emit(count);
261
- }
262
- }
263
-
264
340
  impl ImplicitClone for Session {}
265
341
 
266
342
  impl Deref for Session {
@@ -309,6 +385,7 @@ impl Session {
309
385
  /// `Session`.
310
386
  pub fn reset(&self, options: ResetOptions) -> impl Future<Output = ApiResult<()>> + use<> {
311
387
  let view = self.0.borrow_mut().view_sub.take();
388
+ let had_table = self.borrow().table.is_some();
312
389
  let err = self.get_error();
313
390
  self.borrow_mut().error = None;
314
391
  if options.stats {
@@ -328,11 +405,13 @@ impl Session {
328
405
  self.borrow_mut().is_loading = false;
329
406
  self.borrow_mut().table = None;
330
407
  self.borrow_mut().pending_table = None;
408
+ self.borrow_mut().metadata = SessionMetadata::default();
331
409
  },
332
410
  Some(TableIntermediateState::Reloaded) => {
333
411
  self.borrow_mut().is_loading = true;
334
412
  self.borrow_mut().table = None;
335
413
  self.borrow_mut().pending_table = None;
414
+ self.borrow_mut().metadata = SessionMetadata::default();
336
415
  },
337
416
  _ => {
338
417
  self.borrow_mut().is_loading = false;
@@ -353,7 +432,7 @@ impl Session {
353
432
  let session = self.clone();
354
433
  async move {
355
434
  let res = view.delete().await;
356
- if options.table.is_some() {
435
+ if options.table.is_some() && had_table {
357
436
  session.table_unloaded.emit(true)
358
437
  }
359
438
 
@@ -361,21 +440,19 @@ impl Session {
361
440
  }
362
441
  }
363
442
 
364
- /// Open a pending-load window (see [`SessionHandle::pending_load`]) at the
365
- /// `load()` call site. Bumps the load generation and returns it — the
366
- /// caller carries it into the async classification to detect supersession
367
- /// by a later `load()`. Sets `is_loading` so a FRESH panel shows the
368
- /// spinner while a slow payload resolves (a RELOAD keeps its bound table,
369
- /// hence [`TableLoadState::Loaded`], throughout — no spinner flicker).
370
- pub fn begin_pending_load(&self) -> u32 {
371
- let generation = self.0.load_generation.get() + 1;
372
- self.0.load_generation.set(generation);
373
- *self.0.pending_load.borrow_mut() = Some(PendingLoad {
374
- generation,
375
- journal: Vec::new(),
376
- });
443
+ /// Open a load window (see [`LoadWindow`]) at the `load()` call site,
444
+ /// returning the [`LoadGuard`] that owns it. Sets `is_loading` so a FRESH
445
+ /// panel shows the spinner while a slow payload resolves (a RELOAD keeps
446
+ /// its bound table, hence [`TableLoadState::Loaded`], throughout — no
447
+ /// spinner flicker).
448
+ pub fn begin_pending_load(&self) -> Rc<LoadGuard> {
449
+ let window = Rc::new(LoadWindow::default());
450
+ *self.0.pending_load.borrow_mut() = Rc::downgrade(&window);
377
451
  self.borrow_mut().is_loading = true;
378
- generation
452
+ Rc::new(LoadGuard {
453
+ session: self.clone(),
454
+ window,
455
+ })
379
456
  }
380
457
 
381
458
  /// Whether a `load()` payload is still awaiting classification. A `true`
@@ -383,25 +460,23 @@ impl Session {
383
460
  /// ([`crate::tasks::bind_snapshot`]) — the incoming config must never draw
384
461
  /// against the outgoing table.
385
462
  pub fn has_pending_load(&self) -> bool {
386
- self.0.pending_load.borrow().is_some()
387
- }
388
-
389
- /// Close the pending-load window for `generation`, returning its journal
390
- /// (the raw deltas committed while it was open, in order) for replay.
391
- /// Returns `None` when a later `load()` already superseded this one (or it
392
- /// was already closed), which the caller treats as "abandon this
393
- /// classification, hold the frame". Clears `is_loading` — the
394
- /// classification that follows sets the real table state.
395
- pub fn take_pending_load(&self, generation: u32) -> Option<Vec<ViewConfigUpdate>> {
396
- let mut slot = self.0.pending_load.borrow_mut();
397
- if slot.as_ref().map(|p| p.generation) != Some(generation) {
398
- return None;
399
- }
463
+ self.0.pending_load.borrow().strong_count() > 0
464
+ }
400
465
 
401
- let journal = slot.take().map(|p| p.journal);
402
- drop(slot);
403
- self.borrow_mut().is_loading = false;
404
- journal
466
+ /// Mark this session disposed (its panel was ejected): every later
467
+ /// table bind and locked run refuses, so nothing can rebind or draw a
468
+ /// panel that no longer exists.
469
+ pub(crate) fn mark_disposed(&self, disposal: Disposal) {
470
+ self.borrow_mut().disposed = Some(disposal);
471
+ }
472
+
473
+ pub(crate) fn is_disposed(&self) -> bool {
474
+ self.borrow().disposed.is_some()
475
+ }
476
+
477
+ /// This session's disposal, if its panel was ejected.
478
+ pub(crate) fn disposal(&self) -> Option<Disposal> {
479
+ self.borrow().disposed
405
480
  }
406
481
 
407
482
  pub(crate) fn has_table(&self) -> Option<TableLoadState> {
@@ -410,6 +485,8 @@ impl Session {
410
485
  Some(TableLoadState::Loaded)
411
486
  } else if data.is_loading {
412
487
  Some(TableLoadState::Loading)
488
+ } else if data.pending_table.is_some() {
489
+ Some(TableLoadState::Pending)
413
490
  } else {
414
491
  None
415
492
  }
@@ -470,10 +547,8 @@ impl Session {
470
547
  /// `table_name` is unique per `Client`, so if this value has not changed,
471
548
  /// `Session::set_table` does nothing and returns `Ok(false)`.
472
549
  ///
473
- /// A name NO loaded client hosts (yet) is not an error: the session
474
- /// records it PENDING ([`SessionData::pending_table`]) and returns
475
- /// `Ok(false)`; the element's hosted-tables subscription
476
- /// (`tasks::table_lifecycle`) re-runs the bind when the table is created.
550
+ /// A name no loaded client hosts pends ([`MissingTable::Pend`]) and
551
+ /// returns `Ok(false)` until the table lifecycle re-runs the bind.
477
552
  pub async fn set_table(&self, table_name: String) -> ApiResult<bool> {
478
553
  if Some(table_name.as_str()) == self.0.borrow().table.as_ref().map(|x| x.get_name()) {
479
554
  self.0.borrow_mut().pending_table = None;
@@ -481,60 +556,76 @@ impl Session {
481
556
  }
482
557
 
483
558
  let client = self.0.borrow().client.clone().into_apierror()?;
484
- let table = match client.open_table(table_name.clone()).await {
485
- Ok(table) => table,
486
- Err(e) => {
487
- // Distinguish "not hosted (yet)" — which PENDS — from a real
488
- // open failure by the hosted set, not the error's text.
489
- let hosted = client.get_hosted_table_names().await.unwrap_or_default();
490
- if hosted.iter().any(|n| n == &table_name) {
491
- return Err(e.into());
492
- }
493
-
494
- self.0.borrow_mut().pending_table = Some(table_name);
495
- return Ok(false);
559
+ match probe_table(&client, &table_name, MissingTable::Pend).await? {
560
+ Some(table) => match self.bind_table(table).await {
561
+ Ok(()) => Ok(true),
562
+ Err(err) => self.set_error(false, err).await.map(|_| false),
496
563
  },
497
- };
564
+ None => {
565
+ self.pend_table(table_name).await?;
566
+ Ok(false)
567
+ },
568
+ }
569
+ }
498
570
 
499
- match SessionMetadata::from_table(&table).await {
500
- Ok(metadata) => {
501
- let client = table.get_client();
502
- let on_error = self.on_table_errored.borrow().clone();
503
- let session = self.clone();
504
- let poll_loop = LocalPollLoop::new(move |(message, reconnect): (ApiError, _)| {
505
- session.borrow_mut().error = Some(TableErrorState(message, reconnect));
506
- if let Some(cb) = &on_error {
507
- cb.emit(());
508
- }
509
- if let Some(sub) = session.borrow_mut().view_sub.take() {
510
- sub.dismiss();
511
- }
571
+ /// Bind an opened `Table`, replacing any previous binding and announcing
572
+ /// `table_loaded`.
573
+ pub(crate) async fn bind_table(&self, table: perspective_client::Table) -> ApiResult<()> {
574
+ if self.is_disposed() {
575
+ return Err(ApiError::new("Panel disposed"));
576
+ }
512
577
 
513
- Ok(JsValue::UNDEFINED)
514
- });
515
-
516
- let _callback_id = client
517
- .on_error(Box::new(move |message: ClientError, reconnect| {
518
- let poll_loop = poll_loop.clone();
519
- async move {
520
- poll_loop.poll((message.into(), reconnect)).await;
521
- Ok(())
522
- }
523
- }))
524
- .await?;
578
+ let metadata = SessionMetadata::from_table(&table).await?;
579
+ let client = table.get_client();
580
+ let on_error = self.on_table_errored.borrow().clone();
581
+ let session = self.clone();
582
+ let poll_loop = LocalPollLoop::new(move |(message, reconnect): (ApiError, _)| {
583
+ session.borrow_mut().error = Some(TableErrorState(message, reconnect));
584
+ if let Some(cb) = &on_error {
585
+ cb.emit(());
586
+ }
587
+ if let Some(sub) = session.borrow_mut().view_sub.take() {
588
+ sub.dismiss();
589
+ }
525
590
 
526
- let sub = self.borrow_mut().view_sub.take();
527
- self.borrow_mut().metadata = metadata;
528
- self.borrow_mut().table = Some(table);
529
- self.borrow_mut().pending_table = None;
530
- self.borrow_mut().is_loading = false;
531
- self.borrow_mut().last_validated_expressions = None;
532
- sub.delete().await?;
533
- self.table_loaded.emit(());
534
- Ok(true)
535
- },
536
- Err(err) => self.set_error(false, err).await.map(|_| false),
591
+ Ok(JsValue::UNDEFINED)
592
+ });
593
+
594
+ let _callback_id = client
595
+ .on_error(Box::new(move |message: ClientError, reconnect| {
596
+ let poll_loop = poll_loop.clone();
597
+ async move {
598
+ poll_loop.poll((message.into(), reconnect)).await;
599
+ Ok(())
600
+ }
601
+ }))
602
+ .await?;
603
+
604
+ let sub = self.borrow_mut().view_sub.take();
605
+ self.borrow_mut().metadata = metadata;
606
+ self.borrow_mut().table = Some(table);
607
+ self.borrow_mut().pending_table = None;
608
+ self.borrow_mut().is_loading = false;
609
+ self.borrow_mut().last_validated_expressions = None;
610
+ sub.delete().await?;
611
+ self.table_loaded.emit(());
612
+ Ok(())
613
+ }
614
+
615
+ /// Record `name` as pending, dropping any bound `Table` so nothing binds
616
+ /// against the outgoing table while the name waits for a host.
617
+ pub(crate) async fn pend_table(&self, name: String) -> ApiResult<()> {
618
+ if self.is_disposed() {
619
+ return Err(ApiError::new("Panel disposed"));
537
620
  }
621
+
622
+ let sub = self.borrow_mut().view_sub.take();
623
+ self.borrow_mut().table = None;
624
+ self.borrow_mut().metadata = SessionMetadata::default();
625
+ self.borrow_mut().pending_table = Some(name);
626
+ self.borrow_mut().is_loading = false;
627
+ self.borrow_mut().last_validated_expressions = None;
628
+ sub.delete().await
538
629
  }
539
630
 
540
631
  pub async fn set_error(&self, reset_table: bool, err: ApiError) -> ApiResult<()> {
@@ -754,20 +845,20 @@ impl Session {
754
845
  // absent from the outgoing one — mirroring the "no table bound yet"
755
846
  // leniency already in `validate_names`. Authoritative validation is
756
847
  // deferred to the replay.
757
- let pending = self.has_pending_load();
848
+ let window = self.0.pending_load.borrow().upgrade();
758
849
  let mut candidate = self.borrow().config.clone();
759
- let journal_entry = pending.then(|| config_update.clone());
850
+ let journal_entry = window.is_some().then(|| config_update.clone());
760
851
  if !candidate.apply_update(config_update) {
761
852
  return Ok(());
762
853
  }
763
854
 
764
- if let Some(entry) = journal_entry
765
- && let Some(p) = self.0.pending_load.borrow_mut().as_mut()
855
+ if let Some(window) = &window
856
+ && let Some(entry) = journal_entry
766
857
  {
767
- p.journal.push(entry);
858
+ window.journal.borrow_mut().push(entry);
768
859
  }
769
860
 
770
- if !pending {
861
+ if window.is_none() {
771
862
  self.validate_names(&candidate)?;
772
863
  }
773
864
 
@@ -871,19 +962,10 @@ impl Session {
871
962
  /// [`Self::settle_dispatches`]. Used by the `perspective-config-update`
872
963
  /// dispatcher, whose landing `flush()` must be able to await.
873
964
  pub fn track_dispatch(&self, fut: impl std::future::Future<Output = ApiResult<()>> + 'static) {
874
- self.0
875
- .pending_dispatches
876
- .set(self.0.pending_dispatches.get() + 1);
877
- let session = self.clone();
965
+ let guard = self.0.dispatches.guard();
878
966
  ApiFuture::spawn_named("config-update-dispatch", async move {
879
- let result = fut.await;
880
- let remaining = session.0.pending_dispatches.get() - 1;
881
- session.0.pending_dispatches.set(remaining);
882
- if remaining == 0 {
883
- session.0.dispatches_settled.emit(());
884
- }
885
-
886
- if let Err(e) = result {
967
+ let _guard = guard;
968
+ if let Err(e) = fut.await {
887
969
  tracing::error!("[config-update dispatch] {}", e);
888
970
  }
889
971
 
@@ -894,10 +976,7 @@ impl Session {
894
976
  /// Resolve once every in-flight tracked dispatch for this panel has
895
977
  /// landed. Immediate when none are pending.
896
978
  pub async fn settle_dispatches(&self) -> ApiResult<()> {
897
- while self.0.pending_dispatches.get() > 0 {
898
- self.0.dispatches_settled.read_next().await?;
899
- }
900
-
979
+ self.0.dispatches.settle().await;
901
980
  Ok(())
902
981
  }
903
982
 
@@ -917,26 +996,10 @@ impl Session {
917
996
  }
918
997
 
919
998
  /// Begin spinner accounting for ONE config-driven pipeline run: call
920
- /// immediately after the run's commit, and move the returned token INTO
921
- /// the run future. `Drop` settles it on every exit — completion, error,
922
- /// cancellation, and runs that never reach `bind_view` (the
923
- /// deferred-draw restore) — so, unlike edge-counting PubSub pairs, the
924
- /// count cannot strand by construction.
925
- /// [`SessionHandle::run_state_changed`] broadcasts the ABSOLUTE count
926
- /// on both edges.
927
- pub fn begin_config_run(&self) -> ConfigRunToken {
928
- let count = self.0.in_flight_config_runs.get() + 1;
929
- self.0.in_flight_config_runs.set(count);
930
- self.run_state_changed.emit(count);
931
- ConfigRunToken(self.clone())
932
- }
933
-
934
- /// The number of config-driven pipeline runs currently in flight (see
935
- /// [`Self::begin_config_run`]). Read directly when (re)targeting a
936
- /// panel — level-triggered consumers initialize from this and then
937
- /// track [`SessionHandle::run_state_changed`].
938
- pub fn in_flight_config_runs(&self) -> u32 {
939
- self.0.in_flight_config_runs.get()
999
+ /// immediately after the run's commit, and move the returned guard INTO
1000
+ /// the run future.
1001
+ pub fn begin_config_run(&self) -> InFlightGuard {
1002
+ self.0.config_runs.guard()
940
1003
  }
941
1004
 
942
1005
  /// Read the cached `ColumnStats` for a column. Returns `None` if no
@@ -1094,31 +1157,7 @@ impl Session {
1094
1157
  .clone()
1095
1158
  .ok_or("`restore()` called before `load()`")?;
1096
1159
 
1097
- // Populate the aggregates with defaults as a courtesy to the
1098
- // virtual server api. Snapshot-local — never written back.
1099
- let mut view_config = (*effective).clone();
1100
- for col in view_config
1101
- .columns
1102
- .iter()
1103
- .flatten()
1104
- .chain(view_config.sort.iter().map(|x| &x.0))
1105
- {
1106
- if !view_config.aggregates.contains_key(col.as_str()) {
1107
- let agg = self
1108
- .metadata()
1109
- .get_column_aggregates(col.as_str())
1110
- .and_then(|mut aggs| aggs.next())
1111
- .into_apierror();
1112
-
1113
- match agg {
1114
- Err(_) => {
1115
- tracing::warn!("No default aggregate for column '{}' found, skipping", col)
1116
- },
1117
- Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg),
1118
- };
1119
- }
1120
- }
1121
-
1160
+ let view_config = self.with_default_aggregates(&effective);
1122
1161
  let view = table.view(Some(view_config.into())).await?;
1123
1162
  let view_schema = view.schema().await?;
1124
1163
  self.metadata_mut().update_view_schema(&view_schema)?;
@@ -1150,6 +1189,43 @@ impl Session {
1150
1189
  }
1151
1190
  }
1152
1191
 
1192
+ /// The engine config for a `View` built from `effective`, per-column
1193
+ /// default aggregates filled in as a courtesy to the virtual server API.
1194
+ fn with_default_aggregates(&self, effective: &ViewConfig) -> ViewConfig {
1195
+ let mut view_config = effective.clone();
1196
+ for col in view_config
1197
+ .columns
1198
+ .iter()
1199
+ .flatten()
1200
+ .chain(view_config.sort.iter().map(|x| &x.0))
1201
+ {
1202
+ if !view_config.aggregates.contains_key(col.as_str()) {
1203
+ let agg = self
1204
+ .metadata()
1205
+ .get_column_aggregates(col.as_str())
1206
+ .and_then(|mut aggs| aggs.next())
1207
+ .into_apierror();
1208
+
1209
+ match agg {
1210
+ Err(_) => {
1211
+ tracing::warn!("No default aggregate for column '{}' found, skipping", col)
1212
+ },
1213
+ Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg),
1214
+ };
1215
+ }
1216
+ }
1217
+
1218
+ view_config
1219
+ }
1220
+
1221
+ /// Build a caller-owned `View` from the effective config outside the
1222
+ /// render pipeline, with no subscription and no interaction with pause.
1223
+ pub async fn create_detached_view(&self) -> ApiResult<View> {
1224
+ let table = self.borrow().table.clone().ok_or("No `Table` set")?;
1225
+ let view_config = self.with_default_aggregates(&self.effective_view_config());
1226
+ Ok(table.view(Some(view_config.into())).await?)
1227
+ }
1228
+
1153
1229
  /// Record a failed pipeline run: error state plus a reconnect affordance
1154
1230
  /// that resets the config (the error screen's reset button). Replaces
1155
1231
  /// the old `validate()` error path — the committed config is NOT rolled
@@ -1239,6 +1315,8 @@ impl Session {
1239
1315
  Some(TableLoadState::Loaded)
1240
1316
  } else if data.is_loading {
1241
1317
  Some(TableLoadState::Loading)
1318
+ } else if data.pending_table.is_some() {
1319
+ Some(TableLoadState::Pending)
1242
1320
  } else {
1243
1321
  None
1244
1322
  },