@perspective-dev/viewer 5.4.0 → 5.5.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 (129) 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/GetViewMode.d.ts +4 -0
  38. package/dist/esm/ts-rs/GetViewOptions.d.ts +8 -0
  39. package/dist/esm/ts-rs/RestoreOptions.d.ts +6 -0
  40. package/dist/esm/ts-rs/RestoreWorkspaceOptions.d.ts +9 -0
  41. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +30 -22
  42. package/dist/wasm/perspective-viewer.d.ts +91 -47
  43. package/dist/wasm/perspective-viewer.js +216 -136
  44. package/dist/wasm/perspective-viewer.wasm +0 -0
  45. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -18
  46. package/package.json +5 -5
  47. package/src/css/column-selector.css +11 -3
  48. package/src/css/column-style.css +162 -0
  49. package/src/css/config-selector.css +1 -1
  50. package/src/css/form/debug.css +9 -0
  51. package/src/css/status-bar.css +11 -0
  52. package/src/css/ui/containers/tabs.css +2 -1
  53. package/src/rust/agent/tools.rs +80 -7
  54. package/src/rust/components/column_selector/active_column.rs +9 -0
  55. package/src/rust/components/column_selector.rs +11 -0
  56. package/src/rust/components/column_settings_sidebar/style_tab/alignment_field.rs +90 -0
  57. package/src/rust/components/column_settings_sidebar/style_tab/font_field.rs +267 -0
  58. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +1 -1
  59. package/src/rust/components/column_settings_sidebar/style_tab.rs +68 -9
  60. package/src/rust/components/debug_panel.rs +107 -50
  61. package/src/rust/components/main_panel/msg.rs +4 -0
  62. package/src/rust/components/main_panel.rs +14 -0
  63. package/src/rust/components/mod.rs +0 -1
  64. package/src/rust/components/plugin_tab.rs +57 -3
  65. package/src/rust/components/settings_panel.rs +6 -0
  66. package/src/rust/components/status_indicator.rs +4 -0
  67. package/src/rust/components/viewer/panels.rs +3 -0
  68. package/src/rust/components/viewer/render.rs +1 -0
  69. package/src/rust/components/viewer/wiring.rs +10 -1
  70. package/src/rust/config/column_config_schema.rs +312 -8
  71. package/src/rust/config/datetime_column_style.rs +0 -11
  72. package/src/rust/config/mod.rs +0 -2
  73. package/src/rust/config/options.rs +50 -0
  74. package/src/rust/config/workspace_config.rs +69 -24
  75. package/src/rust/custom_elements/viewer.rs +194 -97
  76. package/src/rust/js/plugin.rs +3 -1
  77. package/src/rust/lib.rs +7 -3
  78. package/src/rust/queries/plugin_column_styles.rs +6 -5
  79. package/src/rust/renderer/plugin_config.rs +200 -41
  80. package/src/rust/renderer/plugin_store.rs +2 -1
  81. package/src/rust/renderer/props.rs +5 -0
  82. package/src/rust/renderer.rs +9 -0
  83. package/src/rust/session/props.rs +4 -0
  84. package/src/rust/session.rs +151 -80
  85. package/src/rust/tasks/create_panel.rs +4 -1
  86. package/src/rust/tasks/pipeline.rs +18 -7
  87. package/src/rust/tasks/restore_panel.rs +129 -30
  88. package/src/rust/tasks/send_column_config.rs +3 -0
  89. package/src/rust/tasks/sync_update_panels.rs +147 -67
  90. package/src/rust/tasks/table_lifecycle.rs +7 -1
  91. package/src/rust/ui/containers/tab_list.rs +1 -1
  92. package/src/rust/ui/form/intl_label.rs +4 -23
  93. package/src/rust/ui/portal.rs +1 -0
  94. package/src/rust/utils/css_vars.rs +13 -0
  95. package/src/rust/utils/local_fonts.rs +395 -0
  96. package/src/rust/utils/mod.rs +2 -0
  97. package/src/rust/workspace.rs +12 -0
  98. package/src/svg/status_pending.svg +3 -0
  99. package/src/themes/icons.css +1 -0
  100. package/src/themes/intl/ar.css +20 -7
  101. package/src/themes/intl/de.css +20 -7
  102. package/src/themes/intl/es.css +20 -7
  103. package/src/themes/intl/fr.css +20 -7
  104. package/src/themes/intl/ja.css +20 -7
  105. package/src/themes/intl/pt.css +20 -7
  106. package/src/themes/intl/zh.css +20 -7
  107. package/src/themes/intl.css +20 -7
  108. package/src/ts/perspective-viewer.ts +2 -0
  109. package/src/ts/plugin.ts +9 -0
  110. package/src/ts/ts-rs/AddPanelOptions.ts +10 -0
  111. package/src/ts/ts-rs/{DatetimeColorMode.ts → GetViewMode.ts} +4 -1
  112. package/src/ts/ts-rs/GetViewOptions.ts +7 -0
  113. package/src/ts/ts-rs/RestoreOptions.ts +7 -1
  114. package/src/ts/ts-rs/RestoreWorkspaceOptions.ts +10 -0
  115. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +31 -20
  116. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +0 -1
  117. package/dist/esm/ts-rs/FormatMode.d.ts +0 -1
  118. package/dist/esm/ts-rs/StringColorMode.d.ts +0 -1
  119. package/src/rust/components/string_column_style.rs +0 -120
  120. package/src/rust/config/datetime_column_style/color_mode.rs +0 -62
  121. package/src/rust/config/string_column_style.rs +0 -135
  122. package/src/ts/ts-rs/FormatMode.ts +0 -3
  123. package/src/ts/ts-rs/StringColorMode.ts +0 -3
  124. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline0.js +0 -0
  125. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline1.js +0 -0
  126. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline2.js +0 -0
  127. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline3.js +0 -0
  128. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline4.js +0 -0
  129. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline5.js +0 -0
@@ -152,6 +152,51 @@ impl Deref for SessionHandle {
152
152
  }
153
153
  }
154
154
 
155
+ /// What an un-hosted `table` name means to a bind, per the `wait_for_table`
156
+ /// option.
157
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
158
+ pub enum MissingTable {
159
+ /// A hard error that leaves the panel as it was.
160
+ Error,
161
+
162
+ /// The panel waits unbound and binds when the name is hosted.
163
+ Pend,
164
+ }
165
+
166
+ impl MissingTable {
167
+ /// Decode the `wait_for_table` option (absent = `false` = `Error`).
168
+ pub fn from_wait(wait_for_table: Option<bool>) -> Self {
169
+ if wait_for_table.unwrap_or_default() {
170
+ Self::Pend
171
+ } else {
172
+ Self::Error
173
+ }
174
+ }
175
+ }
176
+
177
+ /// Open `name` on `client`, returning `Ok(None)` when the name is not hosted
178
+ /// and `missing` is [`MissingTable::Pend`].
179
+ pub(crate) async fn probe_table(
180
+ client: &Client,
181
+ name: &str,
182
+ missing: MissingTable,
183
+ ) -> ApiResult<Option<perspective_client::Table>> {
184
+ match client.open_table(name.to_owned()).await {
185
+ Ok(table) => Ok(Some(table)),
186
+ Err(e) => {
187
+ let hosted = client.get_hosted_table_names().await.unwrap_or_default();
188
+ if hosted.iter().any(|n| n == name) {
189
+ return Err(e.into());
190
+ }
191
+
192
+ match missing {
193
+ MissingTable::Pend => Ok(None),
194
+ MissingTable::Error => Err(ApiError::new(format!("Unknown table \"{name}\""))),
195
+ }
196
+ },
197
+ }
198
+ }
199
+
155
200
  /// Mutable state for `Session`.
156
201
  #[derive(Default)]
157
202
  pub struct SessionData {
@@ -309,6 +354,7 @@ impl Session {
309
354
  /// `Session`.
310
355
  pub fn reset(&self, options: ResetOptions) -> impl Future<Output = ApiResult<()>> + use<> {
311
356
  let view = self.0.borrow_mut().view_sub.take();
357
+ let had_table = self.borrow().table.is_some();
312
358
  let err = self.get_error();
313
359
  self.borrow_mut().error = None;
314
360
  if options.stats {
@@ -328,11 +374,13 @@ impl Session {
328
374
  self.borrow_mut().is_loading = false;
329
375
  self.borrow_mut().table = None;
330
376
  self.borrow_mut().pending_table = None;
377
+ self.borrow_mut().metadata = SessionMetadata::default();
331
378
  },
332
379
  Some(TableIntermediateState::Reloaded) => {
333
380
  self.borrow_mut().is_loading = true;
334
381
  self.borrow_mut().table = None;
335
382
  self.borrow_mut().pending_table = None;
383
+ self.borrow_mut().metadata = SessionMetadata::default();
336
384
  },
337
385
  _ => {
338
386
  self.borrow_mut().is_loading = false;
@@ -353,7 +401,7 @@ impl Session {
353
401
  let session = self.clone();
354
402
  async move {
355
403
  let res = view.delete().await;
356
- if options.table.is_some() {
404
+ if options.table.is_some() && had_table {
357
405
  session.table_unloaded.emit(true)
358
406
  }
359
407
 
@@ -410,6 +458,8 @@ impl Session {
410
458
  Some(TableLoadState::Loaded)
411
459
  } else if data.is_loading {
412
460
  Some(TableLoadState::Loading)
461
+ } else if data.pending_table.is_some() {
462
+ Some(TableLoadState::Pending)
413
463
  } else {
414
464
  None
415
465
  }
@@ -470,10 +520,8 @@ impl Session {
470
520
  /// `table_name` is unique per `Client`, so if this value has not changed,
471
521
  /// `Session::set_table` does nothing and returns `Ok(false)`.
472
522
  ///
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.
523
+ /// A name no loaded client hosts pends ([`MissingTable::Pend`]) and
524
+ /// returns `Ok(false)` until the table lifecycle re-runs the bind.
477
525
  pub async fn set_table(&self, table_name: String) -> ApiResult<bool> {
478
526
  if Some(table_name.as_str()) == self.0.borrow().table.as_ref().map(|x| x.get_name()) {
479
527
  self.0.borrow_mut().pending_table = None;
@@ -481,60 +529,68 @@ impl Session {
481
529
  }
482
530
 
483
531
  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);
532
+ match probe_table(&client, &table_name, MissingTable::Pend).await? {
533
+ Some(table) => match self.bind_table(table).await {
534
+ Ok(()) => Ok(true),
535
+ Err(err) => self.set_error(false, err).await.map(|_| false),
496
536
  },
497
- };
537
+ None => {
538
+ self.pend_table(table_name).await?;
539
+ Ok(false)
540
+ },
541
+ }
542
+ }
498
543
 
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
- }
544
+ /// Bind an opened `Table`, replacing any previous binding and announcing
545
+ /// `table_loaded`.
546
+ pub(crate) async fn bind_table(&self, table: perspective_client::Table) -> ApiResult<()> {
547
+ let metadata = SessionMetadata::from_table(&table).await?;
548
+ let client = table.get_client();
549
+ let on_error = self.on_table_errored.borrow().clone();
550
+ let session = self.clone();
551
+ let poll_loop = LocalPollLoop::new(move |(message, reconnect): (ApiError, _)| {
552
+ session.borrow_mut().error = Some(TableErrorState(message, reconnect));
553
+ if let Some(cb) = &on_error {
554
+ cb.emit(());
555
+ }
556
+ if let Some(sub) = session.borrow_mut().view_sub.take() {
557
+ sub.dismiss();
558
+ }
512
559
 
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?;
560
+ Ok(JsValue::UNDEFINED)
561
+ });
525
562
 
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),
537
- }
563
+ let _callback_id = client
564
+ .on_error(Box::new(move |message: ClientError, reconnect| {
565
+ let poll_loop = poll_loop.clone();
566
+ async move {
567
+ poll_loop.poll((message.into(), reconnect)).await;
568
+ Ok(())
569
+ }
570
+ }))
571
+ .await?;
572
+
573
+ let sub = self.borrow_mut().view_sub.take();
574
+ self.borrow_mut().metadata = metadata;
575
+ self.borrow_mut().table = Some(table);
576
+ self.borrow_mut().pending_table = None;
577
+ self.borrow_mut().is_loading = false;
578
+ self.borrow_mut().last_validated_expressions = None;
579
+ sub.delete().await?;
580
+ self.table_loaded.emit(());
581
+ Ok(())
582
+ }
583
+
584
+ /// Record `name` as pending, dropping any bound `Table` so nothing binds
585
+ /// against the outgoing table while the name waits for a host.
586
+ pub(crate) async fn pend_table(&self, name: String) -> ApiResult<()> {
587
+ let sub = self.borrow_mut().view_sub.take();
588
+ self.borrow_mut().table = None;
589
+ self.borrow_mut().metadata = SessionMetadata::default();
590
+ self.borrow_mut().pending_table = Some(name);
591
+ self.borrow_mut().is_loading = false;
592
+ self.borrow_mut().last_validated_expressions = None;
593
+ sub.delete().await
538
594
  }
539
595
 
540
596
  pub async fn set_error(&self, reset_table: bool, err: ApiError) -> ApiResult<()> {
@@ -1094,31 +1150,7 @@ impl Session {
1094
1150
  .clone()
1095
1151
  .ok_or("`restore()` called before `load()`")?;
1096
1152
 
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
-
1153
+ let view_config = self.with_default_aggregates(&effective);
1122
1154
  let view = table.view(Some(view_config.into())).await?;
1123
1155
  let view_schema = view.schema().await?;
1124
1156
  self.metadata_mut().update_view_schema(&view_schema)?;
@@ -1150,6 +1182,43 @@ impl Session {
1150
1182
  }
1151
1183
  }
1152
1184
 
1185
+ /// The engine config for a `View` built from `effective`, per-column
1186
+ /// default aggregates filled in as a courtesy to the virtual server API.
1187
+ fn with_default_aggregates(&self, effective: &ViewConfig) -> ViewConfig {
1188
+ let mut view_config = effective.clone();
1189
+ for col in view_config
1190
+ .columns
1191
+ .iter()
1192
+ .flatten()
1193
+ .chain(view_config.sort.iter().map(|x| &x.0))
1194
+ {
1195
+ if !view_config.aggregates.contains_key(col.as_str()) {
1196
+ let agg = self
1197
+ .metadata()
1198
+ .get_column_aggregates(col.as_str())
1199
+ .and_then(|mut aggs| aggs.next())
1200
+ .into_apierror();
1201
+
1202
+ match agg {
1203
+ Err(_) => {
1204
+ tracing::warn!("No default aggregate for column '{}' found, skipping", col)
1205
+ },
1206
+ Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg),
1207
+ };
1208
+ }
1209
+ }
1210
+
1211
+ view_config
1212
+ }
1213
+
1214
+ /// Build a caller-owned `View` from the effective config outside the
1215
+ /// render pipeline, with no subscription and no interaction with pause.
1216
+ pub async fn create_detached_view(&self) -> ApiResult<View> {
1217
+ let table = self.borrow().table.clone().ok_or("No `Table` set")?;
1218
+ let view_config = self.with_default_aggregates(&self.effective_view_config());
1219
+ Ok(table.view(Some(view_config.into())).await?)
1220
+ }
1221
+
1153
1222
  /// Record a failed pipeline run: error state plus a reconnect affordance
1154
1223
  /// that resets the config (the error screen's reset button). Replaces
1155
1224
  /// the old `validate()` error path — the committed config is NOT rolled
@@ -1239,6 +1308,8 @@ impl Session {
1239
1308
  Some(TableLoadState::Loaded)
1240
1309
  } else if data.is_loading {
1241
1310
  Some(TableLoadState::Loading)
1311
+ } else if data.pending_table.is_some() {
1312
+ Some(TableLoadState::Pending)
1242
1313
  } else {
1243
1314
  None
1244
1315
  },
@@ -17,7 +17,7 @@ use crate::config::*;
17
17
  use crate::custom_events::wire_panel_events;
18
18
  use crate::presentation::*;
19
19
  use crate::renderer::*;
20
- use crate::session::{ResetOptions, Session};
20
+ use crate::session::{MissingTable, ResetOptions, Session};
21
21
  use crate::tasks::*;
22
22
  use crate::utils::*;
23
23
  use crate::workspace::{Panel, PanelId, PanelPhase, Workspace};
@@ -76,6 +76,7 @@ fn wire_panel_render_sub(session: &Session, renderer: &Renderer) -> Subscription
76
76
  /// one. `theme` is stripped (element-level, not per-panel) and `client` —
77
77
  /// or the element's default client when `None` — is bound so the config's
78
78
  /// `table` resolves against it.
79
+ #[allow(clippy::too_many_arguments)]
79
80
  pub(crate) async fn create_panel(
80
81
  elem: &HtmlElement,
81
82
  presentation: &Presentation,
@@ -84,6 +85,7 @@ pub(crate) async fn create_panel(
84
85
  id: Option<PanelId>,
85
86
  config: ViewerConfigInitial,
86
87
  client: Option<perspective_client::Client>,
88
+ missing: MissingTable,
87
89
  ) -> ApiResult<PanelId> {
88
90
  let (id, session, renderer, update) = create_panel_model(
89
91
  elem,
@@ -120,6 +122,7 @@ pub(crate) async fn create_panel(
120
122
  RestoreMode::Fresh,
121
123
  update,
122
124
  crate::tasks::RestoreErrors::Publish,
125
+ missing,
123
126
  )
124
127
  .await;
125
128
 
@@ -251,6 +251,18 @@ pub(crate) async fn locked_run(
251
251
  let view_config_snapshot = session.get_view_config().clone();
252
252
  let plugin_config_changed =
253
253
  renderer.update_plugin_config(&view_config_snapshot, spec.plugin_config)?;
254
+ let plugin_config_snapshot = renderer.get_plugin_config();
255
+ let plugin_update =
256
+ JsValue::from_serde_ext(&plugin_config_snapshot).unwrap_or(JsValue::NULL);
257
+
258
+ let plugin_restored = plugin_config_changed || plugin_swapped;
259
+ if plugin_restored {
260
+ let columns_config = renderer
261
+ .all_columns_configs_materialized(&view_config_snapshot, &session)
262
+ .await;
263
+ plugin.restore(&plugin_update, Some(&columns_config))?;
264
+ }
265
+
254
266
  let columns_config_changed = renderer.update_columns_configs(
255
267
  &view_config_snapshot,
256
268
  &session,
@@ -258,17 +270,16 @@ pub(crate) async fn locked_run(
258
270
  )?;
259
271
 
260
272
  let changed = plugin_config_changed || columns_config_changed;
261
- if changed || plugin_swapped {
262
- let plugin_config_snapshot = renderer.get_plugin_config();
263
- let plugin_update =
264
- JsValue::from_serde_ext(&plugin_config_snapshot).unwrap_or(JsValue::NULL);
273
+ if columns_config_changed {
265
274
  let columns_config = renderer
266
275
  .all_columns_configs_materialized(&view_config_snapshot, &session)
267
276
  .await;
268
277
  plugin.restore(&plugin_update, Some(&columns_config))?;
269
- if plugin_config_changed {
270
- renderer.plugin_config_changed.emit(plugin_config_snapshot);
271
- }
278
+ renderer.columns_config_changed.emit(columns_config);
279
+ }
280
+
281
+ if plugin_config_changed {
282
+ renderer.plugin_config_changed.emit(plugin_config_snapshot);
272
283
  }
273
284
 
274
285
  if spec
@@ -16,7 +16,7 @@ use perspective_client::utils::PerspectiveResultExt;
16
16
  use crate::config::*;
17
17
  use crate::presentation::Presentation;
18
18
  use crate::renderer::Renderer;
19
- use crate::session::{ResetOptions, Session};
19
+ use crate::session::{MissingTable, ResetOptions, Session, TableIntermediateState, probe_table};
20
20
  use crate::tasks::*;
21
21
  use crate::workspace::Workspace;
22
22
  use crate::*;
@@ -38,27 +38,107 @@ pub(crate) enum RestoreErrors {
38
38
  Suppress,
39
39
  }
40
40
 
41
+ /// The client to bind and the opened `Table`, or `None` when pending.
42
+ type Probed = (
43
+ perspective_client::Client,
44
+ Option<perspective_client::Table>,
45
+ );
46
+
47
+ /// Resolve and probe the client hosting `name` without touching the
48
+ /// session's own binding.
49
+ async fn probe(
50
+ session: &Session,
51
+ workspace: &Workspace,
52
+ name: &str,
53
+ missing: MissingTable,
54
+ ) -> ApiResult<Probed> {
55
+ let current = session.get_client();
56
+ let resolved = workspace
57
+ .resolve_client_for_table(name, current.as_ref())
58
+ .await;
59
+
60
+ let client = resolved.or(current).into_apierror()?;
61
+ let table = probe_table(&client, name, missing).await?;
62
+ Ok((client, table))
63
+ }
64
+
65
+ /// Bind an unbound or same-named session to `name`.
41
66
  pub(crate) async fn bind_table_task(
42
67
  session: &Session,
43
68
  workspace: &Workspace,
44
69
  name: String,
70
+ missing: MissingTable,
45
71
  ) -> ApiResult<()> {
46
- let current = session.get_client();
47
- if let Some(client) = workspace
48
- .resolve_client_for_table(&name, current.as_ref())
49
- .await
72
+ if session
73
+ .get_table()
74
+ .is_some_and(|t| t.get_name() == name.as_str())
50
75
  {
51
- session.set_client(client);
76
+ return Ok(());
77
+ }
78
+
79
+ let (client, table) = probe(session, workspace, &name, missing).await?;
80
+ session.set_client(client);
81
+ match table {
82
+ Some(table) => session.bind_table(table).await?,
83
+ None => session.pend_table(name).await?,
84
+ }
85
+
86
+ session.commit_table_defaults();
87
+ Ok(())
88
+ }
89
+
90
+ /// Rebind a session to `name`, probing the incoming table before the
91
+ /// outgoing binding is dropped and replaying the journal opened as
92
+ /// `generation` over the incoming table's defaults.
93
+ async fn rebind_table_task(
94
+ session: &Session,
95
+ renderer: &Renderer,
96
+ workspace: &Workspace,
97
+ name: String,
98
+ missing: MissingTable,
99
+ generation: u32,
100
+ ) -> ApiResult<()> {
101
+ let probed = probe(session, workspace, &name, missing).await;
102
+ let journal = session.take_pending_load(generation);
103
+ let (client, table) = probed?;
104
+ let Some(journal) = journal else {
105
+ return Ok(());
106
+ };
107
+
108
+ session
109
+ .reset(ResetOptions {
110
+ config: true,
111
+ expressions: true,
112
+ stats: true,
113
+ table: Some(TableIntermediateState::Reloaded),
114
+ })
115
+ .await?;
116
+
117
+ session.set_client(client);
118
+ match table {
119
+ Some(table) => session.bind_table(table).await?,
120
+ None => session.pend_table(name).await?,
52
121
  }
53
122
 
54
- session.set_table(name).await?;
55
123
  session.commit_table_defaults();
124
+ for delta in journal {
125
+ session.commit_view_config(delta)?;
126
+ }
127
+
128
+ session.commit_table_defaults();
129
+ if session.get_table().is_none()
130
+ && let Some(plugin) = renderer.active_plugin()
131
+ {
132
+ plugin.clear().await?;
133
+ }
134
+
56
135
  Ok(())
57
136
  }
58
137
 
59
138
  /// Apply a [`ViewerConfigUpdate`] to a single panel and re-draw — the one
60
139
  /// pipeline shared by `restorePanel` (an existing panel), `restoreWorkspace`,
61
140
  /// and `addPanel` (both fresh panels).
141
+ #[allow(clippy::too_many_arguments)]
62
142
  pub(crate) async fn restore_panel(
63
143
  session: &Session,
64
144
  renderer: &Renderer,
@@ -67,6 +147,7 @@ pub(crate) async fn restore_panel(
67
147
  mode: RestoreMode,
68
148
  mut update: ViewerConfigUpdate,
69
149
  errors: RestoreErrors,
150
+ missing: MissingTable,
70
151
  ) -> ApiResult<()> {
71
152
  let active = matches!(mode, RestoreMode::Existing { active: true });
72
153
  let fresh = matches!(mode, RestoreMode::Fresh);
@@ -100,21 +181,24 @@ pub(crate) async fn restore_panel(
100
181
  let rollback =
101
182
  matches!(errors, RestoreErrors::Suppress).then(|| session.get_view_config().clone());
102
183
 
103
- let table_changed = !fresh
104
- && matches!(&update.table, OptionalUpdate::Update(name)
105
- if session.get_table().map(|t| t.get_name() != name.as_str()).unwrap_or(true));
184
+ let binding_before = (
185
+ session.get_table().map(|t| t.get_name().to_owned()),
186
+ session.pending_table(),
187
+ );
106
188
 
107
- let errored_recovery =
108
- session.is_errored() && matches!(&update.table, OptionalUpdate::Update(_));
109
-
110
- let reset = (table_changed || errored_recovery).then(|| {
111
- session.reset(ResetOptions {
112
- config: true,
113
- expressions: true,
114
- stats: true,
115
- ..ResetOptions::default()
116
- })
117
- });
189
+ let generation = match &update.table {
190
+ OptionalUpdate::Update(name)
191
+ if session.is_errored()
192
+ || (!fresh
193
+ && session
194
+ .get_table()
195
+ .map(|t| t.get_name() != name.as_str())
196
+ .unwrap_or(true)) =>
197
+ {
198
+ Some(session.begin_pending_load())
199
+ },
200
+ _ => None,
201
+ };
118
202
 
119
203
  let result = restore_and_render(
120
204
  session,
@@ -123,27 +207,42 @@ pub(crate) async fn restore_panel(
123
207
  RunOrigin::Public,
124
208
  update.clone(),
125
209
  {
126
- clone!(session, update.table, workspace);
210
+ clone!(session, renderer, update.table, workspace);
127
211
  async move {
128
- if let OptionalUpdate::Update(name) = table {
129
- if let Some(reset) = reset {
130
- reset.await?;
131
- }
212
+ let OptionalUpdate::Update(name) = table else {
213
+ return Ok(());
214
+ };
132
215
 
133
- bind_table_task(&session, &workspace, name).await?;
216
+ match generation {
217
+ Some(generation) => {
218
+ rebind_table_task(
219
+ &session, &renderer, &workspace, name, missing, generation,
220
+ )
221
+ .await
222
+ },
223
+ None => bind_table_task(&session, &workspace, name, missing).await,
134
224
  }
135
-
136
- Ok(())
137
225
  }
138
226
  },
139
227
  )
140
228
  .await;
141
229
 
230
+ if let Some(generation) = generation {
231
+ session.take_pending_load(generation);
232
+ }
233
+
142
234
  if let Err(e) = &result {
143
235
  match errors {
144
236
  RestoreErrors::Publish => session.set_error(false, e.clone()).await?,
145
237
  RestoreErrors::Suppress => {
146
- if let Some(config) = rollback {
238
+ let binding_after = (
239
+ session.get_table().map(|t| t.get_name().to_owned()),
240
+ session.pending_table(),
241
+ );
242
+
243
+ if let Some(config) = rollback
244
+ && binding_after == binding_before
245
+ {
147
246
  session.commit_view_config(config.into()).unwrap_or_log();
148
247
  }
149
248
  },
@@ -51,6 +51,9 @@ pub fn send_column_config(
51
51
  renderer
52
52
  .update_lazy(async move { Ok(session.get_view_with_dimensions()) })
53
53
  .await?;
54
+ renderer
55
+ .columns_config_changed
56
+ .emit(columns_configs.clone());
54
57
  renderer.column_style_changed.emit(columns_configs);
55
58
  Ok(())
56
59
  })