@perspective-dev/viewer 5.0.0 → 5.2.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 (213) hide show
  1. package/dist/cdn/perspective-viewer.js +9 -9
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/docs/perspective-docs.json +1 -0
  24. package/dist/esm/column-format.d.ts +19 -28
  25. package/dist/esm/perspective-viewer.d.ts +2 -0
  26. package/dist/esm/perspective-viewer.inline.js +8 -8
  27. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  28. package/dist/esm/perspective-viewer.js +9 -9
  29. package/dist/esm/perspective-viewer.js.map +4 -4
  30. package/dist/esm/providers.d.ts +79 -0
  31. package/dist/esm/ts-rs/CompactDisplay.d.ts +5 -0
  32. package/dist/esm/ts-rs/CurrencyCode.d.ts +1 -0
  33. package/dist/esm/ts-rs/CurrencyDisplay.d.ts +1 -0
  34. package/dist/esm/ts-rs/CurrencyNumberFormatStyle.d.ts +8 -0
  35. package/dist/esm/ts-rs/CurrencySign.d.ts +1 -0
  36. package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +1 -0
  37. package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +54 -0
  38. package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +34 -0
  39. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +1 -0
  40. package/dist/esm/ts-rs/DatetimeFormatType.d.ts +6 -0
  41. package/dist/esm/ts-rs/FormatMode.d.ts +1 -0
  42. package/dist/esm/ts-rs/FormatUnit.d.ts +1 -0
  43. package/dist/esm/ts-rs/Notation.d.ts +17 -0
  44. package/dist/esm/ts-rs/NumberFormatStyle.d.ts +18 -0
  45. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +43 -1
  46. package/dist/esm/ts-rs/PluginStaticConfig.d.ts +35 -0
  47. package/dist/esm/ts-rs/RestoreOptions.d.ts +19 -0
  48. package/dist/esm/ts-rs/RoundingMode.d.ts +1 -0
  49. package/dist/esm/ts-rs/RoundingPriority.d.ts +1 -0
  50. package/dist/esm/ts-rs/SignDisplay.d.ts +1 -0
  51. package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +1 -0
  52. package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +24 -0
  53. package/dist/esm/ts-rs/SplitRollupMode.d.ts +9 -0
  54. package/dist/esm/ts-rs/StringColorMode.d.ts +1 -0
  55. package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +1 -0
  56. package/dist/esm/ts-rs/Unit.d.ts +1 -0
  57. package/dist/esm/ts-rs/UnitDisplay.d.ts +1 -0
  58. package/dist/esm/ts-rs/UnitNumberFormatStyle.d.ts +6 -0
  59. package/dist/esm/ts-rs/UseGrouping.d.ts +1 -0
  60. package/dist/esm/ts-rs/ViewerConfig.d.ts +43 -1
  61. package/dist/esm/ts-rs/ViewerConfigInitial.d.ts +175 -0
  62. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +72 -0
  63. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  64. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  65. package/dist/esm/ts-rs/WindowSpec.d.ts +47 -0
  66. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  67. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +7 -6
  68. package/dist/wasm/perspective-viewer.d.ts +222 -26
  69. package/dist/wasm/perspective-viewer.js +378 -125
  70. package/dist/wasm/perspective-viewer.wasm +0 -0
  71. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -15
  72. package/package.json +2 -1
  73. package/src/css/_viewer_bundle.css +1 -0
  74. package/src/css/column-selector.css +9 -0
  75. package/src/css/column-settings-panel.css +67 -2
  76. package/src/css/column-style.css +4 -3
  77. package/src/css/config-selector.css +60 -4
  78. package/src/css/containers/context-menu.css +12 -6
  79. package/src/css/containers/tabs.css +2 -2
  80. package/src/css/form/code-editor.css +2 -3
  81. package/src/css/form/mirrored-textarea.css +81 -0
  82. package/src/css/viewer.css +336 -0
  83. package/src/rust/agent/client/mod.rs +19 -0
  84. package/src/rust/agent/client/protocol.rs +320 -0
  85. package/src/rust/agent/client/transport.rs +287 -0
  86. package/src/rust/agent/client/turn.rs +174 -0
  87. package/src/rust/agent/config.rs +159 -0
  88. package/src/rust/agent/docs.rs +293 -0
  89. package/src/rust/agent/markdown/block.rs +509 -0
  90. package/src/rust/agent/markdown/entity.rs +52 -0
  91. package/src/rust/agent/markdown/events.rs +71 -0
  92. package/src/rust/agent/markdown/inline.rs +497 -0
  93. package/src/rust/agent/markdown/mod.rs +466 -0
  94. package/src/rust/agent/mod.rs +23 -0
  95. package/src/rust/agent/preamble.md +16 -0
  96. package/src/rust/agent/runtime.rs +201 -0
  97. package/src/rust/agent/slot.rs +236 -0
  98. package/src/rust/agent/tools.rs +1110 -0
  99. package/src/rust/components/chat_panel.rs +289 -0
  100. package/src/rust/components/column_selector/active_column.rs +21 -27
  101. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  102. package/src/rust/components/column_selector/config_selector.rs +83 -8
  103. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  104. package/src/rust/components/column_selector/inactive_column.rs +8 -1
  105. package/src/rust/components/column_selector.rs +27 -3
  106. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  107. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  108. package/src/rust/components/column_settings_sidebar.rs +163 -16
  109. package/src/rust/components/containers/dragdrop_list.rs +110 -3
  110. package/src/rust/components/containers/sidebar.rs +99 -22
  111. package/src/rust/components/containers/split_panel.rs +6 -0
  112. package/src/rust/components/editable_header.rs +2 -0
  113. package/src/rust/components/form/code_editor.rs +14 -23
  114. package/src/rust/components/form/debug.rs +8 -5
  115. package/src/rust/components/form/mirrored_textarea.rs +165 -0
  116. package/src/rust/components/form/mod.rs +1 -0
  117. package/src/rust/components/main_panel.rs +5 -0
  118. package/src/rust/components/mod.rs +3 -0
  119. package/src/rust/components/panel_menu.rs +7 -0
  120. package/src/rust/components/panel_tab.rs +5 -0
  121. package/src/rust/components/portal.rs +7 -0
  122. package/src/rust/components/settings_panel.rs +80 -2
  123. package/src/rust/components/viewer/msg.rs +2 -0
  124. package/src/rust/components/viewer/panels.rs +9 -14
  125. package/src/rust/components/viewer/render.rs +14 -0
  126. package/src/rust/components/viewer/settings.rs +33 -1
  127. package/src/rust/components/viewer.rs +2 -0
  128. package/src/rust/components/window_editor.rs +985 -0
  129. package/src/rust/config/datetime_column_style/custom.rs +17 -0
  130. package/src/rust/config/datetime_column_style/simple.rs +10 -0
  131. package/src/rust/config/number_string_format.rs +33 -0
  132. package/src/rust/config/options.rs +18 -0
  133. package/src/rust/config/plugin_static_config.rs +120 -5
  134. package/src/rust/config/viewer_config.rs +220 -1
  135. package/src/rust/config/workspace_config.rs +7 -6
  136. package/src/rust/custom_elements/viewer.rs +209 -18
  137. package/src/rust/custom_events.rs +29 -0
  138. package/src/rust/lib.rs +17 -1
  139. package/src/rust/presentation/column_locator.rs +16 -1
  140. package/src/rust/presentation.rs +8 -0
  141. package/src/rust/queries/columns_iter_set.rs +7 -1
  142. package/src/rust/queries/get_viewer_config.rs +5 -1
  143. package/src/rust/renderer/dispatch.rs +85 -58
  144. package/src/rust/renderer.rs +34 -1
  145. package/src/rust/session/column_defaults_update.rs +18 -0
  146. package/src/rust/session/drag_drop_update.rs +18 -0
  147. package/src/rust/session/metadata.rs +64 -9
  148. package/src/rust/session/replace_expression_update.rs +2 -0
  149. package/src/rust/session.rs +7 -5
  150. package/src/rust/tasks/create_panel.rs +18 -8
  151. package/src/rust/tasks/edit_window.rs +155 -0
  152. package/src/rust/tasks/mod.rs +2 -0
  153. package/src/rust/tasks/restore_panel.rs +34 -17
  154. package/src/rust/tasks/sync_update_panels.rs +7 -23
  155. package/src/rust/utils/browser/dragdrop.rs +12 -0
  156. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  157. package/src/rust/utils/debounce.rs +76 -27
  158. package/src/rust/workspace.rs +112 -20
  159. package/src/svg/pin-icon.svg +5 -0
  160. package/src/themes/icons.css +1 -0
  161. package/src/themes/intl/de.css +23 -0
  162. package/src/themes/intl/es.css +23 -0
  163. package/src/themes/intl/fr.css +23 -0
  164. package/src/themes/intl/ja.css +23 -0
  165. package/src/themes/intl/pt.css +23 -0
  166. package/src/themes/intl/zh.css +23 -0
  167. package/src/themes/intl.css +26 -1
  168. package/src/ts/column-format.ts +67 -46
  169. package/src/ts/perspective-viewer.ts +2 -0
  170. package/src/ts/providers.ts +96 -0
  171. package/src/ts/ts-rs/CompactDisplay.ts +3 -0
  172. package/src/ts/ts-rs/CurrencyCode.ts +3 -0
  173. package/src/ts/ts-rs/CurrencyDisplay.ts +3 -0
  174. package/src/ts/ts-rs/CurrencyNumberFormatStyle.ts +6 -0
  175. package/src/ts/ts-rs/CurrencySign.ts +3 -0
  176. package/src/ts/ts-rs/CustomDatetimeFormat.ts +3 -0
  177. package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +54 -0
  178. package/src/ts/ts-rs/CustomNumberFormatConfig.ts +25 -0
  179. package/src/ts/ts-rs/DatetimeColorMode.ts +3 -0
  180. package/src/ts/ts-rs/DatetimeFormatType.ts +8 -0
  181. package/src/ts/ts-rs/FormatMode.ts +3 -0
  182. package/src/ts/ts-rs/FormatUnit.ts +3 -0
  183. package/src/ts/ts-rs/Notation.ts +11 -0
  184. package/src/ts/ts-rs/NumberFormatStyle.ts +12 -0
  185. package/src/ts/ts-rs/PanelViewerConfig.ts +48 -1
  186. package/src/ts/ts-rs/PluginStaticConfig.ts +36 -1
  187. package/src/ts/ts-rs/RestoreOptions.ts +20 -0
  188. package/src/ts/ts-rs/RoundingMode.ts +3 -0
  189. package/src/ts/ts-rs/RoundingPriority.ts +3 -0
  190. package/src/ts/ts-rs/SignDisplay.ts +3 -0
  191. package/src/ts/ts-rs/SimpleDatetimeFormat.ts +3 -0
  192. package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +25 -0
  193. package/src/ts/ts-rs/SplitRollupMode.ts +11 -0
  194. package/src/ts/ts-rs/StringColorMode.ts +3 -0
  195. package/src/ts/ts-rs/TrailingZeroDisplay.ts +3 -0
  196. package/src/ts/ts-rs/Unit.ts +3 -0
  197. package/src/ts/ts-rs/UnitDisplay.ts +3 -0
  198. package/src/ts/ts-rs/UnitNumberFormatStyle.ts +5 -0
  199. package/src/ts/ts-rs/UseGrouping.ts +3 -0
  200. package/src/ts/ts-rs/ViewerConfig.ts +48 -1
  201. package/src/ts/ts-rs/ViewerConfigInitial.ts +164 -0
  202. package/src/ts/ts-rs/ViewerConfigUpdate.ts +81 -2
  203. package/src/ts/ts-rs/WindowSort.ts +9 -0
  204. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  205. package/src/ts/ts-rs/WindowSpec.ts +47 -0
  206. package/src/ts/ts-rs/Windows.ts +10 -0
  207. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -6
  208. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline0.js +0 -0
  209. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline1.js +0 -0
  210. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline2.js +0 -0
  211. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline3.js +0 -0
  212. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline4.js +0 -0
  213. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline5.js +0 -0
@@ -10,15 +10,12 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- use futures::channel::oneshot::channel;
14
13
  use perspective_client::clone;
15
14
  use perspective_client::utils::PerspectiveResultExt;
16
15
 
17
- use crate::components::viewer::{PerspectiveViewer, PerspectiveViewerMsg};
18
16
  use crate::config::*;
19
17
  use crate::presentation::Presentation;
20
18
  use crate::renderer::Renderer;
21
- use crate::root::Root;
22
19
  use crate::session::{ResetOptions, Session};
23
20
  use crate::tasks::*;
24
21
  use crate::workspace::Workspace;
@@ -31,6 +28,20 @@ pub(crate) enum RestoreMode {
31
28
  Fresh,
32
29
  }
33
30
 
31
+ /// Where a failed restore's error goes. `Publish` (every user-facing path)
32
+ /// commits it to the session — the visible error UI and errored session
33
+ /// state. `Suppress` (the agent's `set_view_config` tool) returns it ONLY
34
+ /// to the caller: the model receives the error as a tool result and
35
+ /// self-corrects, so the transient failure is noise to the human user —
36
+ /// and the errored session state would otherwise BLOCK the corrected
37
+ /// retry, since error recovery below requires a `table` field the model's
38
+ /// patches don't carry.
39
+ #[derive(Clone, Copy)]
40
+ pub(crate) enum RestoreErrors {
41
+ Publish,
42
+ Suppress,
43
+ }
44
+
34
45
  pub(crate) async fn bind_table_task(
35
46
  session: &Session,
36
47
  workspace: &Workspace,
@@ -57,9 +68,9 @@ pub(crate) async fn restore_panel(
57
68
  renderer: &Renderer,
58
69
  presentation: &Presentation,
59
70
  workspace: &Workspace,
60
- root: Option<&Root<PerspectiveViewer>>,
61
71
  mode: RestoreMode,
62
72
  mut update: ViewerConfigUpdate,
73
+ errors: RestoreErrors,
63
74
  ) -> ApiResult<()> {
64
75
  let active = matches!(mode, RestoreMode::Existing { active: true });
65
76
  let fresh = matches!(mode, RestoreMode::Fresh);
@@ -77,17 +88,17 @@ pub(crate) async fn restore_panel(
77
88
  tracing::info!("Restoring {update}");
78
89
  }
79
90
 
80
- let (sender, receiver) = channel::<()>();
81
- match (active, root) {
82
- (true, Some(root)) => {
83
- root.borrow().as_ref().into_apierror()?.send_message(
84
- PerspectiveViewerMsg::ToggleSettingsComplete(update.settings.clone(), sender),
85
- );
86
- },
87
- _ => {
88
- let _ = sender.send(());
89
- },
90
- }
91
+ // NOTE: `update.settings` is deliberately NOT applied here. It is
92
+ // element-level chrome rather than panel state, so `restore()` — the
93
+ // only caller that can carry it — applies it before dispatching, and
94
+ // this pipeline stays per-panel. Applying it here reached only the
95
+ // `Existing { active: true }` mode, which is why a freshly created
96
+ // panel silently ignored it.
97
+
98
+ // Under `Suppress` the restore is TRANSACTIONAL, so snapshot the config
99
+ // it is about to overwrite — see the failure tail below for why.
100
+ let rollback =
101
+ matches!(errors, RestoreErrors::Suppress).then(|| session.get_view_config().clone());
91
102
 
92
103
  let table_changed = !fresh
93
104
  && matches!(&update.table, OptionalUpdate::Update(name)
@@ -122,7 +133,6 @@ pub(crate) async fn restore_panel(
122
133
  bind_table_task(&session, &workspace, name).await?;
123
134
  }
124
135
 
125
- receiver.await.unwrap_or_log();
126
136
  Ok(())
127
137
  }
128
138
  },
@@ -130,7 +140,14 @@ pub(crate) async fn restore_panel(
130
140
  .await;
131
141
 
132
142
  if let Err(e) = &result {
133
- session.set_error(false, e.clone()).await?;
143
+ match errors {
144
+ RestoreErrors::Publish => session.set_error(false, e.clone()).await?,
145
+ RestoreErrors::Suppress => {
146
+ if let Some(config) = rollback {
147
+ session.commit_view_config(config.into()).unwrap_or_log();
148
+ }
149
+ },
150
+ }
134
151
  }
135
152
 
136
153
  result?;
@@ -55,20 +55,19 @@ pub fn sync_update_panels(
55
55
  let mut fallback_fresh: Option<String> = None;
56
56
  let mut active_fresh: Option<String> = None;
57
57
  let mut contents = Vec::new();
58
+ // `panels` entries are `ViewerConfigInitial`s — `table` required by
59
+ // TYPE (a placed panel without a binding would be permanently blank),
60
+ // and no per-panel `settings` exists (element-level; carried by the
61
+ // top-level `active` field). An EMPTY `panels` map restores to the
62
+ // zero-panel empty stage — the former table-less fallback panel was
63
+ // exactly the blank-panel state this type exists to preclude.
58
64
  for (saved_id, config) in panels {
59
- if !matches!(config.settings, OptionalUpdate::Missing) {
60
- #[rustfmt::skip]
61
- tracing::warn!(
62
- "`settings` on panel \"{saved_id}\" is ignored in a whole-element config; use the top-level `active` field"
63
- );
64
- }
65
-
66
65
  let (fresh, session, renderer, config) = create_panel_model(
67
66
  &this.elem,
68
67
  &this.presentation,
69
68
  &this.workspace,
70
69
  None,
71
- config,
70
+ config.into(),
72
71
  None,
73
72
  Placement::Placed,
74
73
  );
@@ -82,21 +81,6 @@ pub fn sync_update_panels(
82
81
  contents.push((fresh, session, renderer, config));
83
82
  }
84
83
 
85
- if contents.is_empty() {
86
- let (fresh, session, renderer, config) = create_panel_model(
87
- &this.elem,
88
- &this.presentation,
89
- &this.workspace,
90
- None,
91
- ViewerConfigUpdate::default(),
92
- None,
93
- Placement::Placed,
94
- );
95
-
96
- fallback_fresh = Some(fresh.as_str().to_owned());
97
- contents.push((fresh, session, renderer, config));
98
- }
99
-
100
84
  let mut eject_tasks = Vec::new();
101
85
  if let Some(panel) = this.workspace.take_reserved() {
102
86
  eject_tasks.push(eject_panel(panel));
@@ -17,6 +17,18 @@ pub enum DragTarget {
17
17
  SplitBy,
18
18
  Sort,
19
19
  Filter,
20
+ WindowSource,
21
+ WindowOrderBy,
22
+ WindowPartitionBy,
23
+ }
24
+
25
+ impl DragTarget {
26
+ pub fn is_staged(&self) -> bool {
27
+ matches!(
28
+ self,
29
+ Self::WindowSource | Self::WindowOrderBy | Self::WindowPartitionBy
30
+ )
31
+ }
20
32
  }
21
33
 
22
34
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -86,6 +86,55 @@ pub async fn test_lock_seq() {
86
86
  assert_eq!(cell.get(), 10);
87
87
  }
88
88
 
89
+ /// A dedicated [`DebounceSlot`]'s task must RUN even when the default slot
90
+ /// has a parked runner — coalescing is scoped per slot. Pre-slot (one
91
+ /// shared parked flag), `t4` would resolve via `t2`'s settle without its
92
+ /// closure ever running: the resize-eaten-by-parked-update bug.
93
+ #[wasm_bindgen_test]
94
+ pub async fn test_slot_isolated_from_default_coalescing() {
95
+ let debounce_mutex = DebounceMutex::default();
96
+ let slot = debounce_mutex.slot();
97
+ let defaults: Rc<Cell<u32>> = Rc::new(Cell::new(0));
98
+ let slots: Rc<Cell<u32>> = Rc::new(Cell::new(0));
99
+
100
+ let t1 = debounce_mutex.lock(async {
101
+ set_timeout(10).await.unwrap();
102
+ });
103
+
104
+ let t2 = {
105
+ let defaults = defaults.clone();
106
+ debounce_mutex.debounce(async move {
107
+ defaults.set(defaults.get() + 1);
108
+ Ok(())
109
+ })
110
+ };
111
+
112
+ let t3 = {
113
+ let defaults = defaults.clone();
114
+ debounce_mutex.debounce(async move {
115
+ defaults.set(defaults.get() + 100);
116
+ Ok(())
117
+ })
118
+ };
119
+
120
+ let t4 = {
121
+ let slots = slots.clone();
122
+ slot.debounce_with(|_| async move {
123
+ slots.set(slots.get() + 1);
124
+ Ok(())
125
+ })
126
+ };
127
+
128
+ let (_, r2, r3, r4) = futures::join!(t1, t2, t3, t4);
129
+ r2.unwrap();
130
+ r3.unwrap();
131
+ r4.unwrap();
132
+
133
+ // `t3` coalesced onto the parked `t2`; `t4` ran its own closure.
134
+ assert_eq!(defaults.get(), 1);
135
+ assert_eq!(slots.get(), 1);
136
+ }
137
+
89
138
  #[wasm_bindgen_test]
90
139
  pub async fn test_debounce_seq() {
91
140
  let debounce_mutex = DebounceMutex::default();
@@ -33,16 +33,27 @@ pub struct RenderGuard {
33
33
  _private: (),
34
34
  }
35
35
 
36
+ /// The per-slot debounce bookkeeping: one running + one parked evaluation,
37
+ /// with coalesced callers awaiting the parked runner's settle. Kept
38
+ /// per-[`DebounceSlot`] so coalescing is only expressible WITHIN a kind —
39
+ /// a caller can never be absorbed by a parked runner of a different kind
40
+ /// whose work does not subsume it (the resize-eaten-by-parked-update bug).
36
41
  #[derive(Default)]
37
- struct DebounceMutexData {
42
+ struct SlotState {
38
43
  id: Cell<u64>,
39
- held: Cell<bool>,
40
- mutex: Mutex<u64>,
44
+ last: Cell<u64>,
41
45
  parked: Cell<bool>,
42
46
  settled_id: Cell<u64>,
43
47
  on_settle: PubSub<()>,
44
48
  }
45
49
 
50
+ #[derive(Default)]
51
+ struct DebounceMutexData {
52
+ held: Cell<bool>,
53
+ mutex: Mutex<()>,
54
+ default_slot: Rc<SlotState>,
55
+ }
56
+
46
57
  /// Clears the `held` flag on drop, so cancellation of a locked task (its
47
58
  /// future dropped mid-await) can't leave [`DebounceMutex::is_held`] stuck
48
59
  /// `true`.
@@ -67,25 +78,25 @@ impl Drop for HeldFlag<'_> {
67
78
  /// mid-run still frees the parked slot and releases its coalesced waiters,
68
79
  /// so they can never be stranded awaiting a settle that no task will emit.
69
80
  struct SettleGuard<'a> {
70
- data: &'a DebounceMutexData,
81
+ state: &'a SlotState,
71
82
  next: u64,
72
83
  }
73
84
 
74
85
  impl<'a> SettleGuard<'a> {
75
- fn park(data: &'a DebounceMutexData, next: u64) -> Self {
76
- data.parked.set(true);
77
- Self { data, next }
86
+ fn park(state: &'a SlotState, next: u64) -> Self {
87
+ state.parked.set(true);
88
+ Self { state, next }
78
89
  }
79
90
  }
80
91
 
81
92
  impl Drop for SettleGuard<'_> {
82
93
  fn drop(&mut self) {
83
- self.data.parked.set(false);
84
- if self.data.settled_id.get() < self.next {
85
- self.data.settled_id.set(self.next);
94
+ self.state.parked.set(false);
95
+ if self.state.settled_id.get() < self.next {
96
+ self.state.settled_id.set(self.next);
86
97
  }
87
98
 
88
- self.data.on_settle.emit(());
99
+ self.state.on_settle.emit(());
89
100
  }
90
101
  }
91
102
 
@@ -123,12 +134,11 @@ impl DebounceMutex {
123
134
  F: FnOnce(RenderGuard) -> Fut,
124
135
  Fut: Future<Output = T>,
125
136
  {
126
- let mut last = self.0.mutex.lock().await;
127
- let next = self.0.id.get();
137
+ let guard = self.0.mutex.lock().await;
128
138
  let held = HeldFlag::set(&self.0.held);
129
139
  let result = f(RenderGuard { _private: () }).await;
130
140
  drop(held);
131
- *last = next;
141
+ drop(guard);
132
142
  result
133
143
  }
134
144
 
@@ -144,23 +154,62 @@ impl DebounceMutex {
144
154
  F: FnOnce(RenderGuard) -> Fut,
145
155
  Fut: Future<Output = ApiResult<T>>,
146
156
  {
147
- let next = self.0.id.get() + 1;
148
- if self.0.parked.get() {
157
+ DebounceSlot {
158
+ mutex: self.clone(),
159
+ state: self.0.default_slot.clone(),
160
+ }
161
+ .debounce_with(f)
162
+ .await
163
+ }
164
+
165
+ /// A NEW debounce slot over this mutex: its tasks serialize against
166
+ /// every other locked task, but coalesce only among themselves.
167
+ pub fn slot(&self) -> DebounceSlot {
168
+ DebounceSlot {
169
+ mutex: self.clone(),
170
+ state: Default::default(),
171
+ }
172
+ }
173
+ }
174
+
175
+ /// One debounce KIND over a shared [`DebounceMutex`]. Execution is
176
+ /// exclusive across the whole mutex; coalescing (a caller resolving via a
177
+ /// parked runner instead of queueing) is scoped to the slot. A coalesced
178
+ /// caller's work must be subsumable by any same-slot runner that runs no
179
+ /// earlier than the call — which is why parameterized tasks sharing a slot
180
+ /// must read their parameters at RUN time (e.g. the [`Renderer`] geometry
181
+ /// command cell), never capture them at call time.
182
+ #[derive(Clone)]
183
+ pub struct DebounceSlot {
184
+ mutex: DebounceMutex,
185
+ state: Rc<SlotState>,
186
+ }
187
+
188
+ impl DebounceSlot {
189
+ pub async fn debounce_with<T, F, Fut>(&self, f: F) -> ApiResult<T>
190
+ where
191
+ T: Default,
192
+ F: FnOnce(RenderGuard) -> Fut,
193
+ Fut: Future<Output = ApiResult<T>>,
194
+ {
195
+ let state = &self.state;
196
+ let next = state.id.get() + 1;
197
+ if state.parked.get() {
149
198
  self.await_settled(next).await;
150
199
  return Ok(T::default());
151
200
  }
152
201
 
153
- let settle = SettleGuard::park(&self.0, next);
154
- let mut last = self.0.mutex.lock().await;
155
- self.0.parked.set(false);
156
- let result = if *last < next {
157
- let next = self.0.id.get() + 1;
158
- self.0.id.set(next);
159
- let held = HeldFlag::set(&self.0.held);
202
+ let settle = SettleGuard::park(state, next);
203
+ let guard = self.mutex.0.mutex.lock().await;
204
+ state.parked.set(false);
205
+ let result = if state.last.get() < next {
206
+ let next = state.id.get() + 1;
207
+ state.id.set(next);
208
+ let held = HeldFlag::set(&self.mutex.0.held);
160
209
  let result = f(RenderGuard { _private: () }).await;
161
210
  drop(held);
162
211
  if result.is_ok() {
163
- *last = next;
212
+ state.last.set(next);
164
213
  }
165
214
 
166
215
  result
@@ -168,14 +217,14 @@ impl DebounceMutex {
168
217
  Ok(T::default())
169
218
  };
170
219
 
171
- drop(last);
220
+ drop(guard);
172
221
  drop(settle);
173
222
  result
174
223
  }
175
224
 
176
225
  async fn await_settled(&self, next: u64) {
177
- while self.0.settled_id.get() < next {
178
- if self.0.on_settle.read_next().await.is_err() {
226
+ while self.state.settled_id.get() < next {
227
+ if self.state.on_settle.read_next().await.is_err() {
179
228
  break;
180
229
  }
181
230
  }
@@ -21,7 +21,7 @@ use perspective_client::config::Filter;
21
21
 
22
22
  use crate::renderer::Renderer;
23
23
  use crate::session::Session;
24
- use crate::utils::{EffectLedger, PubSub, Subscription};
24
+ use crate::utils::{EffectLedger, PubSub, Subscription, spawn_owned};
25
25
 
26
26
  /// A unique identifier for a [`Panel`] within a [`Workspace`].
27
27
  #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -243,6 +243,22 @@ struct WorkspaceData {
243
243
  /// In-flight effects (public mutators + scheduled internal flows),
244
244
  /// drained by `flush()` — see [`EffectLedger`].
245
245
  effects: EffectLedger,
246
+
247
+ /// The PLACED panel set changed since the last emit. Mutation sites set
248
+ /// this and emit NOTHING; the coalescing flush task owns delivery (see
249
+ /// [`Workspace::schedule_layout_flush`]).
250
+ layout_dirty: bool,
251
+ layout_changed: Rc<PubSub<Vec<PanelId>>>,
252
+
253
+ /// The active panel changed since the last emit. A separate channel from
254
+ /// `layout_dirty`: "which panel is selected" and "which panels exist" are
255
+ /// distinct facts, and one event may not mean both.
256
+ active_dirty: bool,
257
+ active_changed: Rc<PubSub<Option<PanelId>>>,
258
+
259
+ /// A flush task is already queued — the flag that makes N mutations
260
+ /// within one operation schedule ONE task rather than N.
261
+ flush_scheduled: bool,
246
262
  }
247
263
 
248
264
  impl Default for Workspace {
@@ -269,6 +285,11 @@ impl Workspace {
269
285
  staged_changed: Rc::new(PubSub::default()),
270
286
  reserved: None,
271
287
  effects: EffectLedger::default(),
288
+ layout_dirty: false,
289
+ layout_changed: Rc::new(PubSub::default()),
290
+ active_dirty: false,
291
+ active_changed: Rc::new(PubSub::default()),
292
+ flush_scheduled: false,
272
293
  })))
273
294
  }
274
295
 
@@ -277,6 +298,62 @@ impl Workspace {
277
298
  self.0.borrow().effects.clone()
278
299
  }
279
300
 
301
+ pub fn layout_changed(&self) -> Rc<PubSub<Vec<PanelId>>> {
302
+ self.0.borrow().layout_changed.clone()
303
+ }
304
+
305
+ pub fn active_changed(&self) -> Rc<PubSub<Option<PanelId>>> {
306
+ self.0.borrow().active_changed.clone()
307
+ }
308
+
309
+ /// Queue the coalescing layout-event flush, if anything is dirty and no
310
+ /// flush is already pending.
311
+ fn schedule_layout_flush(&self) {
312
+ let schedule = {
313
+ let mut data = self.0.borrow_mut();
314
+ let dirty = data.layout_dirty || data.active_dirty;
315
+ let queued = data.flush_scheduled;
316
+ data.flush_scheduled |= dirty;
317
+ dirty && !queued
318
+ };
319
+
320
+ if !schedule {
321
+ return;
322
+ }
323
+
324
+ let effects = self.effects();
325
+ let this = self.clone();
326
+ spawn_owned("workspace_layout_flush", async move {
327
+ effects.settle().await;
328
+ this.flush_layout_events();
329
+ Ok(())
330
+ });
331
+ }
332
+
333
+ /// Emit whatever is dirty, outside any borrow.
334
+ fn flush_layout_events(&self) {
335
+ let (layout, active, panels, active_id, layout_pubsub, active_pubsub) = {
336
+ let mut data = self.0.borrow_mut();
337
+ data.flush_scheduled = false;
338
+ (
339
+ std::mem::take(&mut data.layout_dirty),
340
+ std::mem::take(&mut data.active_dirty),
341
+ data.panels.iter().map(|p| p.id.clone()).collect::<Vec<_>>(),
342
+ data.active.clone(),
343
+ data.layout_changed.clone(),
344
+ data.active_changed.clone(),
345
+ )
346
+ };
347
+
348
+ if layout {
349
+ layout_pubsub.emit(panels);
350
+ }
351
+
352
+ if active {
353
+ active_pubsub.emit(active_id);
354
+ }
355
+ }
356
+
280
357
  /// Stage a layout tree for `MainPanel` to apply at its next `rendered`
281
358
  /// pass (see `WorkspaceData::pending_layout`).
282
359
  pub fn set_pending_layout(&self, layout: crate::js::Layout) {
@@ -512,16 +589,22 @@ impl Workspace {
512
589
  /// Append a [`Panel`]. When the element had zero panels, the inserted panel
513
590
  /// becomes the active one (there is no other candidate).
514
591
  pub fn insert_panel(&self, panel: Panel) {
515
- let mut data = self.0.borrow_mut();
516
- if data.active.is_none() {
517
- data.active = Some(panel.id.clone());
592
+ {
593
+ let mut data = self.0.borrow_mut();
594
+ if data.active.is_none() {
595
+ data.active = Some(panel.id.clone());
596
+ data.active_dirty = true;
597
+ }
598
+
599
+ panel
600
+ .renderer
601
+ .set_active_flag(data.active.as_ref() == Some(&panel.id));
602
+ data.panels.push(panel);
603
+ data.layout_dirty = true;
604
+ Self::sync_solo_flags(&data);
518
605
  }
519
606
 
520
- panel
521
- .renderer
522
- .set_active_flag(data.active.as_ref() == Some(&panel.id));
523
- data.panels.push(panel);
524
- Self::sync_solo_flags(&data);
607
+ self.schedule_layout_flush();
525
608
  }
526
609
 
527
610
  /// Hold `panel` in the reservation slot (see [`WorkspaceData::reserved`]):
@@ -585,8 +668,10 @@ impl Workspace {
585
668
 
586
669
  if data.active.as_ref() == Some(id) {
587
670
  data.active = None;
671
+ data.active_dirty = true;
588
672
  }
589
673
 
674
+ data.layout_dirty |= removed.is_some();
590
675
  Self::sync_solo_flags(&data);
591
676
  (
592
677
  removed,
@@ -605,25 +690,32 @@ impl Workspace {
605
690
  staged_pubsub.emit(());
606
691
  }
607
692
 
693
+ self.schedule_layout_flush();
608
694
  removed
609
695
  }
610
696
 
611
697
  /// Set the active panel. Returns `false` (no-op) if `id` is not a known
612
698
  /// panel.
613
699
  pub fn set_active(&self, id: PanelId) -> bool {
614
- let mut data = self.0.borrow_mut();
615
- if data.panels.iter().any(|p| p.id == id) {
616
- data.active = Some(id);
617
- for panel in data.panels.iter() {
618
- panel
619
- .renderer
620
- .set_active_flag(data.active.as_ref() == Some(&panel.id));
700
+ let known = {
701
+ let mut data = self.0.borrow_mut();
702
+ if data.panels.iter().any(|p| p.id == id) {
703
+ data.active_dirty |= data.active.as_ref() != Some(&id);
704
+ data.active = Some(id);
705
+ for panel in data.panels.iter() {
706
+ panel
707
+ .renderer
708
+ .set_active_flag(data.active.as_ref() == Some(&panel.id));
709
+ }
710
+
711
+ true
712
+ } else {
713
+ false
621
714
  }
715
+ };
622
716
 
623
- true
624
- } else {
625
- false
626
- }
717
+ self.schedule_layout_flush();
718
+ known
627
719
  }
628
720
 
629
721
  /// The default [`Client`], if one has been loaded.
@@ -0,0 +1,5 @@
1
+ <svg width="14" height="14" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path
3
+ d="M8 2h6v2l-1 1v4l3 2v2h-4v6l-1 2-1-2v-6H6v-2l3-2V5L8 4z"
4
+ stroke="#042121" />
5
+ </svg>
@@ -52,6 +52,7 @@ perspective-viewer-tab {
52
52
  --psp-icon--sort-none--mask-image: url("../svg/sort-none-icon.svg");
53
53
  --psp-icon--add-expression--mask-image: url("../svg/expression.svg");
54
54
  --psp-icon--close--mask-image: url("../svg/close-icon.svg");
55
+ --psp-icon--pin--mask-image: url("../svg/pin-icon.svg");
55
56
  --psp-label--inactive-column-selector--content: url("../svg/checkbox-unchecked-icon.svg");
56
57
  --psp-label--active-column-selector--content: url("../svg/checkbox-checked-icon.svg");
57
58
  --psp-icon--select-arrow-light--mask-image: url("../svg/dropdown-selector-light.svg");
@@ -108,9 +108,32 @@ perspective-dropdown {
108
108
  /* Tabs */
109
109
  --psp-label--style-tab--content: "Stil";
110
110
  --psp-label--attributes-tab--content: "Attribute";
111
+ --psp-label--window-tab--content: "Fenster";
112
+ --psp-label--window-source--content: "Spalte";
113
+ --psp-label--window-order-by--content: "Sortieren nach";
114
+ --psp-label--window-partition-by--content: "Partitionieren nach";
115
+ --psp-label--window-frame--content: "Rahmen";
116
+ --psp-label--window-offset--content: "Versatz";
117
+ --psp-label--window-alpha--content: "Alpha";
111
118
  --psp-label--query-tab--content: "Abfrage";
112
119
  --psp-label--plugin-tab--content: "Plugin";
113
120
  --psp-label--debug-tab--content: "JSON";
121
+ --psp-label--chat-tab--content: "Chat";
122
+
123
+ /* Chat panel */
124
+ --psp-label--chat-reasoning--content: "Denkprozess";
125
+ --psp-label--chat-send--content: "Senden";
126
+ --psp-label--chat-stop--content: "Stopp";
127
+ --psp-label--chat-placeholder--content: "Frage zu Ihren Daten…";
128
+
129
+ /* Plugin-declared pivot roles (PluginStaticConfig) */
130
+ --psp-label--role--x-axis--content: "X-Achse";
131
+ --psp-label--role--y-axis--content: "Y-Achse";
132
+ --psp-label--role--series--content: "Serien";
133
+ --psp-label--role--hierarchy--content: "Hierarchie";
134
+ --psp-label--role--facets--content: "Facetten";
135
+ --psp-label--role--row-groups--content: "Zeilengruppen";
136
+ --psp-label--role--column-groups--content: "Spaltengruppen";
114
137
 
115
138
  /* Plugin settings */
116
139
  --psp-label--edit-mode--content: "Bearbeitungsmodus";
@@ -108,9 +108,32 @@ perspective-dropdown {
108
108
  /* Tabs */
109
109
  --psp-label--style-tab--content: "Estilo";
110
110
  --psp-label--attributes-tab--content: "Atributos";
111
+ --psp-label--window-tab--content: "Ventana";
112
+ --psp-label--window-source--content: "Columna";
113
+ --psp-label--window-order-by--content: "Ordenar por";
114
+ --psp-label--window-partition-by--content: "Particionar por";
115
+ --psp-label--window-frame--content: "Marco";
116
+ --psp-label--window-offset--content: "Desplazamiento";
117
+ --psp-label--window-alpha--content: "Alfa";
111
118
  --psp-label--query-tab--content: "Consulta";
112
119
  --psp-label--plugin-tab--content: "Plugin";
113
120
  --psp-label--debug-tab--content: "JSON";
121
+ --psp-label--chat-tab--content: "Chat";
122
+
123
+ /* Chat panel */
124
+ --psp-label--chat-reasoning--content: "Razonamiento";
125
+ --psp-label--chat-send--content: "Enviar";
126
+ --psp-label--chat-stop--content: "Detener";
127
+ --psp-label--chat-placeholder--content: "Pregunta sobre tus datos…";
128
+
129
+ /* Plugin-declared pivot roles (PluginStaticConfig) */
130
+ --psp-label--role--x-axis--content: "Eje X";
131
+ --psp-label--role--y-axis--content: "Eje Y";
132
+ --psp-label--role--series--content: "Series";
133
+ --psp-label--role--hierarchy--content: "Jerarquía";
134
+ --psp-label--role--facets--content: "Facetas";
135
+ --psp-label--role--row-groups--content: "Grupos de filas";
136
+ --psp-label--role--column-groups--content: "Grupos de columnas";
114
137
 
115
138
  /* Plugin settings */
116
139
  --psp-label--edit-mode--content: "Modo de edición";