@perspective-dev/viewer 5.3.1 → 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 (291) hide show
  1. package/dist/cdn/perspective-viewer.js +6 -6
  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 -0
  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/extensions.d.ts +0 -5
  31. package/dist/esm/perspective-viewer.d.ts +2 -0
  32. package/dist/esm/perspective-viewer.inline.js +8 -8
  33. package/dist/esm/perspective-viewer.inline.js.map +2 -2
  34. package/dist/esm/perspective-viewer.js +5 -5
  35. package/dist/esm/perspective-viewer.js.map +2 -2
  36. package/dist/esm/plugin.d.ts +9 -0
  37. package/dist/esm/ts-rs/AddPanelOptions.d.ts +9 -0
  38. package/dist/esm/ts-rs/GetViewMode.d.ts +4 -0
  39. package/dist/esm/ts-rs/GetViewOptions.d.ts +8 -0
  40. package/dist/esm/ts-rs/RestoreOptions.d.ts +6 -0
  41. package/dist/esm/ts-rs/RestoreWorkspaceOptions.d.ts +9 -0
  42. package/dist/esm/ts-rs/ViewOnUpdateResp.d.ts +1 -1
  43. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +30 -22
  44. package/dist/wasm/perspective-viewer.d.ts +137 -104
  45. package/dist/wasm/perspective-viewer.js +337 -269
  46. package/dist/wasm/perspective-viewer.wasm +0 -0
  47. package/dist/wasm/perspective-viewer.wasm.d.ts +20 -30
  48. package/package.json +7 -7
  49. package/src/css/_context_menu.css +1 -1
  50. package/src/css/_dropdown_menu.css +1 -1
  51. package/src/css/_viewer_bundle.css +7 -8
  52. package/src/css/column-dropdown.css +0 -3
  53. package/src/css/column-selector.css +18 -7
  54. package/src/css/column-settings-panel.css +0 -225
  55. package/src/css/column-style.css +175 -0
  56. package/src/css/config-selector.css +19 -11
  57. package/src/css/filter-dropdown.css +5 -5
  58. package/src/css/form/debug.css +37 -4
  59. package/src/css/function-dropdown.css +5 -5
  60. package/src/css/panel-tab.css +23 -0
  61. package/src/css/plugin-selector.css +5 -5
  62. package/src/css/render-warning.css +1 -1
  63. package/src/css/status-bar.css +63 -21
  64. package/src/css/type-icon.css +1 -3
  65. package/src/css/{containers → ui/containers}/context-menu.css +22 -16
  66. package/src/css/ui/containers/dropdown-menu-items.css +66 -0
  67. package/src/css/{containers → ui/containers}/dropdown-menu.css +8 -53
  68. package/src/css/{containers → ui/containers}/tabs.css +2 -1
  69. package/src/css/viewer.css +32 -26
  70. package/src/rust/agent/tools.rs +80 -7
  71. package/src/rust/components/chat_panel.rs +1 -1
  72. package/src/rust/components/{form/code_editor.rs → code_editor.rs} +4 -2
  73. package/src/rust/components/column_dropdown.rs +2 -2
  74. package/src/rust/components/column_selector/active_column.rs +24 -9
  75. package/src/rust/components/column_selector/aggregate_selector.rs +1 -1
  76. package/src/rust/components/column_selector/config_selector.rs +2 -2
  77. package/src/rust/components/column_selector/filter_column.rs +8 -5
  78. package/src/rust/components/column_selector/inactive_column.rs +4 -2
  79. package/src/rust/components/column_selector/pivot_column.rs +4 -3
  80. package/src/rust/components/column_selector/sort_column.rs +7 -4
  81. package/src/rust/components/column_selector.rs +14 -3
  82. package/src/rust/components/column_settings_sidebar/style_tab/agg_depth_selector.rs +1 -1
  83. package/src/rust/components/column_settings_sidebar/style_tab/alignment_field.rs +90 -0
  84. package/src/rust/components/column_settings_sidebar/style_tab/font_field.rs +267 -0
  85. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +16 -18
  86. package/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_selector.rs +1 -1
  87. package/src/rust/components/column_settings_sidebar/style_tab.rs +70 -12
  88. package/src/rust/components/column_settings_sidebar.rs +3 -4
  89. package/src/rust/components/copy_dropdown.rs +1 -1
  90. package/src/rust/components/datetime_column_style/custom.rs +1 -3
  91. package/src/rust/components/datetime_column_style/simple.rs +1 -2
  92. package/src/rust/components/datetime_column_style.rs +1 -2
  93. package/src/rust/components/{form/debug.rs → debug_panel.rs} +112 -55
  94. package/src/rust/components/{containers/dragdrop_list.rs → dragdrop_list.rs} +2 -10
  95. package/src/rust/components/export_dropdown.rs +1 -1
  96. package/src/rust/components/expression_editor.rs +1 -1
  97. package/src/rust/components/filter_dropdown.rs +2 -2
  98. package/src/rust/components/function_dropdown.rs +2 -2
  99. package/src/rust/components/main_panel/frame_theme.rs +3 -59
  100. package/src/rust/components/main_panel/msg.rs +4 -0
  101. package/src/rust/components/main_panel/reconcile.rs +9 -4
  102. package/src/rust/components/main_panel/render.rs +62 -11
  103. package/src/rust/components/main_panel/update.rs +39 -10
  104. package/src/rust/components/main_panel.rs +40 -2
  105. package/src/rust/components/mod.rs +5 -8
  106. package/src/rust/components/new_panel_menu.rs +97 -0
  107. package/src/rust/components/number_series_style.rs +2 -3
  108. package/src/rust/components/panel_menu.rs +61 -87
  109. package/src/rust/components/panel_tab.rs +100 -43
  110. package/src/rust/components/plugin_tab.rs +58 -4
  111. package/src/rust/components/render_warning.rs +2 -2
  112. package/src/rust/components/{status_bar_counter.rs → rows_counter.rs} +10 -10
  113. package/src/rust/components/settings_panel.rs +8 -2
  114. package/src/rust/components/status_bar.rs +113 -19
  115. package/src/rust/components/status_indicator.rs +9 -14
  116. package/src/rust/components/{style/mod.rs → style.rs} +32 -15
  117. package/src/rust/components/style_controls/number_string_format/digits_section.rs +3 -5
  118. package/src/rust/components/style_controls/number_string_format/misc_section.rs +1 -1
  119. package/src/rust/components/style_controls/number_string_format/style_section.rs +1 -1
  120. package/src/rust/components/viewer/msg.rs +43 -30
  121. package/src/rust/components/viewer/panels.rs +53 -19
  122. package/src/rust/components/viewer/render.rs +16 -13
  123. package/src/rust/components/viewer/settings.rs +137 -68
  124. package/src/rust/components/viewer/snapshots.rs +5 -10
  125. package/src/rust/components/viewer/wiring.rs +11 -22
  126. package/src/rust/components/viewer.rs +15 -10
  127. package/src/rust/components/window_editor.rs +8 -9
  128. package/src/rust/config/column_config_schema.rs +312 -33
  129. package/src/rust/config/datetime_column_style.rs +0 -11
  130. package/src/rust/config/mod.rs +0 -4
  131. package/src/rust/config/options.rs +50 -0
  132. package/src/rust/config/workspace_config.rs +70 -24
  133. package/src/rust/custom_elements/debug_plugin.rs +1 -10
  134. package/src/rust/custom_elements/mod.rs +0 -3
  135. package/src/rust/custom_elements/viewer.rs +194 -115
  136. package/src/rust/js/plugin.rs +3 -87
  137. package/src/rust/lib.rs +19 -18
  138. package/src/rust/presentation/column_locator.rs +3 -7
  139. package/src/rust/presentation.rs +68 -38
  140. package/src/rust/queries/hosted_tables.rs +38 -0
  141. package/src/rust/queries/mod.rs +2 -0
  142. package/src/rust/queries/palette_set.rs +1 -1
  143. package/src/rust/queries/plugin_column_styles.rs +6 -5
  144. package/src/rust/renderer/activate.rs +1 -3
  145. package/src/rust/renderer/dispatch.rs +83 -22
  146. package/src/rust/renderer/limits.rs +24 -5
  147. package/src/rust/renderer/plugin_config.rs +201 -41
  148. package/src/rust/renderer/plugin_store.rs +2 -1
  149. package/src/rust/renderer/props.rs +7 -1
  150. package/src/rust/renderer.rs +23 -27
  151. package/src/rust/session/props.rs +8 -3
  152. package/src/rust/session/view_subscription.rs +19 -6
  153. package/src/rust/session.rs +189 -150
  154. package/src/rust/tasks/create_panel.rs +5 -2
  155. package/src/rust/tasks/dismiss_render_warning.rs +1 -1
  156. package/src/rust/tasks/eject.rs +1 -0
  157. package/src/rust/tasks/pipeline.rs +18 -67
  158. package/src/rust/tasks/presize_panels.rs +24 -1
  159. package/src/rust/tasks/restore_and_render.rs +0 -17
  160. package/src/rust/tasks/restore_panel.rs +129 -30
  161. package/src/rust/tasks/send_column_config.rs +4 -1
  162. package/src/rust/tasks/send_plugin_config.rs +1 -1
  163. package/src/rust/tasks/set_edit_mode.rs +1 -1
  164. package/src/rust/tasks/sync_update_panels.rs +147 -67
  165. package/src/rust/tasks/table_lifecycle.rs +7 -1
  166. package/src/rust/{components → ui}/containers/control_group.rs +25 -3
  167. package/src/rust/{components → ui}/containers/dropdown_menu.rs +5 -2
  168. package/src/rust/{components → ui}/containers/mod.rs +0 -4
  169. package/src/rust/{components → ui}/containers/scroll_panel.rs +0 -22
  170. package/src/rust/{components → ui}/containers/scroll_panel_item.rs +0 -13
  171. package/src/rust/{components → ui}/containers/select.rs +0 -6
  172. package/src/rust/{components → ui}/containers/sidebar.rs +5 -32
  173. package/src/rust/{components → ui}/containers/split_panel.rs +8 -80
  174. package/src/rust/{components → ui}/containers/tab_list.rs +15 -9
  175. package/src/rust/{components → ui}/context_menu.rs +8 -13
  176. package/src/rust/{presentation/drag_helpers.rs → ui/dragdrop.rs} +26 -31
  177. package/src/rust/{components → ui}/font_loader.rs +0 -26
  178. package/src/rust/{components → ui}/form/color_selector.rs +4 -2
  179. package/src/rust/{config/datetime_column_style/color_mode.rs → ui/form/intl_label.rs} +42 -42
  180. package/src/rust/{components → ui}/form/mirrored_textarea.rs +0 -53
  181. package/src/rust/{components → ui}/form/mod.rs +1 -10
  182. package/src/rust/{components → ui}/form/multi_stop_gradient_selector.rs +4 -5
  183. package/src/rust/{components → ui}/form/named_value_picker.rs +1 -6
  184. package/src/rust/{components → ui}/form/number_field.rs +1 -1
  185. package/src/rust/{components → ui}/form/number_range_field.rs +1 -1
  186. package/src/rust/{components → ui}/form/optional_field.rs +3 -1
  187. package/src/rust/{components → ui}/form/palette_selector.rs +4 -5
  188. package/src/rust/{components → ui}/form/select_enum_field.rs +2 -2
  189. package/src/rust/{components → ui}/form/select_value_field.rs +2 -2
  190. package/src/rust/ui/mod.rs +56 -0
  191. package/src/rust/ui/modal.rs +61 -0
  192. package/src/rust/{components → ui}/portal.rs +175 -79
  193. package/src/rust/{components/style/style_provider.rs → ui/style.rs} +4 -34
  194. package/src/rust/utils/completion.rs +0 -6
  195. package/src/rust/{config/css_scale.rs → utils/css.rs} +38 -35
  196. package/src/rust/utils/css_vars.rs +13 -0
  197. package/src/rust/utils/local_fonts.rs +395 -0
  198. package/src/rust/utils/mod.rs +4 -0
  199. package/src/rust/workspace.rs +47 -0
  200. package/src/svg/boolean-type.svg +3 -2
  201. package/src/svg/checkbox-checked-icon.svg +4 -7
  202. package/src/svg/checkbox-hover.svg +3 -3
  203. package/src/svg/checkbox-off.svg +2 -3
  204. package/src/svg/checkbox-on.svg +3 -3
  205. package/src/svg/checkbox-unchecked-icon.svg +3 -3
  206. package/src/svg/datagrid-select-column.svg +8 -15
  207. package/src/svg/datagrid-select-region.svg +8 -15
  208. package/src/svg/datagrid-select-row-tree.svg +7 -12
  209. package/src/svg/datagrid-select-row.svg +8 -13
  210. package/src/svg/date-type.svg +4 -23
  211. package/src/svg/drawer-tab-hover.svg +4 -6
  212. package/src/svg/drawer-tab-invert-hover.svg +5 -5
  213. package/src/svg/drawer-tab-invert.svg +3 -5
  214. package/src/svg/drawer-tab.svg +5 -5
  215. package/src/svg/mega-menu-icons-candlestick.svg +5 -37
  216. package/src/svg/mega-menu-icons-ohlc.svg +4 -39
  217. package/src/svg/mega-menu-icons-sunburst.svg +4 -38
  218. package/src/svg/mega-menu-icons-treemap.svg +3 -43
  219. package/src/svg/mega-menu-icons-y-scatter.svg +12 -72
  220. package/src/svg/new-panel-icon.svg +4 -0
  221. package/src/svg/number-type.svg +3 -3
  222. package/src/svg/radio-hover.svg +3 -3
  223. package/src/svg/radio-off.svg +3 -3
  224. package/src/svg/radio-on.svg +3 -3
  225. package/src/svg/status_ok.svg +3 -26
  226. package/src/svg/status_pending.svg +3 -0
  227. package/src/svg/string-type.svg +4 -8
  228. package/src/svg/theme-icon.svg +5 -5
  229. package/src/themes/blueprint.css +0 -57
  230. package/src/themes/botanical.css +0 -36
  231. package/src/themes/defaults.css +1 -24
  232. package/src/themes/dracula.css +0 -58
  233. package/src/themes/eggplant.css +0 -58
  234. package/src/themes/gruvbox-dark.css +0 -65
  235. package/src/themes/gruvbox.css +1 -39
  236. package/src/themes/icons.css +2 -8
  237. package/src/themes/intl/ar.css +198 -0
  238. package/src/themes/intl/de.css +63 -40
  239. package/src/themes/intl/es.css +58 -35
  240. package/src/themes/intl/fr.css +57 -34
  241. package/src/themes/intl/ja.css +59 -36
  242. package/src/themes/intl/pt.css +54 -31
  243. package/src/themes/intl/zh.css +65 -42
  244. package/src/themes/intl.css +34 -8
  245. package/src/themes/ledger.css +1 -59
  246. package/src/themes/monokai.css +0 -51
  247. package/src/themes/nord.css +0 -55
  248. package/src/themes/phosphor.css +0 -48
  249. package/src/themes/pro-dark.css +0 -37
  250. package/src/themes/pro.css +3 -23
  251. package/src/themes/solarized-dark.css +0 -31
  252. package/src/themes/solarized.css +1 -24
  253. package/src/themes/vaporwave.css +0 -44
  254. package/src/themes/velvet.css +0 -60
  255. package/src/ts/extensions.ts +0 -13
  256. package/src/ts/perspective-viewer.ts +2 -0
  257. package/src/ts/plugin.ts +9 -0
  258. package/src/ts/ts-rs/AddPanelOptions.ts +10 -0
  259. package/src/ts/ts-rs/{DatetimeColorMode.ts → GetViewMode.ts} +4 -1
  260. package/src/ts/ts-rs/GetViewOptions.ts +7 -0
  261. package/src/ts/ts-rs/RestoreOptions.ts +7 -1
  262. package/src/ts/ts-rs/RestoreWorkspaceOptions.ts +10 -0
  263. package/src/ts/ts-rs/ViewOnUpdateResp.ts +1 -1
  264. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +31 -20
  265. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +0 -1
  266. package/dist/esm/ts-rs/FormatMode.d.ts +0 -1
  267. package/dist/esm/ts-rs/StringColorMode.d.ts +0 -1
  268. package/src/css/plugin-settings-panel.css +0 -171
  269. package/src/rust/components/modal.rs +0 -177
  270. package/src/rust/components/string_column_style.rs +0 -121
  271. package/src/rust/config/string_column_style.rs +0 -135
  272. package/src/rust/custom_elements/copy_dropdown.rs +0 -219
  273. package/src/rust/custom_elements/export_dropdown.rs +0 -216
  274. package/src/rust/custom_elements/modal.rs +0 -299
  275. package/src/ts/ts-rs/FormatMode.ts +0 -3
  276. package/src/ts/ts-rs/StringColorMode.ts +0 -3
  277. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-ea71a3990cad35d7}/inline0.js +0 -0
  278. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-ea71a3990cad35d7}/inline1.js +0 -0
  279. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-ea71a3990cad35d7}/inline2.js +0 -0
  280. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-ea71a3990cad35d7}/inline3.js +0 -0
  281. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-ea71a3990cad35d7}/inline4.js +0 -0
  282. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-ea71a3990cad35d7}/inline5.js +0 -0
  283. /package/src/css/{containers → ui/containers}/scroll-panel.css +0 -0
  284. /package/src/css/{containers → ui/containers}/split-panel.css +0 -0
  285. /package/src/css/{dom → ui/dom}/checkbox.css +0 -0
  286. /package/src/css/{dom → ui/dom}/scrollbar.css +0 -0
  287. /package/src/css/{dom → ui/dom}/select.css +0 -0
  288. /package/src/css/{form → ui/form}/mirrored-textarea.css +0 -0
  289. /package/src/rust/components/{form → code_editor}/highlight.rs +0 -0
  290. /package/src/rust/{components → ui}/containers/sidebar_close_button.rs +0 -0
  291. /package/src/rust/{components → ui}/form/number_input.rs +0 -0
@@ -24,6 +24,7 @@ use std::ops::Deref;
24
24
  use std::rc::Rc;
25
25
 
26
26
  use perspective_client::config::*;
27
+ use perspective_client::proto::ViewDimensionsResp;
27
28
  use perspective_client::{Client, ClientError, ReconnectCallback, View};
28
29
  use perspective_js::apierror;
29
30
  use perspective_js::utils::*;
@@ -56,11 +57,7 @@ pub struct SessionHandle {
56
57
  pub table_loaded: PubSub<()>,
57
58
  pub table_unloaded: PubSub<bool>,
58
59
 
59
- /// Fires when a `View` was CREATED — literally: `bind_view`'s REBUILD
60
- /// path constructed and bound a new engine `View`. Nothing else may
61
- /// emit this (a SKIP/REUSE/paused reconcile is [`Self::commit_reconciled`],
62
- /// which REBUILD also emits) — overloading it broke consumers that
63
- /// relied on the name's meaning (see `UPDATE_COUNT_REGRESSION_PLAN.md`).
60
+ /// Fires when a `View` was CREATED — literally.
64
61
  pub view_created: PubSub<()>,
65
62
 
66
63
  /// Fires exactly once per locked run that RECONCILED the committed
@@ -74,43 +71,23 @@ pub struct SessionHandle {
74
71
  pub view_config_changed: PubSub<()>,
75
72
  pub title_changed: PubSub<Option<String>>,
76
73
 
77
- /// Count of in-flight CONFIG-DRIVEN pipeline runs (commit → run
78
- /// settled), incremented by [`Session::begin_config_run`] and
79
- /// decremented when the returned [`ConfigRunToken`] drops. Drives the
80
- /// `StatusIndicator` "updating" spinner via [`Self::run_state_changed`].
81
- /// Pure redraws (resize, activation nudges, `just_render`) do NOT
82
- /// count — the spinner means "a config requery is in flight".
74
+ /// Count of in-flight CONFIG-DRIVEN pipeline runs.
83
75
  in_flight_config_runs: Cell<u32>,
84
76
 
85
77
  /// Fires with the ABSOLUTE [`Self::in_flight_config_runs`] count after
86
- /// every change. LEVEL-triggered by design: subscribers ASSIGN the
87
- /// payload rather than accumulate deltas, so a dropped or reordered
88
- /// notification is corrected by the next one — the delta-counting
89
- /// (`view_config_changed`+1 / `view_created`−1) design this replaces
90
- /// drifted whenever those streams weren't perfectly paired (see
91
- /// `UPDATE_COUNT_REGRESSION_PLAN.md`).
78
+ /// every change.
92
79
  pub run_state_changed: PubSub<u32>,
93
80
 
94
81
  /// Fires when the user clicks the status indicator while in
95
- /// [`StatusIconState::Normal`]. `wire_panel_events` is the only
96
- /// listener and fans this out as the `perspective-status-indicator-click`
97
- /// `CustomEvent`.
82
+ /// [`StatusIconState::Normal`].
98
83
  pub status_indicator_clicked: PubSub<()>,
99
84
 
100
- /// Per-column numeric stats cache. Populated by the
101
- /// `fetch_column_abs_max` task and consumed by the schema-query
102
- /// path so plugins emit gradient defaults without
103
- /// per-render `View::get_min_max` round trips. Cleared
104
- /// synchronously inside [`Session::commit_view_config`].
85
+ /// Per-column numeric stats cache.
105
86
  column_stats: RefCell<HashMap<String, ColumnStats>>,
106
87
 
107
88
  /// Memoized snapshots used by [`Session::to_props`] to keep
108
89
  /// `PtrEqRc` identity stable across repeated `to_props()` calls
109
- /// when the underlying value hasn't changed. Without this, every
110
- /// `PubSub` fire (including `column_stats_changed`) produces a
111
- /// fresh `Rc` for `config` / `metadata`, triggering downstream
112
- /// `use_effect_with(view_config, ...)` effects to spuriously
113
- /// refire — closing a loop with the stats fetch path.
90
+ /// when the underlying value hasn't changed.
114
91
  cached_config: RefCell<Option<PtrEqRc<ViewConfig>>>,
115
92
  cached_metadata: RefCell<Option<SessionMetadataRc>>,
116
93
 
@@ -131,18 +108,7 @@ pub struct SessionHandle {
131
108
  load_generation: Cell<u32>,
132
109
 
133
110
  /// Open between a `load()` call and its payload's classification as
134
- /// `Table`/`Client` ([`PendingLoad`]). While `Some` the payload type is
135
- /// unknown, so its RESET/no-RESET disposition cannot be decided yet — but
136
- /// the window's POSITION on the config-commit stream is fixed at the
137
- /// `load()` call site. During the window, config commits still apply live
138
- /// (so `save()` stays coherent — I1) but ALSO append their raw delta to
139
- /// the journal, and config-driven binds DEFER ([`Session::snapshot`]'s
140
- /// caller [`crate::tasks::bind_snapshot`]) — the incoming config must
141
- /// never draw against the still-bound outgoing table (hold the last
142
- /// frame). Classified as `Table` the journal replays over a reset base
143
- /// (`reset ∘ Δ₁ ∘ Δ₂ …`, the program-order result the old synchronous
144
- /// reset guaranteed); as `Client` it is dropped (no reset — live already
145
- /// carries every Δ). See `SESSION_CONFIG_COHERENCE_PLAN.md`.
111
+ /// `Table`/`Client` ([`PendingLoad`]).
146
112
  pending_load: RefCell<Option<PendingLoad>>,
147
113
 
148
114
  /// Coalesces `view_config_changed`: multiple synchronous commits in one
@@ -165,8 +131,11 @@ pub struct SessionHandle {
165
131
  /// new value.
166
132
  pub column_stats_changed: PubSub<()>,
167
133
 
168
- /// Injected callback from the root component, replacing the former
169
- /// `stats_changed: PubSub` field. Fires when view stats are updated.
134
+ /// Fires when view stats are updated.
135
+ pub stats_changed: PubSub<()>,
136
+
137
+ /// Injected callback from the root component, driving
138
+ /// `session_props.has_table_cells` for the active panel.
170
139
  pub on_stats_changed: RefCell<Option<Callback<()>>>,
171
140
 
172
141
  /// Injected callback from the root component, replacing the former
@@ -183,6 +152,51 @@ impl Deref for SessionHandle {
183
152
  }
184
153
  }
185
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
+
186
200
  /// Mutable state for `Session`.
187
201
  #[derive(Default)]
188
202
  pub struct SessionData {
@@ -279,13 +293,7 @@ pub struct ResetOptions {
279
293
  #[derive(Clone)]
280
294
  pub struct Session(Rc<SessionHandle>);
281
295
 
282
- /// RAII spinner accounting for one config-driven pipeline run (see
283
- /// [`Session::begin_config_run`] and `UPDATE_COUNT_REGRESSION_PLAN.md`):
284
- /// created when the run is scheduled — immediately after its commit — and
285
- /// moved INTO the run future, so `Drop` settles the count on every exit
286
- /// path: completion, error, cancellation, and runs that never reach
287
- /// `bind_view` (the deferred-draw restore). A stranded count is
288
- /// unrepresentable; there is no other writer.
296
+ /// RAII spinner accounting for one config-driven pipeline run.
289
297
  pub struct ConfigRunToken(Session);
290
298
 
291
299
  impl Drop for ConfigRunToken {
@@ -346,6 +354,7 @@ impl Session {
346
354
  /// `Session`.
347
355
  pub fn reset(&self, options: ResetOptions) -> impl Future<Output = ApiResult<()>> + use<> {
348
356
  let view = self.0.borrow_mut().view_sub.take();
357
+ let had_table = self.borrow().table.is_some();
349
358
  let err = self.get_error();
350
359
  self.borrow_mut().error = None;
351
360
  if options.stats {
@@ -365,11 +374,13 @@ impl Session {
365
374
  self.borrow_mut().is_loading = false;
366
375
  self.borrow_mut().table = None;
367
376
  self.borrow_mut().pending_table = None;
377
+ self.borrow_mut().metadata = SessionMetadata::default();
368
378
  },
369
379
  Some(TableIntermediateState::Reloaded) => {
370
380
  self.borrow_mut().is_loading = true;
371
381
  self.borrow_mut().table = None;
372
382
  self.borrow_mut().pending_table = None;
383
+ self.borrow_mut().metadata = SessionMetadata::default();
373
384
  },
374
385
  _ => {
375
386
  self.borrow_mut().is_loading = false;
@@ -390,7 +401,7 @@ impl Session {
390
401
  let session = self.clone();
391
402
  async move {
392
403
  let res = view.delete().await;
393
- if options.table.is_some() {
404
+ if options.table.is_some() && had_table {
394
405
  session.table_unloaded.emit(true)
395
406
  }
396
407
 
@@ -447,6 +458,8 @@ impl Session {
447
458
  Some(TableLoadState::Loaded)
448
459
  } else if data.is_loading {
449
460
  Some(TableLoadState::Loading)
461
+ } else if data.pending_table.is_some() {
462
+ Some(TableLoadState::Pending)
450
463
  } else {
451
464
  None
452
465
  }
@@ -507,10 +520,8 @@ impl Session {
507
520
  /// `table_name` is unique per `Client`, so if this value has not changed,
508
521
  /// `Session::set_table` does nothing and returns `Ok(false)`.
509
522
  ///
510
- /// A name NO loaded client hosts (yet) is not an error: the session
511
- /// records it PENDING ([`SessionData::pending_table`]) and returns
512
- /// `Ok(false)`; the element's hosted-tables subscription
513
- /// (`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.
514
525
  pub async fn set_table(&self, table_name: String) -> ApiResult<bool> {
515
526
  if Some(table_name.as_str()) == self.0.borrow().table.as_ref().map(|x| x.get_name()) {
516
527
  self.0.borrow_mut().pending_table = None;
@@ -518,60 +529,68 @@ impl Session {
518
529
  }
519
530
 
520
531
  let client = self.0.borrow().client.clone().into_apierror()?;
521
- let table = match client.open_table(table_name.clone()).await {
522
- Ok(table) => table,
523
- Err(e) => {
524
- // Distinguish "not hosted (yet)" — which PENDS — from a real
525
- // open failure by the hosted set, not the error's text.
526
- let hosted = client.get_hosted_table_names().await.unwrap_or_default();
527
- if hosted.iter().any(|n| n == &table_name) {
528
- return Err(e.into());
529
- }
530
-
531
- self.0.borrow_mut().pending_table = Some(table_name);
532
- 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),
533
536
  },
534
- };
537
+ None => {
538
+ self.pend_table(table_name).await?;
539
+ Ok(false)
540
+ },
541
+ }
542
+ }
535
543
 
536
- match SessionMetadata::from_table(&table).await {
537
- Ok(metadata) => {
538
- let client = table.get_client();
539
- let on_error = self.on_table_errored.borrow().clone();
540
- let session = self.clone();
541
- let poll_loop = LocalPollLoop::new(move |(message, reconnect): (ApiError, _)| {
542
- session.borrow_mut().error = Some(TableErrorState(message, reconnect));
543
- if let Some(cb) = &on_error {
544
- cb.emit(());
545
- }
546
- if let Some(sub) = session.borrow_mut().view_sub.take() {
547
- sub.dismiss();
548
- }
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
+ }
549
559
 
550
- Ok(JsValue::UNDEFINED)
551
- });
552
-
553
- let _callback_id = client
554
- .on_error(Box::new(move |message: ClientError, reconnect| {
555
- let poll_loop = poll_loop.clone();
556
- async move {
557
- poll_loop.poll((message.into(), reconnect)).await;
558
- Ok(())
559
- }
560
- }))
561
- .await?;
560
+ Ok(JsValue::UNDEFINED)
561
+ });
562
562
 
563
- let sub = self.borrow_mut().view_sub.take();
564
- self.borrow_mut().metadata = metadata;
565
- self.borrow_mut().table = Some(table);
566
- self.borrow_mut().pending_table = None;
567
- self.borrow_mut().is_loading = false;
568
- self.borrow_mut().last_validated_expressions = None;
569
- sub.delete().await?;
570
- self.table_loaded.emit(());
571
- Ok(true)
572
- },
573
- Err(err) => self.set_error(false, err).await.map(|_| false),
574
- }
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
575
594
  }
576
595
 
577
596
  pub async fn set_error(&self, reset_table: bool, err: ApiError) -> ApiResult<()> {
@@ -664,10 +683,27 @@ impl Session {
664
683
  .map(|sub| sub.get_view().clone())
665
684
  }
666
685
 
686
+ /// The bound `View` together with its latest known dimensions, read under
687
+ /// one borrow so they always belong together.
688
+ pub fn get_view_with_dimensions(&self) -> Option<(View, Option<ViewDimensionsResp>)> {
689
+ self.borrow()
690
+ .view_sub
691
+ .as_ref()
692
+ .map(|sub| (sub.get_view().clone(), sub.dimensions()))
693
+ }
694
+
667
695
  pub(crate) fn get_table_stats(&self) -> Option<ViewStats> {
668
696
  self.borrow().stats.clone()
669
697
  }
670
698
 
699
+ /// Whether the stats snapshot carries table dimensions.
700
+ pub(crate) fn has_table_cells(&self) -> bool {
701
+ self.borrow()
702
+ .stats
703
+ .as_ref()
704
+ .is_some_and(|s| s.num_table_cells.is_some())
705
+ }
706
+
671
707
  pub fn get_view_config(&'_ self) -> Ref<'_, ViewConfig> {
672
708
  Ref::map(self.borrow(), |x| &x.config)
673
709
  }
@@ -924,15 +960,6 @@ impl Session {
924
960
  /// Emit `view_config_changed` coalesced to one event per microtask batch.
925
961
  /// Stats are cleared synchronously per commit (idempotent — an
926
962
  /// already-empty cache no-ops, so rapid commit sequences fetch once).
927
- ///
928
- /// CONTRACT: this channel is COALESCED and DEFERRED — N same-batch
929
- /// commits produce ONE emission, on a microtask AFTER the mutation.
930
- /// Subscribers must be batch-tolerant snapshot refreshers (they re-read
931
- /// live state; today: the root `SessionProps` snapshot and the debug
932
- /// panel). It is NOT suitable for per-commit accounting — pairing its
933
- /// edges against any other event stream WILL drift (the stuck-spinner
934
- /// regression, `UPDATE_COUNT_REGRESSION_PLAN.md`); run accounting is
935
- /// [`Self::begin_config_run`]'s RAII token instead.
936
963
  fn notify_view_config_changed(&self) {
937
964
  self.clear_column_stats();
938
965
  if !self.0.config_event_scheduled.replace(true) {
@@ -1123,31 +1150,7 @@ impl Session {
1123
1150
  .clone()
1124
1151
  .ok_or("`restore()` called before `load()`")?;
1125
1152
 
1126
- // Populate the aggregates with defaults as a courtesy to the
1127
- // virtual server api. Snapshot-local — never written back.
1128
- let mut view_config = (*effective).clone();
1129
- for col in view_config
1130
- .columns
1131
- .iter()
1132
- .flatten()
1133
- .chain(view_config.sort.iter().map(|x| &x.0))
1134
- {
1135
- if !view_config.aggregates.contains_key(col.as_str()) {
1136
- let agg = self
1137
- .metadata()
1138
- .get_column_aggregates(col.as_str())
1139
- .and_then(|mut aggs| aggs.next())
1140
- .into_apierror();
1141
-
1142
- match agg {
1143
- Err(_) => {
1144
- tracing::warn!("No default aggregate for column '{}' found, skipping", col)
1145
- },
1146
- Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg),
1147
- };
1148
- }
1149
- }
1150
-
1153
+ let view_config = self.with_default_aggregates(&effective);
1151
1154
  let view = table.view(Some(view_config.into())).await?;
1152
1155
  let view_schema = view.schema().await?;
1153
1156
  self.metadata_mut().update_view_schema(&view_schema)?;
@@ -1179,6 +1182,43 @@ impl Session {
1179
1182
  }
1180
1183
  }
1181
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
+
1182
1222
  /// Record a failed pipeline run: error state plus a reconnect affordance
1183
1223
  /// that resets the config (the error screen's reset button). Replaces
1184
1224
  /// the old `validate()` error path — the committed config is NOT rolled
@@ -1218,6 +1258,8 @@ impl Session {
1218
1258
  if let Some(cb) = self.on_stats_changed.borrow().as_ref() {
1219
1259
  cb.emit(());
1220
1260
  }
1261
+
1262
+ self.stats_changed.emit(());
1221
1263
  }
1222
1264
 
1223
1265
  fn all_columns(&self) -> Vec<String> {
@@ -1258,11 +1300,16 @@ impl Session {
1258
1300
 
1259
1301
  SessionProps {
1260
1302
  config,
1261
- stats: data.stats.clone(),
1303
+ has_table_cells: data
1304
+ .stats
1305
+ .as_ref()
1306
+ .is_some_and(|s| s.num_table_cells.is_some()),
1262
1307
  has_table: if data.table.is_some() {
1263
1308
  Some(TableLoadState::Loaded)
1264
1309
  } else if data.is_loading {
1265
1310
  Some(TableLoadState::Loading)
1311
+ } else if data.pending_table.is_some() {
1312
+ Some(TableLoadState::Pending)
1266
1313
  } else {
1267
1314
  None
1268
1315
  },
@@ -1290,15 +1337,7 @@ pub struct ConfigSnapshot {
1290
1337
  pub struct ValidatedSnapshot(ConfigSnapshot);
1291
1338
 
1292
1339
  /// Type-state witness that a `View` is NEW for the plugin about to render
1293
- /// it — `plugin.draw()`'s contract (see `PLUGIN_DRAW_INVARIANT_PLAN.md`:
1294
- /// `draw` fires iff there is a new `View`, never otherwise). Minted in
1295
- /// exactly two places: [`Session::bind_view`]'s REBUILD arm (a new engine
1296
- /// `View` was constructed) and
1297
- /// [`crate::renderer::Renderer::promote_first_paint`] (a freshly-selected
1298
- /// plugin owes its first paint of the bound `View`). Do NOT construct it
1299
- /// anywhere else — `Renderer::draw_fresh` is the only consumer, and this
1300
- /// witness is what makes "full draw without a new `View`" (the stacked-chart
1301
- /// activation regression) uncompilable.
1340
+ /// it.
1302
1341
  pub struct FreshView(View);
1303
1342
 
1304
1343
  impl FreshView {
@@ -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};
@@ -60,7 +60,7 @@ fn wire_panel_render_sub(session: &Session, renderer: &Renderer) -> Subscription
60
60
  clone!(renderer, session);
61
61
  ApiFuture::spawn(async move {
62
62
  renderer
63
- .update_lazy(async move { Ok(session.get_view()) })
63
+ .update_lazy(async move { Ok(session.get_view_with_dimensions()) })
64
64
  .await
65
65
  .ignore_view_delete()
66
66
  .map(|_| ())
@@ -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
 
@@ -28,7 +28,7 @@ pub fn dismiss_render_warning_callback(session: &Session, renderer: &Renderer) -
28
28
  ApiFuture::spawn(async move {
29
29
  renderer.disable_active_plugin_render_warning();
30
30
  renderer
31
- .update_lazy(async move { Ok(session.get_view()) })
31
+ .update_lazy(async move { Ok(session.get_view_with_dimensions()) })
32
32
  .await
33
33
  });
34
34
  })
@@ -34,6 +34,7 @@ pub fn delete_all<T: Component>(workspace: &Workspace, root: &Root<T>) -> ApiFut
34
34
  let panels = workspace
35
35
  .take_reserved()
36
36
  .into_iter()
37
+ .chain(workspace.take_all_closing())
37
38
  .chain(
38
39
  workspace
39
40
  .panel_ids()