@perspective-dev/viewer 5.5.1 → 5.6.0

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 (194) hide show
  1. package/README.md +82 -3
  2. package/dist/cdn/perspective-viewer.js +9 -9
  3. package/dist/cdn/perspective-viewer.js.map +3 -3
  4. package/dist/css/blueprint.css +1 -1
  5. package/dist/css/botanical.css +1 -1
  6. package/dist/css/dracula.css +1 -1
  7. package/dist/css/eggplant.css +1 -1
  8. package/dist/css/gruvbox-dark.css +1 -1
  9. package/dist/css/gruvbox.css +1 -1
  10. package/dist/css/icons.css +1 -1
  11. package/dist/css/intl/ar.css +1 -1
  12. package/dist/css/intl/de.css +1 -1
  13. package/dist/css/intl/es.css +1 -1
  14. package/dist/css/intl/fr.css +1 -1
  15. package/dist/css/intl/ja.css +1 -1
  16. package/dist/css/intl/pt.css +1 -1
  17. package/dist/css/intl/zh.css +1 -1
  18. package/dist/css/intl.css +1 -1
  19. package/dist/css/ledger.css +1 -1
  20. package/dist/css/monokai.css +1 -1
  21. package/dist/css/nord.css +1 -1
  22. package/dist/css/phosphor.css +1 -1
  23. package/dist/css/pro-dark.css +1 -1
  24. package/dist/css/pro.css +1 -1
  25. package/dist/css/solarized-dark.css +1 -1
  26. package/dist/css/solarized.css +1 -1
  27. package/dist/css/themes.css +1 -1
  28. package/dist/css/vaporwave.css +1 -1
  29. package/dist/css/velvet.css +1 -1
  30. package/dist/docs/perspective-docs.json +1 -1
  31. package/dist/esm/column-format.js +2 -0
  32. package/dist/esm/column-format.js.map +7 -0
  33. package/dist/esm/extensions.d.ts +3 -13
  34. package/dist/esm/perspective-viewer.d.ts +1 -1
  35. package/dist/esm/perspective-viewer.js +9 -9
  36. package/dist/esm/perspective-viewer.js.map +3 -3
  37. package/dist/esm/plugin.d.ts +4 -1
  38. package/dist/esm/select-detail.d.ts +15 -0
  39. package/dist/esm/select-detail.js +2 -0
  40. package/dist/esm/select-detail.js.map +7 -0
  41. package/dist/esm/ts-rs/DerivedTableConfig.d.ts +35 -0
  42. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +7 -6
  43. package/dist/esm/ts-rs/RestoreOptions.d.ts +3 -7
  44. package/dist/esm/ts-rs/TableRef.d.ts +9 -0
  45. package/dist/esm/ts-rs/ViewerConfig.d.ts +7 -6
  46. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +7 -5
  47. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  48. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +9 -0
  49. package/dist/wasm/perspective-viewer.d.ts +115 -35
  50. package/dist/wasm/perspective-viewer.js +314 -190
  51. package/dist/wasm/perspective-viewer.wasm +0 -0
  52. package/dist/wasm/perspective-viewer.wasm.d.ts +21 -17
  53. package/package.json +31 -18
  54. package/src/css/column-selector.css +205 -50
  55. package/src/css/ui/containers/context-menu.css +12 -1
  56. package/src/css/ui/containers/dropdown-menu-items.css +29 -0
  57. package/src/css/ui/containers/scroll-panel.css +4 -1
  58. package/src/rust/agent/client/protocol.rs +0 -89
  59. package/src/rust/agent/markdown/mod.rs +0 -238
  60. package/src/rust/agent/preamble.md +1 -0
  61. package/src/rust/agent/tools.rs +2 -16
  62. package/src/rust/components/column_selector/active_column.rs +15 -5
  63. package/src/rust/components/column_selector/add_expression_button.rs +9 -26
  64. package/src/rust/components/column_selector/config_selector.rs +34 -6
  65. package/src/rust/components/column_selector/derived_control.rs +229 -0
  66. package/src/rust/components/column_selector/expr_edit_button.rs +3 -2
  67. package/src/rust/components/column_selector/filter_column.rs +0 -2
  68. package/src/rust/components/column_selector/inactive_column.rs +4 -5
  69. package/src/rust/components/column_selector/pivot_column.rs +21 -1
  70. package/src/rust/components/column_selector/sort_column.rs +4 -3
  71. package/src/rust/components/column_selector.rs +21 -1
  72. package/src/rust/components/column_settings_sidebar/save_settings.rs +5 -3
  73. package/src/rust/components/column_settings_sidebar/style_tab.rs +1 -0
  74. package/src/rust/components/column_settings_sidebar.rs +25 -19
  75. package/src/rust/components/debug_panel.rs +38 -29
  76. package/src/rust/components/dragdrop_list.rs +8 -9
  77. package/src/rust/components/main_panel/msg.rs +6 -4
  78. package/src/rust/components/main_panel/reconcile.rs +6 -1
  79. package/src/rust/components/main_panel/render.rs +70 -51
  80. package/src/rust/components/main_panel/update.rs +39 -1
  81. package/src/rust/components/main_panel.rs +97 -59
  82. package/src/rust/components/new_panel_menu.rs +69 -1
  83. package/src/rust/components/panel_menu.rs +63 -12
  84. package/src/rust/components/plugin_tab.rs +3 -2
  85. package/src/rust/components/render_warning.rs +2 -1
  86. package/src/rust/components/settings_panel.rs +4 -4
  87. package/src/rust/components/status_bar.rs +74 -24
  88. package/src/rust/components/status_indicator.rs +8 -1
  89. package/src/rust/components/viewer/filters.rs +13 -1
  90. package/src/rust/components/viewer/msg.rs +4 -0
  91. package/src/rust/components/viewer/panels.rs +41 -1
  92. package/src/rust/components/viewer/render.rs +1 -0
  93. package/src/rust/components/viewer/settings.rs +0 -22
  94. package/src/rust/components/viewer/snapshots.rs +2 -2
  95. package/src/rust/components/viewer/wiring.rs +14 -0
  96. package/src/rust/components/viewer.rs +45 -25
  97. package/src/rust/components/window_editor.rs +5 -4
  98. package/src/rust/config/column_config_schema.rs +38 -377
  99. package/src/rust/config/number_string_format.rs +0 -94
  100. package/src/rust/config/options.rs +3 -7
  101. package/src/rust/config/plugin_static_config.rs +0 -42
  102. package/src/rust/config/viewer_config.rs +47 -17
  103. package/src/rust/config/workspace_config.rs +158 -128
  104. package/src/rust/custom_elements/viewer.rs +187 -131
  105. package/src/rust/custom_events.rs +33 -18
  106. package/src/rust/derived.rs +266 -0
  107. package/src/rust/exprtk/tokenize.rs +0 -54
  108. package/src/rust/js/plugin.rs +1 -1
  109. package/src/rust/lib.rs +3 -0
  110. package/src/rust/presentation.rs +23 -4
  111. package/src/rust/queries/column_locator.rs +55 -48
  112. package/src/rust/queries/get_viewer_config.rs +1 -1
  113. package/src/rust/queries/plugin_column_styles.rs +22 -23
  114. package/src/rust/renderer/dispatch.rs +2 -0
  115. package/src/rust/renderer/plugin_config.rs +652 -487
  116. package/src/rust/renderer/props.rs +4 -0
  117. package/src/rust/renderer/registry.rs +2 -6
  118. package/src/rust/renderer/state.rs +205 -0
  119. package/src/rust/renderer.rs +201 -59
  120. package/src/rust/session/metadata.rs +30 -9
  121. package/src/rust/session/op_queue.rs +430 -0
  122. package/src/rust/session/panel_state.rs +327 -0
  123. package/src/rust/session/view_subscription.rs +42 -3
  124. package/src/rust/session.rs +900 -395
  125. package/src/rust/tasks/apply_global_filters.rs +49 -35
  126. package/src/rust/tasks/auto_pause.rs +22 -12
  127. package/src/rust/tasks/copy_export.rs +5 -0
  128. package/src/rust/tasks/create_panel.rs +34 -9
  129. package/src/rust/tasks/derive.rs +312 -0
  130. package/src/rust/tasks/mod.rs +6 -2
  131. package/src/rust/tasks/pipeline.rs +145 -89
  132. package/src/rust/tasks/reset_all.rs +31 -20
  133. package/src/rust/tasks/restore_derived.rs +200 -0
  134. package/src/rust/tasks/restore_panel.rs +153 -141
  135. package/src/rust/tasks/send_column_config.rs +56 -29
  136. package/src/rust/tasks/send_plugin_config.rs +52 -23
  137. package/src/rust/tasks/set_edit_mode.rs +33 -25
  138. package/src/rust/tasks/sync_update_panels.rs +39 -12
  139. package/src/rust/tasks/table_lifecycle.rs +70 -34
  140. package/src/rust/tasks/transactional_restore.rs +387 -0
  141. package/src/rust/tasks/update_theme.rs +45 -6
  142. package/src/rust/ui/containers/scroll_panel.rs +17 -10
  143. package/src/rust/ui/containers/split_panel.rs +0 -41
  144. package/src/rust/ui/context_menu.rs +157 -18
  145. package/src/rust/ui/font_loader.rs +23 -35
  146. package/src/rust/ui/form/mirrored_textarea.rs +0 -12
  147. package/src/rust/ui/form/multi_stop_gradient_selector.rs +0 -89
  148. package/src/rust/ui/form/palette_selector.rs +0 -51
  149. package/src/rust/ui/portal.rs +4 -10
  150. package/src/rust/utils/browser/mod.rs +2 -3
  151. package/src/{ts/perspective-viewer.inline.ts → rust/utils/browser/mouse.rs} +11 -10
  152. package/src/rust/utils/browser/request_animation_frame.rs +27 -8
  153. package/src/rust/utils/css.rs +0 -360
  154. package/src/rust/utils/local_fonts.rs +0 -36
  155. package/src/rust/utils/mod.rs +2 -3
  156. package/src/rust/utils/modal_position.rs +15 -21
  157. package/src/rust/utils/{browser/tests/mod.rs → plugin_host_state.rs} +31 -1
  158. package/src/rust/utils/pubsub.rs +0 -1
  159. package/src/rust/workspace.rs +187 -140
  160. package/src/svg/derived.svg +6 -0
  161. package/src/themes/icons.css +1 -0
  162. package/src/themes/intl/ar.css +10 -0
  163. package/src/themes/intl/de.css +10 -0
  164. package/src/themes/intl/es.css +10 -0
  165. package/src/themes/intl/fr.css +10 -0
  166. package/src/themes/intl/ja.css +10 -0
  167. package/src/themes/intl/pt.css +10 -0
  168. package/src/themes/intl/zh.css +10 -0
  169. package/src/themes/intl.css +10 -0
  170. package/src/ts/extensions.ts +3 -36
  171. package/src/ts/perspective-viewer.ts +1 -1
  172. package/src/ts/plugin.ts +4 -1
  173. package/src/{rust/utils/tests/request_animation_frame.rs → ts/select-detail.ts} +32 -26
  174. package/src/ts/ts-rs/DerivedTableConfig.ts +22 -0
  175. package/src/ts/ts-rs/PanelViewerConfig.ts +7 -6
  176. package/src/ts/ts-rs/RestoreOptions.ts +3 -7
  177. package/src/ts/ts-rs/TableRef.ts +9 -0
  178. package/src/ts/ts-rs/ViewerConfig.ts +7 -6
  179. package/src/ts/ts-rs/ViewerConfigUpdate.ts +7 -5
  180. package/src/ts/ts-rs/WorkspaceConfig.ts +7 -1
  181. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -0
  182. package/dist/esm/perspective-viewer.inline.d.ts +0 -3
  183. package/dist/esm/perspective-viewer.inline.js +0 -21
  184. package/dist/esm/perspective-viewer.inline.js.map +0 -7
  185. package/src/rust/tasks/restore_and_render.rs +0 -107
  186. package/src/rust/utils/browser/tests/debounce.rs +0 -163
  187. package/src/rust/utils/tests/mod.rs +0 -14
  188. package/src/rust/utils/tests/pubsub.rs +0 -95
  189. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline0.js +0 -0
  190. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline1.js +0 -0
  191. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline2.js +0 -0
  192. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline3.js +0 -0
  193. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline4.js +0 -0
  194. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline5.js +0 -0
@@ -13,19 +13,23 @@
13
13
  pub(crate) mod column_defaults_update;
14
14
  pub(crate) mod drag_drop_update;
15
15
  mod metadata;
16
+ mod op_queue;
17
+ mod panel_state;
16
18
  mod props;
17
19
  pub(crate) mod replace_expression_update;
18
20
  mod view_subscription;
19
21
 
20
- use std::cell::{Cell, Ref, RefCell};
22
+ use std::cell::{Cell, RefCell};
21
23
  use std::collections::{HashMap, HashSet};
22
24
  use std::future::Future;
23
25
  use std::ops::Deref;
24
- use std::rc::{Rc, Weak};
26
+ use std::rc::Rc;
25
27
 
26
28
  use perspective_client::config::*;
27
29
  use perspective_client::proto::ViewDimensionsResp;
28
- use perspective_client::{Client, ClientError, ReconnectCallback, View};
30
+ use perspective_client::{
31
+ Client, ClientError, DescribeError, Description, ExprValidationResult, ReconnectCallback, View,
32
+ };
29
33
  use perspective_js::apierror;
30
34
  use perspective_js::utils::*;
31
35
  use wasm_bindgen::prelude::*;
@@ -34,9 +38,13 @@ use yew::prelude::*;
34
38
 
35
39
  use self::metadata::*;
36
40
  pub use self::metadata::{MetadataRef, SessionMetadata, SessionMetadataRc};
41
+ use self::op_queue::OpQueue;
42
+ pub use self::op_queue::{EditDelta, OpCtx, OpKind, StepFuture, Ticket};
43
+ use self::panel_state::effective;
44
+ pub use self::panel_state::{Binding, OverlayClause, PanelState};
37
45
  pub use self::props::{SessionProps, TableLoadState};
38
- pub use self::view_subscription::ViewStats;
39
46
  use self::view_subscription::*;
47
+ pub use self::view_subscription::{ViewStats, ViewSubscription};
40
48
  use crate::config::PluginStaticConfig;
41
49
  use crate::utils::*;
42
50
 
@@ -96,10 +104,9 @@ pub struct SessionHandle {
96
104
  /// copying the config.
97
105
  pub last_dispatched_config: RefCell<Option<std::rc::Rc<crate::config::ViewerConfig>>>,
98
106
 
99
- /// Open between a `load()` call and its payload's classification as
100
- /// `Table`/`Client` ([`LoadWindow`]), held weakly so its [`LoadGuard`] is
101
- /// the sole owner.
102
- pending_load: RefCell<Weak<LoadWindow>>,
107
+ /// Every writer of this panel's committed state, run one at a time in
108
+ /// submit order (see [`op_queue`]).
109
+ queue: Rc<OpQueue>,
103
110
 
104
111
  /// Coalesces `view_config_changed`: multiple synchronous commits in one
105
112
  /// task emit ONE event on the next microtask — the cadence the deleted
@@ -187,29 +194,144 @@ pub(crate) async fn probe_table(
187
194
  /// Mutable state for `Session`.
188
195
  #[derive(Default)]
189
196
  pub struct SessionData {
190
- client: Option<perspective_client::Client>,
191
- table: Option<perspective_client::Table>,
192
- pending_table: Option<String>,
193
- metadata: SessionMetadata,
194
- config: ViewConfig,
195
- global_filter: Vec<Filter>,
197
+ /// This panel's committed state: ONE immutable value, replaced whole by
198
+ /// [`SessionData::swap`].
199
+ state: Lifecycle,
200
+
201
+ /// What [`Session::metadata`] answers while nothing is bound, and what
202
+ /// [`Session::metadata_mut`] scribbles on (every write is a no-op then).
203
+ unbound_metadata: Rc<SessionMetadata>,
196
204
  view_sub: Option<ViewSubscription>,
197
205
  stats: Option<ViewStats>,
198
- is_loading: bool,
199
206
  is_paused: bool,
207
+ }
208
+
209
+ /// A panel's state and whether it can still be written.
210
+ enum Lifecycle {
211
+ Live(Rc<PanelState>),
200
212
 
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
-
206
- /// Memo for [`Session::validate_snapshot`]: the expression set validated
207
- /// by the last successful server round trip; an equal snapshot skips the
208
- /// round trip. Written only under the draw lock; cleared on table
209
- /// (re)bind and expression reset.
210
- last_validated_expressions: Option<Expressions>,
211
- error: Option<TableErrorState>,
212
- title: Option<String>,
213
+ /// Terminal: the owning panel was ejected ([`Session::dispose`]).
214
+ Disposed {
215
+ last: Rc<PanelState>,
216
+ disposal: Disposal,
217
+ },
218
+ }
219
+
220
+ impl Default for Lifecycle {
221
+ fn default() -> Self {
222
+ Self::Live(Rc::default())
223
+ }
224
+ }
225
+
226
+ impl Deref for Lifecycle {
227
+ type Target = Rc<PanelState>;
228
+
229
+ fn deref(&self) -> &Self::Target {
230
+ match self {
231
+ Lifecycle::Live(state) | Lifecycle::Disposed { last: state, .. } => state,
232
+ }
233
+ }
234
+ }
235
+
236
+ impl Lifecycle {
237
+ fn live_mut(&mut self) -> Option<&mut Rc<PanelState>> {
238
+ match self {
239
+ Lifecycle::Live(state) => Some(state),
240
+ Lifecycle::Disposed { .. } => None,
241
+ }
242
+ }
243
+
244
+ fn disposal(&self) -> Option<Disposal> {
245
+ match self {
246
+ Lifecycle::Live(_) => None,
247
+ Lifecycle::Disposed { disposal, .. } => Some(*disposal),
248
+ }
249
+ }
250
+ }
251
+
252
+ /// An OWNED read guard over the committed [`ViewConfig`]: a snapshot that
253
+ /// borrows nothing.
254
+ pub struct ViewConfigRef(Rc<ViewConfig>);
255
+
256
+ impl Deref for ViewConfigRef {
257
+ type Target = ViewConfig;
258
+
259
+ fn deref(&self) -> &Self::Target {
260
+ &self.0
261
+ }
262
+ }
263
+
264
+ impl SessionData {
265
+ fn error(&self) -> Option<&TableErrorState> {
266
+ self.state.lost()
267
+ }
268
+
269
+ fn clear_errors(&mut self) {
270
+ if self.state.lost().is_some() {
271
+ self.swap(self.state.recovered());
272
+ }
273
+ }
274
+
275
+ fn swap(&mut self, next: PanelState) {
276
+ if let Some(state) = self.state.live_mut() {
277
+ *state = Rc::new(next);
278
+ }
279
+ }
280
+
281
+ fn table(&self) -> Option<&perspective_client::Table> {
282
+ self.state.bound().map(|bound| &bound.table)
283
+ }
284
+
285
+ fn pending_table(&self) -> Option<&str> {
286
+ self.state.awaiting()
287
+ }
288
+
289
+ fn metadata(&self) -> &Rc<SessionMetadata> {
290
+ match self.state.bound() {
291
+ Some(bound) => &bound.metadata,
292
+ None => &self.unbound_metadata,
293
+ }
294
+ }
295
+
296
+ /// Copy-on-write: a snapshot held elsewhere never observes the mutation.
297
+ fn metadata_mut(&mut self) -> &mut SessionMetadata {
298
+ let Some(state) = self.state.live_mut() else {
299
+ return Rc::make_mut(&mut self.unbound_metadata);
300
+ };
301
+
302
+ let binding = match &mut Rc::make_mut(state).binding {
303
+ Binding::Lost { prior, .. } => Rc::make_mut(prior),
304
+ binding => binding,
305
+ };
306
+
307
+ match binding {
308
+ Binding::Bound(bound) => Rc::make_mut(&mut Rc::make_mut(bound).metadata),
309
+ _ => Rc::make_mut(&mut self.unbound_metadata),
310
+ }
311
+ }
312
+
313
+ fn config(&self) -> &ViewConfig {
314
+ self.state.config()
315
+ }
316
+
317
+ fn set_config(&mut self, config: ViewConfig) {
318
+ self.swap(self.state.with_config(Rc::new(config)));
319
+ }
320
+
321
+ /// Memo for [`Session::validate_snapshot`]: the [`Description`] the server
322
+ /// gave for the last validated effective config; an equal snapshot skips
323
+ /// the round trip.
324
+ fn description(&self) -> Option<&(Rc<ViewConfig>, Rc<Description>)> {
325
+ self.state.bound()?.description.as_ref()
326
+ }
327
+
328
+ fn set_description(&mut self, description: Option<(Rc<ViewConfig>, Rc<Description>)>) {
329
+ self.swap(self.state.with_description(description));
330
+ }
331
+
332
+ fn unbind(&mut self) {
333
+ self.swap(self.state.unbound());
334
+ }
213
335
  }
214
336
 
215
337
  #[derive(Clone)]
@@ -260,59 +382,19 @@ pub enum Disposal {
260
382
  Resolve,
261
383
  }
262
384
 
263
- #[derive(Debug, Default)]
264
- pub enum TableIntermediateState {
265
- #[default]
266
- Ejected,
267
- Reloaded,
268
- }
269
-
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;
385
+ impl From<Disposal> for ApiResult<()> {
386
+ fn from(disposal: Disposal) -> Self {
387
+ match disposal {
388
+ Disposal::Reject => Err(ApiError::new("Panel disposed")),
389
+ Disposal::Resolve => Ok(()),
303
390
  }
304
-
305
- *slot = Weak::new();
306
- drop(slot);
307
- self.session.borrow_mut().is_loading = false;
308
- true
309
391
  }
310
392
  }
311
393
 
312
- impl Drop for LoadGuard {
313
- fn drop(&mut self) {
314
- self.close();
315
- }
394
+ #[derive(Debug, Default)]
395
+ pub enum TableIntermediateState {
396
+ #[default]
397
+ Ejected,
316
398
  }
317
399
 
318
400
  /// Options for [`Session::reset`]
@@ -361,21 +443,56 @@ impl Session {
361
443
  Self(Rc::default())
362
444
  }
363
445
 
364
- pub(crate) fn metadata(&self) -> MetadataRef<'_> {
365
- std::cell::Ref::map(self.borrow(), |x| &x.metadata)
446
+ pub(crate) fn metadata(&self) -> MetadataRef {
447
+ MetadataRef(self.borrow().metadata().clone())
366
448
  }
367
449
 
368
450
  pub(crate) fn metadata_mut(&self) -> MetadataMutRef<'_> {
369
- std::cell::RefMut::map(self.borrow_mut(), |x| &mut x.metadata)
451
+ std::cell::RefMut::map(self.borrow_mut(), |x| x.metadata_mut())
370
452
  }
371
453
 
454
+ /// The title as the UI and `save()` see it: the committed title, or the
455
+ /// latest pending rename.
372
456
  pub(crate) fn get_title(&self) -> Option<String> {
373
- self.borrow().title.clone()
457
+ let pending = self
458
+ .0
459
+ .queue
460
+ .pending_edits()
461
+ .into_iter()
462
+ .filter_map(|delta| match delta {
463
+ EditDelta::Title(title) => Some(title),
464
+ _ => None,
465
+ })
466
+ .next_back();
467
+
468
+ pending.unwrap_or_else(|| self.borrow().state.chrome.title.clone())
374
469
  }
375
470
 
471
+ /// Rename this panel — a UI edit, committed by the drain in its turn.
376
472
  pub fn set_title(&self, title: Option<String>) {
473
+ let title = title.filter(|x| !x.is_empty());
474
+ let session = self.clone();
475
+ let _ticket = self.submit(
476
+ OpKind::Edit {
477
+ delta: EditDelta::Title(title.clone()),
478
+ swaps_plugin: false,
479
+ },
480
+ move |_ctx| {
481
+ Box::pin(async move {
482
+ session.commit_title(title);
483
+ Ok(None)
484
+ })
485
+ },
486
+ );
487
+
488
+ self.title_changed.emit(self.get_title());
489
+ }
490
+
491
+ /// Write the title NOW.
492
+ pub(crate) fn commit_title(&self, title: Option<String>) {
377
493
  let new_title = title.filter(|x| !x.is_empty());
378
- self.borrow_mut().title.clone_from(&new_title);
494
+ let next = self.borrow().state.with_title(new_title.clone());
495
+ self.borrow_mut().swap(next);
379
496
  self.title_changed.emit(new_title);
380
497
  }
381
498
 
@@ -385,37 +502,26 @@ impl Session {
385
502
  /// `Session`.
386
503
  pub fn reset(&self, options: ResetOptions) -> impl Future<Output = ApiResult<()>> + use<> {
387
504
  let view = self.0.borrow_mut().view_sub.take();
388
- let had_table = self.borrow().table.is_some();
505
+ let had_table = self.borrow().table().is_some();
389
506
  let err = self.get_error();
390
- self.borrow_mut().error = None;
507
+ self.borrow_mut().clear_errors();
391
508
  if options.stats {
392
509
  self.update_stats(ViewStats::default());
393
510
  }
394
511
 
395
512
  if options.config {
396
- self.borrow_mut().config.reset(options.expressions);
513
+ let mut config = self.borrow().config().clone();
514
+ config.reset(options.expressions);
515
+ self.borrow_mut().set_config(config);
397
516
  }
398
517
 
399
518
  if options.expressions {
400
- self.borrow_mut().last_validated_expressions = None;
519
+ self.borrow_mut().set_description(None);
401
520
  }
402
521
 
403
522
  match options.table {
404
- Some(TableIntermediateState::Ejected) => {
405
- self.borrow_mut().is_loading = false;
406
- self.borrow_mut().table = None;
407
- self.borrow_mut().pending_table = None;
408
- self.borrow_mut().metadata = SessionMetadata::default();
409
- },
410
- Some(TableIntermediateState::Reloaded) => {
411
- self.borrow_mut().is_loading = true;
412
- self.borrow_mut().table = None;
413
- self.borrow_mut().pending_table = None;
414
- self.borrow_mut().metadata = SessionMetadata::default();
415
- },
416
- _ => {
417
- self.borrow_mut().is_loading = false;
418
- },
523
+ Some(TableIntermediateState::Ejected) => self.borrow_mut().unbind(),
524
+ None => {},
419
525
  };
420
526
 
421
527
  // A config reset that KEEPS its `Table` is itself a commit, and every
@@ -440,52 +546,94 @@ impl Session {
440
546
  }
441
547
  }
442
548
 
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);
451
- self.borrow_mut().is_loading = true;
452
- Rc::new(LoadGuard {
453
- session: self.clone(),
454
- window,
455
- })
549
+ /// Submit a writer of this panel's state.
550
+ pub fn submit(&self, kind: OpKind, step: impl FnOnce(OpCtx) -> StepFuture + 'static) -> Ticket {
551
+ if let Some(disposal) = self.disposal() {
552
+ return Ticket::settled(disposal.into());
553
+ }
554
+
555
+ let is_edit = matches!(kind, OpKind::Edit {
556
+ delta: EditDelta::View(_),
557
+ ..
558
+ });
559
+
560
+ let guard = self.begin_config_run();
561
+ let (ticket, start) = self.0.queue.push(kind, guard, step);
562
+ if is_edit {
563
+ self.notify_view_config_changed();
564
+ }
565
+
566
+ if start {
567
+ let queue = self.0.queue.clone();
568
+ let session = self.clone();
569
+ ApiFuture::spawn_named("op-queue-drain", async move {
570
+ queue
571
+ .drain(move |_exit| session.notify_view_config_changed())
572
+ .await;
573
+
574
+ Ok(())
575
+ });
576
+ }
577
+
578
+ ticket
579
+ }
580
+
581
+ /// Resolves once every op submitted to this panel has settled — its write
582
+ /// made (or rejected) AND its render landed.
583
+ pub async fn settle_ops(&self) {
584
+ self.0.queue.idle().await
456
585
  }
457
586
 
458
- /// Whether a `load()` payload is still awaiting classification. A `true`
459
- /// value makes every config-driven bind DEFER
460
- /// ([`crate::tasks::bind_snapshot`]) — the incoming config must never draw
461
- /// against the outgoing table.
587
+ /// Whether a `load()` is queued or running.
462
588
  pub fn has_pending_load(&self) -> bool {
463
- self.0.pending_load.borrow().strong_count() > 0
589
+ self.0.queue.has_load()
464
590
  }
465
591
 
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);
592
+ /// The ONE `Live → Disposed` transition (this session's panel was ejected):
593
+ /// the state is frozen as its last value, everything queued settles per
594
+ /// `disposal`, and every later `submit` settles at once.
595
+ pub(crate) fn dispose(&self, disposal: Disposal) {
596
+ {
597
+ let mut data = self.borrow_mut();
598
+ if let Lifecycle::Live(last) = &data.state {
599
+ data.state = Lifecycle::Disposed {
600
+ last: last.clone(),
601
+ disposal,
602
+ };
603
+ }
604
+ }
605
+
606
+ self.0.queue.reject_all(disposal.into());
607
+ }
608
+
609
+ /// Take the bound `View` out WITHOUT deleting it, so it can feed a derived
610
+ /// `Table`, leaving the next bind nothing to dispose of.
611
+ pub fn take_view_sub(&self) -> Option<ViewSubscription> {
612
+ self.borrow_mut().view_sub.take()
613
+ }
614
+
615
+ /// Put a subscription taken by [`Self::take_view_sub`] back, for a
616
+ /// derivation which could not complete.
617
+ pub fn put_view_sub(&self, sub: ViewSubscription) {
618
+ self.borrow_mut().view_sub = Some(sub);
471
619
  }
472
620
 
473
621
  pub(crate) fn is_disposed(&self) -> bool {
474
- self.borrow().disposed.is_some()
622
+ self.disposal().is_some()
475
623
  }
476
624
 
477
625
  /// This session's disposal, if its panel was ejected.
478
626
  pub(crate) fn disposal(&self) -> Option<Disposal> {
479
- self.borrow().disposed
627
+ self.borrow().state.disposal()
480
628
  }
481
629
 
482
630
  pub(crate) fn has_table(&self) -> Option<TableLoadState> {
483
631
  let data = self.borrow();
484
- if data.table.is_some() {
632
+ if data.table().is_some() {
485
633
  Some(TableLoadState::Loaded)
486
- } else if data.is_loading {
634
+ } else if self.0.queue.has_load() {
487
635
  Some(TableLoadState::Loading)
488
- } else if data.pending_table.is_some() {
636
+ } else if data.pending_table().is_some() {
489
637
  Some(TableLoadState::Pending)
490
638
  } else {
491
639
  None
@@ -493,13 +641,15 @@ impl Session {
493
641
  }
494
642
 
495
643
  pub fn get_table(&self) -> Option<perspective_client::Table> {
496
- self.borrow().table.clone()
644
+ self.borrow().table().cloned()
497
645
  }
498
646
 
647
+ /// Look this panel's tables up on `client` — unbinding it from a table of
648
+ /// any other client's.
499
649
  pub fn set_client(&self, client: Client) -> bool {
500
- if Some(&client) != self.borrow().client.as_ref() {
501
- self.borrow_mut().client = Some(client);
502
- self.borrow_mut().table = None;
650
+ if Some(&client) != self.get_client().as_ref() {
651
+ let next = self.borrow().state.unbound_on(Some(client));
652
+ self.borrow_mut().swap(next);
503
653
  true
504
654
  } else {
505
655
  false
@@ -507,13 +657,13 @@ impl Session {
507
657
  }
508
658
 
509
659
  pub fn get_client(&self) -> Option<Client> {
510
- self.borrow().client.clone()
660
+ self.borrow().state.client()
511
661
  }
512
662
 
513
663
  /// The configured table name awaiting a host, if any (see
514
664
  /// [`SessionData::pending_table`]).
515
665
  pub fn pending_table(&self) -> Option<String> {
516
- self.borrow().pending_table.clone()
666
+ self.borrow().pending_table().map(str::to_owned)
517
667
  }
518
668
 
519
669
  /// Suspend a BOUND table to PENDING: delete the `View`, drop the `Table`
@@ -523,64 +673,35 @@ impl Session {
523
673
  /// as it was when the name is hosted again. `None` when no table is
524
674
  /// bound.
525
675
  pub fn suspend_table(&self) -> Option<impl Future<Output = ApiResult<()>> + use<>> {
526
- let name = self.borrow().table.as_ref()?.get_name().to_owned();
676
+ let name = self.borrow().table()?.get_name().to_owned();
677
+ let client = self.get_client()?;
527
678
  let fut = self.reset(ResetOptions {
528
679
  table: Some(TableIntermediateState::Ejected),
529
680
  stats: true,
530
681
  ..ResetOptions::default()
531
682
  });
532
683
 
533
- self.borrow_mut().pending_table = Some(name);
684
+ let next = self.borrow().state.awaiting_table(client, name);
685
+ self.borrow_mut().swap(next);
534
686
  Some(fut)
535
687
  }
536
688
 
537
- /// Reset this `Session`'s state with a new `Table`. Implicitly clears the
538
- /// `ViewSubscription`, which will need to be re-initialized later via
539
- /// `create_view()`.
540
- ///
541
- /// # Arguments
542
- ///
543
- /// - `table_name` The name of the `Table` to load.
544
- ///
545
- /// # Returns
546
- ///
547
- /// `table_name` is unique per `Client`, so if this value has not changed,
548
- /// `Session::set_table` does nothing and returns `Ok(false)`.
549
- ///
550
- /// A name no loaded client hosts pends ([`MissingTable::Pend`]) and
551
- /// returns `Ok(false)` until the table lifecycle re-runs the bind.
552
- pub async fn set_table(&self, table_name: String) -> ApiResult<bool> {
553
- if Some(table_name.as_str()) == self.0.borrow().table.as_ref().map(|x| x.get_name()) {
554
- self.0.borrow_mut().pending_table = None;
555
- return Ok(false);
556
- }
557
-
558
- let client = self.0.borrow().client.clone().into_apierror()?;
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),
563
- },
564
- None => {
565
- self.pend_table(table_name).await?;
566
- Ok(false)
567
- },
568
- }
569
- }
570
-
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
- }
577
-
578
- let metadata = SessionMetadata::from_table(&table).await?;
579
- let client = table.get_client();
689
+ /// Record a connection error on `client` as this panel's lost binding — for
690
+ /// as long as `client` is the one the panel is bound through.
691
+ async fn watch_client(&self, client: &Client) -> ApiResult<()> {
580
692
  let on_error = self.on_table_errored.borrow().clone();
581
693
  let session = self.clone();
694
+ let watched = client.clone();
582
695
  let poll_loop = LocalPollLoop::new(move |(message, reconnect): (ApiError, _)| {
583
- session.borrow_mut().error = Some(TableErrorState(message, reconnect));
696
+ if session.get_client().as_ref() != Some(&watched) {
697
+ return Ok(JsValue::UNDEFINED);
698
+ }
699
+
700
+ let next = session
701
+ .borrow()
702
+ .state
703
+ .with_lost(TableErrorState(message, reconnect));
704
+ session.borrow_mut().swap(next);
584
705
  if let Some(cb) = &on_error {
585
706
  cb.emit(());
586
707
  }
@@ -601,33 +722,9 @@ impl Session {
601
722
  }))
602
723
  .await?;
603
724
 
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
725
  Ok(())
613
726
  }
614
727
 
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"));
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
629
- }
630
-
631
728
  pub async fn set_error(&self, reset_table: bool, err: ApiError) -> ApiResult<()> {
632
729
  let session = self.clone();
633
730
  let poll_loop = LocalPollLoop::new(move |()| {
@@ -639,7 +736,7 @@ impl Session {
639
736
  Ok(JsValue::UNDEFINED)
640
737
  });
641
738
 
642
- self.borrow_mut().error = Some(TableErrorState(
739
+ let error = TableErrorState(
643
740
  err.clone(),
644
741
  Some(ReconnectCallback::new(move || {
645
742
  clone!(poll_loop);
@@ -648,7 +745,10 @@ impl Session {
648
745
  Ok(())
649
746
  })
650
747
  })),
651
- ));
748
+ );
749
+
750
+ let next = self.borrow().state.with_lost(error);
751
+ self.borrow_mut().swap(next);
652
752
 
653
753
  if let Some(cb) = self.on_table_errored.borrow().as_ref() {
654
754
  cb.emit(());
@@ -656,7 +756,7 @@ impl Session {
656
756
 
657
757
  let sub = self.borrow_mut().view_sub.take();
658
758
  if reset_table {
659
- self.borrow_mut().table = None;
759
+ self.borrow_mut().unbind();
660
760
  }
661
761
 
662
762
  sub.delete().await?;
@@ -686,24 +786,38 @@ impl Session {
686
786
  }
687
787
 
688
788
  pub fn js_get_table(&self) -> Option<JsValue> {
689
- Some(perspective_js::Table::from(self.borrow().table.clone()?).into())
789
+ Some(perspective_js::Table::from(self.borrow().table().cloned()?).into())
690
790
  }
691
791
 
792
+ /// Whether the binding is lost.
692
793
  pub(crate) fn is_errored(&self) -> bool {
693
- self.borrow().error.is_some()
794
+ self.borrow().error().is_some()
694
795
  }
695
796
 
696
797
  pub(crate) fn get_error(&self) -> Option<ApiError> {
697
- self.borrow().error.as_ref().map(|x| x.0.clone())
798
+ self.borrow().error().map(|x| x.0.clone())
698
799
  }
699
800
 
801
+ /// Recover from an error state (the overlay's button) — a writer, so an op
802
+ /// on the queue like any other.
700
803
  pub async fn reconnect(&self) -> ApiResult<()> {
701
- let err = self.borrow().error.clone();
804
+ let session = self.clone();
805
+ self.submit(OpKind::Restore { update: None }, move |_ctx| {
806
+ Box::pin(async move {
807
+ session.reconnect_step().await?;
808
+ Ok(None)
809
+ })
810
+ })
811
+ .settle()
812
+ .await
813
+ }
814
+
815
+ async fn reconnect_step(&self) -> ApiResult<()> {
816
+ let err = self.borrow().error().cloned();
702
817
  if let Some(TableErrorState(_, Some(reconnect))) = err {
703
818
  reconnect().await?;
704
- self.borrow_mut().is_loading = false;
705
- self.borrow_mut().error = None;
706
- self.borrow_mut().last_validated_expressions = None;
819
+ self.borrow_mut().clear_errors();
820
+ self.borrow_mut().set_description(None);
707
821
  self.borrow_mut().view_sub = None;
708
822
  self.table_loaded.emit(());
709
823
  }
@@ -739,36 +853,93 @@ impl Session {
739
853
  .is_some_and(|s| s.num_table_cells.is_some())
740
854
  }
741
855
 
742
- pub fn get_view_config(&'_ self) -> Ref<'_, ViewConfig> {
743
- Ref::map(self.borrow(), |x| &x.config)
856
+ /// The view config as the UI and `save()` see it: the committed config with
857
+ /// every pending UI edit applied, in submit order.
858
+ pub fn get_view_config(&self) -> ViewConfigRef {
859
+ ViewConfigRef(self.projected())
744
860
  }
745
861
 
746
- /// The effective [`ViewConfig`] the `View` is built from: the stored config
747
- /// with the transient element-level [`SessionData::global_filter`] clauses
748
- /// appended. Used at view-creation only; the stored `config` (hence
749
- /// `savePanel`/the settings UI) is unaffected.
750
- fn effective_view_config(&self) -> ViewConfig {
751
- let data = self.borrow();
752
- if data.global_filter.is_empty() {
753
- return data.config.clone();
862
+ /// The COMMITTED view config — what a running op must read, since the edits
863
+ /// still queued behind it have not happened yet.
864
+ pub fn committed_view_config(&self) -> ViewConfigRef {
865
+ ViewConfigRef(self.borrow().state.config().clone())
866
+ }
867
+
868
+ fn projected(&self) -> Rc<ViewConfig> {
869
+ let committed = self.borrow().state.config().clone();
870
+ let edits = self
871
+ .0
872
+ .queue
873
+ .pending_edits()
874
+ .into_iter()
875
+ .filter_map(|delta| match delta {
876
+ EditDelta::View(delta) => Some(*delta),
877
+ _ => None,
878
+ })
879
+ .collect::<Vec<_>>();
880
+
881
+ if edits.is_empty() {
882
+ return committed;
754
883
  }
755
884
 
756
- let mut config = data.config.clone();
757
- config.filter.extend(data.global_filter.iter().cloned());
758
- config
885
+ let mut config = (*committed).clone();
886
+ for delta in edits {
887
+ let mut candidate = config.clone();
888
+ if candidate.apply_update(delta) && self.validate_names(&candidate).is_ok() {
889
+ self.normalize_view_config(&mut candidate);
890
+ config = candidate;
891
+ }
892
+ }
893
+
894
+ Rc::new(config)
759
895
  }
760
896
 
761
- /// Replace the transient element-level global filters and re-render the
762
- /// view (no-op if unchanged). These are applied on top of the panel's
763
- /// own config at view-creation but never persisted into it.
764
- pub fn set_global_filter(&self, filter: Vec<Filter>) -> bool {
765
- if self.borrow().global_filter != filter {
766
- self.borrow_mut().global_filter = filter;
767
- self.notify_view_config_changed();
768
- true
769
- } else {
770
- false
897
+ /// Whether `delta` is an edit the UI may submit: SYNCHRONOUS validation
898
+ /// against the projection, so an invalid edit is refused at the control
899
+ /// that made it.
900
+ pub fn check_edit(&self, delta: &ViewConfigUpdate) -> ApiResult<()> {
901
+ if let Some(x) = self.borrow().state.lost() {
902
+ return Err(ApiError::new(x.0.clone()));
771
903
  }
904
+
905
+ let mut candidate = (*self.projected()).clone();
906
+ if candidate.apply_update(delta.clone()) {
907
+ self.validate_names(&candidate)?;
908
+ }
909
+
910
+ Ok(())
911
+ }
912
+
913
+ /// The effective [`ViewConfig`] the `View` is built from — the one the
914
+ /// committed description describes.
915
+ fn effective_view_config(&self) -> Rc<ViewConfig> {
916
+ let data = self.borrow();
917
+ if let Some((effective, _)) = data.description() {
918
+ return effective.clone();
919
+ }
920
+
921
+ let metadata = data.metadata().clone();
922
+ let (config, _) = effective(data.config(), &data.state.overlay, &|name| {
923
+ metadata.get_column_table_type(name)
924
+ });
925
+
926
+ Rc::new(config)
927
+ }
928
+
929
+ /// The overlay clauses this panel's table cannot honor, by index — a column
930
+ /// it lacks, or has with another type.
931
+ pub fn skipped_overlay(&self) -> Vec<usize> {
932
+ let data = self.borrow();
933
+ let metadata = data.metadata().clone();
934
+ effective(data.config(), &data.state.overlay, &|name| {
935
+ metadata.get_column_table_type(name)
936
+ })
937
+ .1
938
+ }
939
+
940
+ /// The element's global filter as last broadcast to this panel.
941
+ pub(crate) fn committed_overlay(&self) -> Rc<Vec<OverlayClause>> {
942
+ self.borrow().state.overlay.clone()
772
943
  }
773
944
 
774
945
  /// Snapshot of the [`ViewConfig`] the currently-bound `View` was
@@ -777,9 +948,8 @@ impl Session {
777
948
  ///
778
949
  /// Prefer this over [`Self::get_view_config`] when you need a
779
950
  /// value consistent with what the active plugin is rendering.
780
- /// `get_view_config` returns the live config, which is mutated
781
- /// synchronously by [`Self::commit_view_config`] ahead of the next
782
- /// queued run and so may temporarily disagree with the bound `View`.
951
+ /// `get_view_config` returns the PROJECTED config — committed state plus
952
+ /// pending edits — which runs ahead of the bound `View`.
783
953
  pub fn get_rendered_view_config(&self) -> Option<Rc<ViewConfig>> {
784
954
  self.borrow().view_sub.as_ref().map(|s| s.get_view_config())
785
955
  }
@@ -790,7 +960,7 @@ impl Session {
790
960
  config_static: &PluginStaticConfig,
791
961
  ) {
792
962
  use self::column_defaults_update::*;
793
- let config = self.get_view_config();
963
+ let config = self.committed_view_config();
794
964
  config_update.set_update_column_defaults(
795
965
  &self.metadata(),
796
966
  &config,
@@ -812,72 +982,18 @@ impl Session {
812
982
  use self::column_defaults_update::*;
813
983
  config_update.set_update_rollup_defaults(
814
984
  &self.metadata(),
815
- &self.get_view_config(),
985
+ &self.committed_view_config(),
816
986
  config_static,
817
987
  )
818
988
  }
819
989
 
820
- /// Apply a `ViewConfigUpdate` to the live config — the ONLY view-config
821
- /// mutator (invariant I1: synchronous and total; no `await` separates any
822
- /// read of the config from this write, so a lost update is
823
- /// unrepresentable).
824
- ///
825
- /// Validation is SYNCHRONOUS and happens before anything is applied: an
826
- /// update naming an unknown column is rejected with `Err` and the config
827
- /// is untouched (I4 — invalid state is never entered, so no rollback
828
- /// path exists). Server-side expression compilation is deliberately NOT
829
- /// checked here; it is a property of a pipeline run
830
- /// ([`Self::validate_snapshot`]) and fails that run, never the commit.
831
- pub fn commit_view_config(&self, config_update: ViewConfigUpdate) -> ApiResult<()> {
832
- if let Some(x) = self.borrow().error.as_ref() {
833
- tracing::warn!("Errored state");
834
-
835
- // Load bearing return
836
- return Err(ApiError::new(x.0.clone()));
837
- }
838
-
839
- // A `load()` is classifying its payload (see
840
- // [`SessionHandle::pending_load`]): record the RAW delta so a `Table`
841
- // classification can replay it over the reset base, validated against
842
- // the INCOMING table's schema. The live apply below is a best-effort
843
- // preview (kept for `save()` coherence, I1) that SKIPS name validation
844
- // — the delta may legitimately name the incoming table's columns,
845
- // absent from the outgoing one — mirroring the "no table bound yet"
846
- // leniency already in `validate_names`. Authoritative validation is
847
- // deferred to the replay.
848
- let window = self.0.pending_load.borrow().upgrade();
849
- let mut candidate = self.borrow().config.clone();
850
- let journal_entry = window.is_some().then(|| config_update.clone());
851
- if !candidate.apply_update(config_update) {
852
- return Ok(());
853
- }
854
-
855
- if let Some(window) = &window
856
- && let Some(entry) = journal_entry
857
- {
858
- window.journal.borrow_mut().push(entry);
859
- }
860
-
861
- if window.is_none() {
862
- self.validate_names(&candidate)?;
863
- }
864
-
990
+ /// Re-normalize the config against the bound table's metadata — the
991
+ /// default-view materialization after a config reset that keeps its table.
992
+ fn commit_table_defaults(&self) {
993
+ let mut candidate = self.borrow().config().clone();
865
994
  self.normalize_view_config(&mut candidate);
866
- self.borrow_mut().config = candidate;
867
- self.notify_view_config_changed();
868
- Ok(())
869
- }
870
-
871
- /// Table-bind commit: normalize the (possibly empty) config against the
872
- /// newly-bound table's metadata — the default-view materialization that
873
- /// previously happened inside the async validate write-back. SYNC;
874
- /// called immediately after `set_table().await` inside the binding run,
875
- /// so it is ordered like any other commit.
876
- pub fn commit_table_defaults(&self) {
877
- let mut candidate = self.borrow().config.clone();
878
- self.normalize_view_config(&mut candidate);
879
- if candidate != self.borrow().config {
880
- self.borrow_mut().config = candidate;
995
+ if candidate != *self.borrow().config() {
996
+ self.borrow_mut().set_config(candidate);
881
997
  self.notify_view_config_changed();
882
998
  }
883
999
  }
@@ -889,7 +1005,13 @@ impl Session {
889
1005
  /// when no table is bound yet: the config rides along until `load()`
890
1006
  /// binds one, and the engine surfaces any residual error on that run.
891
1007
  fn validate_names(&self, config: &ViewConfig) -> ApiResult<()> {
892
- let table_columns = self.all_columns();
1008
+ Self::validate_names_with(&self.metadata(), config)
1009
+ }
1010
+
1011
+ /// [`Self::validate_names`] against an explicit table's metadata — the
1012
+ /// INCOMING table's, for a restore that binds one.
1013
+ fn validate_names_with(metadata: &SessionMetadata, config: &ViewConfig) -> ApiResult<()> {
1014
+ let table_columns = Self::columns_of(metadata);
893
1015
  if table_columns.is_empty() {
894
1016
  return Ok(());
895
1017
  }
@@ -932,7 +1054,11 @@ impl Session {
932
1054
  /// write-back): fill empty `columns` from the table, prune `aggregates`
933
1055
  /// to referenced columns.
934
1056
  fn normalize_view_config(&self, config: &mut ViewConfig) {
935
- let table_columns = self.all_columns();
1057
+ Self::normalize_with(&self.metadata(), config)
1058
+ }
1059
+
1060
+ fn normalize_with(metadata: &SessionMetadata, config: &mut ViewConfig) {
1061
+ let table_columns = Self::columns_of(metadata);
936
1062
  if table_columns.is_empty() {
937
1063
  return;
938
1064
  }
@@ -1038,53 +1164,336 @@ impl Session {
1038
1164
  /// that preceded run *N*'s completion (invariant I3).
1039
1165
  pub fn snapshot(&self, _guard: &RenderGuard) -> ConfigSnapshot {
1040
1166
  ConfigSnapshot {
1041
- config: Rc::new(self.borrow().config.clone()),
1042
- effective: Rc::new(self.effective_view_config()),
1167
+ config: self.borrow().state.config().clone(),
1168
+ effective: self.effective_view_config(),
1043
1169
  }
1044
1170
  }
1045
1171
 
1046
- /// Validate a snapshot's expressions against the server (the only
1047
- /// inherently-async validation), updating the metadata expression
1048
- /// schema. Reads nothing from — and writes nothing to — the live
1049
- /// config; a failure fails this RUN, never the committed config (I4).
1050
- ///
1051
- /// Memoized: when the snapshot's expressions equal the last successfully
1052
- /// validated set, the round trip is skipped entirely — safe because the
1053
- /// memo key is an immutable snapshot field and the recorded set is
1054
- /// written only under the draw lock.
1172
+ /// Validate a snapshot against the server with `Table::describe`, failing
1173
+ /// this run and never the committed config.
1055
1174
  pub async fn validate_snapshot(
1056
1175
  &self,
1057
1176
  _guard: &RenderGuard,
1058
1177
  snap: ConfigSnapshot,
1059
1178
  ) -> ApiResult<ValidatedSnapshot> {
1060
- let memo_hit =
1061
- self.borrow().last_validated_expressions.as_ref() == Some(&snap.effective.expressions);
1062
- if !memo_hit {
1063
- let supports_expressions = self
1064
- .metadata()
1065
- .get_features()
1066
- .map(|x| x.expressions)
1067
- .unwrap_or_default();
1068
-
1069
- if supports_expressions {
1070
- let table = self
1071
- .borrow()
1072
- .table
1073
- .as_ref()
1074
- .ok_or_else(|| apierror!(NoTableError))?
1075
- .clone();
1076
-
1077
- let valid_recs = table
1078
- .validate_expressions(snap.effective.expressions.clone())
1079
- .await?;
1179
+ let (description, fresh) = self.describe_effective(&snap.effective).await?;
1180
+ if fresh {
1181
+ tracing::warn!("Rendering a commit that was not described");
1182
+ Self::record_description(&mut self.metadata_mut(), &snap.effective, &description)?;
1183
+ self.borrow_mut()
1184
+ .set_description(Some((snap.effective.clone(), description.clone())));
1185
+ }
1186
+
1187
+ Ok(ValidatedSnapshot { snap })
1188
+ }
1189
+
1190
+ /// The server's [`Description`] of `effective`, and whether it took a round
1191
+ /// trip (`false` when the memo already held it).
1192
+ async fn describe_effective(
1193
+ &self,
1194
+ effective: &Rc<ViewConfig>,
1195
+ ) -> ApiResult<(Rc<Description>, bool)> {
1196
+ let memo = self
1197
+ .borrow()
1198
+ .description()
1199
+ .cloned()
1200
+ .filter(|(key, _)| **key == **effective)
1201
+ .map(|(_, description)| description);
1080
1202
 
1081
- self.metadata_mut().update_expressions(&valid_recs)?;
1203
+ if let Some(description) = memo {
1204
+ return Ok((description, false));
1205
+ }
1206
+
1207
+ let table = self
1208
+ .borrow()
1209
+ .table()
1210
+ .cloned()
1211
+ .ok_or_else(|| apierror!(NoTableError))?;
1212
+
1213
+ Self::describe_with(&table, &self.metadata(), effective)
1214
+ .await
1215
+ .map(|description| (description, true))
1216
+ }
1217
+
1218
+ /// `table`'s [`Description`] of `effective`, with validation failures as
1219
+ /// the errors a `restore()` rejects with.
1220
+ async fn describe_with(
1221
+ table: &perspective_client::Table,
1222
+ metadata: &SessionMetadata,
1223
+ effective: &Rc<ViewConfig>,
1224
+ ) -> ApiResult<Rc<Description>> {
1225
+ let engine_config = Self::with_default_aggregates_of(metadata, effective);
1226
+ match table.describe(engine_config.into()).await? {
1227
+ Ok(description) => Ok(Rc::new(description)),
1228
+ Err(DescribeError::Expressions {
1229
+ expression_schema,
1230
+ errors,
1231
+ }) => Err(apierror!(InvalidViewerConfigExpressionsError(Rc::new(
1232
+ ExprValidationResult {
1233
+ expression_schema,
1234
+ errors,
1235
+ expression_alias: effective.expressions.0.clone(),
1236
+ }
1237
+ )))),
1238
+ Err(DescribeError::Config(msg)) => Err(ApiError::new(msg)),
1239
+ }
1240
+ }
1241
+
1242
+ /// The PREPARE half of a transactional restore's view of the panel: the
1243
+ /// binding `plan` resolved, and the config it leaves — the committed one (a
1244
+ /// default one, for a plan that resets) with `update` applied —
1245
+ /// name-checked, normalized and DESCRIBED by the table it will be bound to.
1246
+ pub(crate) async fn prepare_view(
1247
+ &self,
1248
+ plan: BindPlan,
1249
+ mut update: ViewConfigUpdate,
1250
+ defaults: ViewDefaults<'_>,
1251
+ overlay: Option<Rc<Vec<OverlayClause>>>,
1252
+ ) -> ApiResult<PreparedView> {
1253
+ if self.is_disposed() {
1254
+ return Err(ApiError::new("Panel disposed"));
1255
+ }
1256
+
1257
+ if matches!(plan, BindPlan::Keep)
1258
+ && let Some(error) = self.borrow().state.lost()
1259
+ {
1260
+ return Err(error.0.clone());
1261
+ }
1262
+
1263
+ let committed = self.borrow().state.config().clone();
1264
+ let base = match &plan {
1265
+ BindPlan::Bind { reset: true, .. } | BindPlan::Pend { reset: true, .. } => {
1266
+ Rc::new(ViewConfig::default())
1267
+ },
1268
+ _ => committed.clone(),
1269
+ };
1270
+
1271
+ let (binding, checked) = match plan {
1272
+ BindPlan::Keep => {
1273
+ let bound = self.borrow().state.bound().cloned();
1274
+ (
1275
+ PreparedBinding::Keep,
1276
+ bound.map(|bound| (bound.table.clone(), bound.metadata.clone())),
1277
+ )
1278
+ },
1279
+ BindPlan::Bind { table, client, .. } => {
1280
+ let metadata = Rc::new(SessionMetadata::from_table(&table).await?);
1281
+ let binding = PreparedBinding::Bind {
1282
+ client: client.clone(),
1283
+ table: table.clone(),
1284
+ metadata: metadata.clone(),
1285
+ };
1286
+
1287
+ (binding, Some((*table, metadata)))
1288
+ },
1289
+ BindPlan::Pend { client, name, .. } => (PreparedBinding::Pend { client, name }, None),
1290
+ };
1291
+
1292
+ {
1293
+ use self::column_defaults_update::*;
1294
+ let metadata = match &checked {
1295
+ Some((_, metadata)) => metadata.clone(),
1296
+ None => self.borrow().metadata().clone(),
1297
+ };
1298
+
1299
+ match defaults {
1300
+ ViewDefaults::Swap(plugin) => {
1301
+ update.set_update_column_defaults(&metadata, &base, &base.columns, plugin)
1302
+ },
1303
+ ViewDefaults::Rollup(plugin) => {
1304
+ update.set_update_rollup_defaults(&metadata, &base, plugin)
1305
+ },
1306
+ ViewDefaults::AsGiven => {},
1082
1307
  }
1308
+ }
1083
1309
 
1084
- self.borrow_mut().last_validated_expressions = Some(snap.effective.expressions.clone());
1310
+ let mut candidate = (*base).clone();
1311
+ candidate.apply_update(update);
1312
+ if let Some((_, metadata)) = &checked {
1313
+ Self::validate_names_with(metadata, &candidate)?;
1314
+ Self::normalize_with(metadata, &mut candidate);
1085
1315
  }
1086
1316
 
1087
- Ok(ValidatedSnapshot(snap))
1317
+ let config = if candidate == *committed {
1318
+ committed.clone()
1319
+ } else {
1320
+ Rc::new(candidate)
1321
+ };
1322
+
1323
+ let changed = !Rc::ptr_eq(&config, &committed);
1324
+ let clauses = overlay
1325
+ .clone()
1326
+ .unwrap_or_else(|| self.borrow().state.overlay.clone());
1327
+
1328
+ let (effective, description) = match &checked {
1329
+ None => (config.clone(), None),
1330
+ Some((table, metadata)) => {
1331
+ let on_expression = clauses
1332
+ .iter()
1333
+ .any(|x| config.expressions.0.contains_key(x.filter.column()));
1334
+
1335
+ let bare = if on_expression {
1336
+ Some(Self::describe_with(table, metadata, &config).await?)
1337
+ } else {
1338
+ None
1339
+ };
1340
+
1341
+ let (effective, _) = effective(&config, &clauses, &|name| {
1342
+ metadata.get_table_schema_type(name).or_else(|| {
1343
+ bare.as_ref()
1344
+ .and_then(|x| x.expression_schema.get(name).copied())
1345
+ })
1346
+ });
1347
+
1348
+ let effective = if effective == *config {
1349
+ config.clone()
1350
+ } else {
1351
+ Rc::new(effective)
1352
+ };
1353
+
1354
+ let description = match (&binding, bare) {
1355
+ (_, Some(bare)) if Rc::ptr_eq(&effective, &config) => bare,
1356
+ (PreparedBinding::Keep, _) => self.describe_effective(&effective).await?.0,
1357
+ _ => Self::describe_with(table, metadata, &effective).await?,
1358
+ };
1359
+
1360
+ (effective, Some(description))
1361
+ },
1362
+ };
1363
+
1364
+ Ok(PreparedView {
1365
+ binding,
1366
+ overlay,
1367
+ config,
1368
+ effective,
1369
+ description,
1370
+ changed,
1371
+ announce: true,
1372
+ })
1373
+ }
1374
+
1375
+ /// The COMMIT half of [`Self::prepare_view`], as one swap of this session's
1376
+ /// state.
1377
+ pub(crate) fn commit_view(
1378
+ &self,
1379
+ view: PreparedView,
1380
+ title: Option<Option<String>>,
1381
+ ) -> BindingEffects {
1382
+ let state = self.borrow().state.clone();
1383
+ let title_before = state.chrome.title.clone();
1384
+ let had_table = state.bound().is_some();
1385
+ let (rebound, watch) = match view.binding {
1386
+ PreparedBinding::Keep => (None, None),
1387
+ PreparedBinding::Bind {
1388
+ client,
1389
+ table,
1390
+ metadata,
1391
+ } => (
1392
+ Some(state.bound_to(*table, (*metadata).clone())),
1393
+ Some(client),
1394
+ ),
1395
+ PreparedBinding::Pend { client, name } => {
1396
+ (Some(state.awaiting_table(client, name)), None)
1397
+ },
1398
+ };
1399
+
1400
+ let rebinds = rebound.is_some();
1401
+ let mut next = rebound.unwrap_or_else(|| (*state).clone());
1402
+ next = next.with_config(view.config.clone());
1403
+ if let Some(overlay) = view.overlay {
1404
+ next = next.with_overlay(overlay);
1405
+ }
1406
+
1407
+ if let Some(description) = view.description {
1408
+ if let Some(bound) = next.bound() {
1409
+ let mut metadata = (*bound.metadata).clone();
1410
+ if Self::record_description(&mut metadata, &view.effective, &description).is_ok() {
1411
+ next = next.with_metadata(Rc::new(metadata));
1412
+ }
1413
+ }
1414
+
1415
+ next = next.with_description(Some((view.effective, description)));
1416
+ }
1417
+
1418
+ let next = match title {
1419
+ Some(title) => next.with_title(title),
1420
+ None => next,
1421
+ };
1422
+
1423
+ let title_after = next.chrome.title.clone();
1424
+ let bound = next.bound().is_some();
1425
+ self.borrow_mut().swap(next);
1426
+ let outgoing = if rebinds {
1427
+ self.borrow_mut().view_sub.take()
1428
+ } else {
1429
+ None
1430
+ };
1431
+
1432
+ if rebinds {
1433
+ self.update_stats(ViewStats::default());
1434
+ }
1435
+
1436
+ if (view.changed && view.announce) || rebinds {
1437
+ self.notify_view_config_changed();
1438
+ }
1439
+
1440
+ if title_before != title_after {
1441
+ self.title_changed.emit(title_after);
1442
+ }
1443
+
1444
+ BindingEffects {
1445
+ rebinds,
1446
+ outgoing,
1447
+ unloaded: rebinds && had_table,
1448
+ loaded: rebinds && bound,
1449
+ watch,
1450
+ }
1451
+ }
1452
+
1453
+ /// Everything the metadata derives from a [`Description`] of `effective`:
1454
+ /// the expression types, the types the `View` will have, and the window
1455
+ /// columns.
1456
+ fn record_description(
1457
+ metadata: &mut SessionMetadata,
1458
+ effective: &ViewConfig,
1459
+ description: &Description,
1460
+ ) -> ApiResult<()> {
1461
+ metadata.update_expressions(&ExprValidationResult {
1462
+ expression_schema: description.expression_schema.clone(),
1463
+ errors: HashMap::new(),
1464
+ expression_alias: effective.expressions.0.clone(),
1465
+ })?;
1466
+
1467
+ metadata.update_view_schema(&description.view_schema)?;
1468
+ metadata.update_windows(&effective.windows)?;
1469
+ Ok(())
1470
+ }
1471
+
1472
+ /// Finish a committed rebind: dispose of the outgoing `View`, announce the
1473
+ /// table change, and watch the incoming client for errors.
1474
+ pub(crate) async fn finish_binding(&self, effects: BindingEffects) -> ApiResult<()> {
1475
+ let BindingEffects {
1476
+ outgoing,
1477
+ unloaded,
1478
+ loaded,
1479
+ watch,
1480
+ ..
1481
+ } = effects;
1482
+
1483
+ let deleted = outgoing.delete().await;
1484
+ if unloaded {
1485
+ self.table_unloaded.emit(true);
1486
+ }
1487
+
1488
+ if let Some(client) = watch {
1489
+ self.watch_client(&client).await?;
1490
+ }
1491
+
1492
+ if loaded {
1493
+ self.table_loaded.emit(());
1494
+ }
1495
+
1496
+ deleted
1088
1497
  }
1089
1498
 
1090
1499
  /// Bind the engine `View` for a validated snapshot: SKIP
@@ -1112,7 +1521,9 @@ impl Session {
1112
1521
  }
1113
1522
  }
1114
1523
 
1115
- let ConfigSnapshot { config, effective } = validated.0;
1524
+ let ValidatedSnapshot {
1525
+ snap: ConfigSnapshot { config, effective },
1526
+ } = validated;
1116
1527
  if self.borrow().is_paused {
1117
1528
  // A paused bind still RECONCILES the committed config (no `View`
1118
1529
  // is constructed — `view_created` stays silent). Without this
@@ -1128,8 +1539,7 @@ impl Session {
1128
1539
  return Ok(unchanged_or_deferred(self));
1129
1540
  }
1130
1541
 
1131
- let needs_schema = !self.metadata().has_view_schema();
1132
- if !needs_schema {
1542
+ {
1133
1543
  let bound = self.borrow().view_sub.as_ref().map(|x| x.build_config());
1134
1544
  if let Some(bound) = bound {
1135
1545
  if *bound == *effective {
@@ -1153,15 +1563,12 @@ impl Session {
1153
1563
 
1154
1564
  let table = self
1155
1565
  .borrow()
1156
- .table
1157
- .clone()
1566
+ .table()
1567
+ .cloned()
1158
1568
  .ok_or("`restore()` called before `load()`")?;
1159
1569
 
1160
1570
  let view_config = self.with_default_aggregates(&effective);
1161
1571
  let view = table.view(Some(view_config.into())).await?;
1162
- let view_schema = view.schema().await?;
1163
- self.metadata_mut().update_view_schema(&view_schema)?;
1164
- self.metadata_mut().update_windows(&effective.windows)?;
1165
1572
  let on_stats = Callback::from({
1166
1573
  let this = self.clone();
1167
1574
  move |stats| this.update_stats(stats)
@@ -1192,6 +1599,13 @@ impl Session {
1192
1599
  /// The engine config for a `View` built from `effective`, per-column
1193
1600
  /// default aggregates filled in as a courtesy to the virtual server API.
1194
1601
  fn with_default_aggregates(&self, effective: &ViewConfig) -> ViewConfig {
1602
+ Self::with_default_aggregates_of(&self.metadata(), effective)
1603
+ }
1604
+
1605
+ fn with_default_aggregates_of(
1606
+ metadata: &SessionMetadata,
1607
+ effective: &ViewConfig,
1608
+ ) -> ViewConfig {
1195
1609
  let mut view_config = effective.clone();
1196
1610
  for col in view_config
1197
1611
  .columns
@@ -1200,15 +1614,14 @@ impl Session {
1200
1614
  .chain(view_config.sort.iter().map(|x| &x.0))
1201
1615
  {
1202
1616
  if !view_config.aggregates.contains_key(col.as_str()) {
1203
- let agg = self
1204
- .metadata()
1617
+ let agg = metadata
1205
1618
  .get_column_aggregates(col.as_str())
1206
1619
  .and_then(|mut aggs| aggs.next())
1207
1620
  .into_apierror();
1208
1621
 
1209
1622
  match agg {
1210
1623
  Err(_) => {
1211
- tracing::warn!("No default aggregate for column '{}' found, skipping", col)
1624
+ tracing::debug!("No default aggregate for column '{}' found, skipping", col)
1212
1625
  },
1213
1626
  Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg),
1214
1627
  };
@@ -1221,43 +1634,29 @@ impl Session {
1221
1634
  /// Build a caller-owned `View` from the effective config outside the
1222
1635
  /// render pipeline, with no subscription and no interaction with pause.
1223
1636
  pub async fn create_detached_view(&self) -> ApiResult<View> {
1224
- let table = self.borrow().table.clone().ok_or("No `Table` set")?;
1637
+ let table = self.borrow().table().cloned().ok_or("No `Table` set")?;
1225
1638
  let view_config = self.with_default_aggregates(&self.effective_view_config());
1226
1639
  Ok(table.view(Some(view_config.into())).await?)
1227
1640
  }
1228
1641
 
1229
- /// Record a failed pipeline run: error state plus a reconnect affordance
1230
- /// that resets the config (the error screen's reset button). Replaces
1231
- /// the old `validate()` error path — the committed config is NOT rolled
1232
- /// back (I4: it holds exactly what the caller committed; the failure
1233
- /// belongs to the run).
1234
- pub async fn set_run_error(&self, err: ApiError) -> ApiResult<()> {
1642
+ /// Reset the config a failed draw could not render, as one queued op.
1643
+ pub async fn reset_failed_config(&self) -> ApiResult<()> {
1235
1644
  let session = self.clone();
1236
- let poll_loop = LocalPollLoop::new(move |()| {
1237
- ApiFuture::spawn(session.reset(ResetOptions {
1238
- config: true,
1239
- expressions: true,
1240
- ..ResetOptions::default()
1241
- }));
1242
- Ok(JsValue::UNDEFINED)
1243
- });
1244
-
1245
- self.borrow_mut().error = Some(TableErrorState(
1246
- err.clone(),
1247
- Some(ReconnectCallback::new(move || {
1248
- clone!(poll_loop);
1249
- Box::pin(async move {
1250
- poll_loop.poll(()).await;
1251
- Ok(())
1252
- })
1253
- })),
1254
- ));
1255
-
1256
- if let Some(cb) = self.on_table_errored.borrow().as_ref() {
1257
- cb.emit(());
1258
- }
1645
+ self.submit(OpKind::Restore { update: None }, move |_ctx| {
1646
+ Box::pin(async move {
1647
+ session
1648
+ .reset(ResetOptions {
1649
+ config: true,
1650
+ expressions: true,
1651
+ ..ResetOptions::default()
1652
+ })
1653
+ .await?;
1259
1654
 
1260
- Err(err)
1655
+ Ok(None)
1656
+ })
1657
+ })
1658
+ .settle()
1659
+ .await
1261
1660
  }
1262
1661
 
1263
1662
  fn update_stats(&self, stats: ViewStats) {
@@ -1269,8 +1668,8 @@ impl Session {
1269
1668
  self.stats_changed.emit(());
1270
1669
  }
1271
1670
 
1272
- fn all_columns(&self) -> Vec<String> {
1273
- self.metadata()
1671
+ fn columns_of(metadata: &SessionMetadata) -> Vec<String> {
1672
+ metadata
1274
1673
  .get_table_columns()
1275
1674
  .into_iter()
1276
1675
  .flatten()
@@ -1283,6 +1682,8 @@ impl Session {
1283
1682
  /// session-related PubSub event fires.
1284
1683
  pub fn to_props(&self) -> SessionProps {
1285
1684
  let column_stats = PtrEqRc::new(self.column_stats.borrow().clone());
1685
+ let projected = self.projected();
1686
+ let title = self.get_title();
1286
1687
  let data = self.borrow();
1287
1688
 
1288
1689
  // Reuse memoized snapshots when the underlying value hasn't
@@ -1292,15 +1693,15 @@ impl Session {
1292
1693
  // these `PtrEqRc`s will spuriously refire.
1293
1694
  let config = {
1294
1695
  let mut cached = self.cached_config.borrow_mut();
1295
- if !matches!(&*cached, Some(c) if **c == data.config) {
1296
- *cached = Some(PtrEqRc::new(data.config.clone()));
1696
+ if !matches!(&*cached, Some(c) if **c == *projected) {
1697
+ *cached = Some(PtrEqRc::new((*projected).clone()));
1297
1698
  }
1298
1699
  cached.clone().unwrap()
1299
1700
  };
1300
1701
  let metadata = {
1301
1702
  let mut cached = self.cached_metadata.borrow_mut();
1302
- if !matches!(&*cached, Some(m) if **m == data.metadata) {
1303
- *cached = Some(PtrEqRc::new(data.metadata.clone()));
1703
+ if !matches!(&*cached, Some(m) if **m == **data.metadata()) {
1704
+ *cached = Some(PtrEqRc::new((**data.metadata()).clone()));
1304
1705
  }
1305
1706
  cached.clone().unwrap()
1306
1707
  };
@@ -1311,23 +1712,126 @@ impl Session {
1311
1712
  .stats
1312
1713
  .as_ref()
1313
1714
  .is_some_and(|s| s.num_table_cells.is_some()),
1314
- has_table: if data.table.is_some() {
1715
+ has_table: if data.table().is_some() {
1315
1716
  Some(TableLoadState::Loaded)
1316
- } else if data.is_loading {
1717
+ } else if self.0.queue.has_load() {
1317
1718
  Some(TableLoadState::Loading)
1318
- } else if data.pending_table.is_some() {
1719
+ } else if data.pending_table().is_some() {
1319
1720
  Some(TableLoadState::Pending)
1320
1721
  } else {
1321
1722
  None
1322
1723
  },
1323
- error: data.error.clone(),
1324
- title: data.title.clone(),
1724
+ error: data.error().cloned(),
1725
+ title,
1325
1726
  metadata,
1326
1727
  column_stats,
1327
1728
  }
1328
1729
  }
1329
1730
  }
1330
1731
 
1732
+ /// A view-config change that has passed every check — names, normalization and
1733
+ /// the server's `describe` — and only awaits [`Session::commit_view`].
1734
+ pub(crate) struct PreparedView {
1735
+ binding: PreparedBinding,
1736
+
1737
+ /// A new overlay to commit, when the op broadcasts one.
1738
+ overlay: Option<Rc<Vec<OverlayClause>>>,
1739
+ config: Rc<ViewConfig>,
1740
+ effective: Rc<ViewConfig>,
1741
+ description: Option<Rc<Description>>,
1742
+ changed: bool,
1743
+
1744
+ /// `false` for a projected UI edit, whose SUBMIT already announced it.
1745
+ announce: bool,
1746
+ }
1747
+
1748
+ /// The plugin whose advice fills in what a restore's view config leaves unsaid
1749
+ /// — computed against the table and config the restore LANDS on.
1750
+ pub(crate) enum ViewDefaults<'a> {
1751
+ /// The restore swaps to this plugin: default its columns and rollup mode.
1752
+ Swap(&'a PluginStaticConfig),
1753
+
1754
+ /// The restore stays on this plugin: re-enforce its rollup mode only.
1755
+ Rollup(&'a PluginStaticConfig),
1756
+
1757
+ /// The update is complete as given (a UI edit, whose control applied the
1758
+ /// plugin's advice when it made it).
1759
+ AsGiven,
1760
+ }
1761
+
1762
+ /// What a restore does to the panel's table binding — decided by its caller
1763
+ /// from the restore's `table` and the panel it lands on, with the incoming
1764
+ /// table already probed.
1765
+ pub(crate) enum BindPlan {
1766
+ /// The binding stands: bound, awaiting or unbound, as it is.
1767
+ Keep,
1768
+
1769
+ /// Bind `table`.
1770
+ Bind {
1771
+ client: Client,
1772
+ table: Box<perspective_client::Table>,
1773
+ reset: bool,
1774
+ },
1775
+
1776
+ /// Await a host for `name`.
1777
+ Pend {
1778
+ client: Client,
1779
+ name: String,
1780
+ reset: bool,
1781
+ },
1782
+ }
1783
+
1784
+ enum PreparedBinding {
1785
+ Keep,
1786
+ Bind {
1787
+ client: Client,
1788
+ table: Box<perspective_client::Table>,
1789
+ metadata: Rc<SessionMetadata>,
1790
+ },
1791
+ Pend {
1792
+ client: Client,
1793
+ name: String,
1794
+ },
1795
+ }
1796
+
1797
+ /// What [`Session::commit_view`] leaves for [`Session::finish_binding`].
1798
+ #[must_use]
1799
+ pub(crate) struct BindingEffects {
1800
+ rebinds: bool,
1801
+ outgoing: Option<ViewSubscription>,
1802
+ unloaded: bool,
1803
+ loaded: bool,
1804
+ watch: Option<Client>,
1805
+ }
1806
+
1807
+ impl BindingEffects {
1808
+ /// Whether the commit replaced the panel's table binding.
1809
+ pub fn rebound(&self) -> bool {
1810
+ self.rebinds
1811
+ }
1812
+
1813
+ /// Discard the effects of a commit that kept its binding (there are none).
1814
+ pub fn forget(self) {}
1815
+ }
1816
+
1817
+ impl PreparedView {
1818
+ /// Mark this as a projected UI edit: the UI has shown it since it was
1819
+ /// submitted, so committing it announces nothing.
1820
+ pub fn projected(mut self) -> Self {
1821
+ self.announce = false;
1822
+ self
1823
+ }
1824
+
1825
+ pub fn config(&self) -> &ViewConfig {
1826
+ &self.config
1827
+ }
1828
+
1829
+ /// The type each column will have in the `View` this config builds.
1830
+ pub fn view_schema(&self) -> Option<&HashMap<String, perspective_client::proto::ColumnType>> {
1831
+ self.description.as_ref().map(|x| &x.view_schema)
1832
+ }
1833
+ }
1834
+
1331
1835
  /// One pipeline run's frozen input (invariant I2): the persisted
1332
1836
  /// [`ViewConfig`] and its EFFECTIVE companion (global-filter overlay
1333
1837
  /// appended), captured at the same synchronous instant inside the draw
@@ -1338,10 +1842,11 @@ pub struct ConfigSnapshot {
1338
1842
  pub effective: Rc<ViewConfig>,
1339
1843
  }
1340
1844
 
1341
- /// Type-state token: proof this snapshot's expressions were validated by
1342
- /// [`Session::validate_snapshot`]. [`Session::bind_view`] accepts only this
1343
- /// token, so a `View` can never be built from an unvalidated snapshot.
1344
- pub struct ValidatedSnapshot(ConfigSnapshot);
1845
+ /// Type-state token: proof this snapshot was validated by
1846
+ /// [`Session::validate_snapshot`], carrying the server's [`Description`] of it.
1847
+ pub struct ValidatedSnapshot {
1848
+ snap: ConfigSnapshot,
1849
+ }
1345
1850
 
1346
1851
  /// Type-state witness that a `View` is NEW for the plugin about to render
1347
1852
  /// it.