Skip to main content

perspective_viewer/components/
settings_panel.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use std::rc::Rc;
14
15use perspective_client::config::{ViewConfig, ViewConfigUpdate};
16use perspective_js::utils::ApiFuture;
17use yew::prelude::*;
18
19use super::column_selector::ColumnSelector;
20use super::plugin_selector::PluginSelector;
21use super::plugin_tab::PluginTab;
22use crate::components::containers::sidebar_close_button::SidebarCloseButton;
23use crate::components::form::debug::DebugPanel;
24use crate::config::{PluginStaticConfig, PluginUpdate};
25use crate::presentation::{ColumnLocator, OpenColumnSettings, Presentation};
26use crate::renderer::*;
27use crate::session::column_defaults_update::*;
28use crate::session::*;
29use crate::tasks::update_plugin_and_render;
30use crate::utils::*;
31use crate::workspace::Workspace;
32
33#[derive(Clone, Properties)]
34pub struct SettingsPanelProps {
35    pub on_close: Callback<()>,
36    pub on_resize: Rc<PubSub<()>>,
37    pub on_select_column: Callback<Option<ColumnLocator>>,
38    pub on_debug: Callback<()>,
39    pub is_debug: bool,
40
41    /// Value props threaded from the root's `RendererProps` / `SessionProps`.
42    pub plugin_name: Option<String>,
43    pub available_plugins: PtrEqRc<Vec<String>>,
44    pub has_table: Option<TableLoadState>,
45    pub named_column_count: usize,
46
47    /// The ACTIVE plugin's declared contract, threaded as a value prop so
48    /// that switching plugins re-renders the panes that read it. The
49    /// renderer handle cannot serve this: it is excluded from prop
50    /// equality (it is a handle, not a value), so a plugin swap that
51    /// leaves the view config untouched — Y Line back to Datagrid, both
52    /// of which name one column slot — would otherwise change nothing any
53    /// component compares.
54    pub plugin_static_config: Rc<PluginStaticConfig>,
55    pub view_config: PtrEqRc<ViewConfig>,
56
57    /// Snapshot of the active plugin's `plugin_config` bucket, threaded
58    /// from `RendererProps`. Forwarded into `PluginTab` so the tab is
59    /// prop-driven instead of reading `Renderer` directly.
60    pub plugin_config: PtrEqRc<serde_json::Map<String, serde_json::Value>>,
61
62    /// Column currently being dragged (if any) — threaded to show drag
63    /// highlights without per-component `DragDrop` PubSub subscriptions.
64    pub drag_column: Option<String>,
65
66    /// Cloned session metadata snapshot — threaded from `SessionProps`
67    /// so that metadata changes trigger re-renders via prop diffing.
68    pub metadata: SessionMetadataRc,
69
70    /// Snapshot of the column-settings sidebar state — threaded from
71    /// `PresentationProps` so that open/close triggers re-renders.
72    pub open_column_settings: OpenColumnSettings,
73
74    /// Selected theme name, threaded for PortalModal consumers.
75    pub selected_theme: Option<String>,
76
77    /// Controlled: the currently selected tab. Lifted to `PerspectiveViewer`
78    /// so that messages like `OpenColumnSettings` can revert the tab without
79    /// the panel owning the state.
80    pub selected_tab: SelectedTab,
81
82    /// Controlled: the running max of measured tab widths. Lifted so that
83    /// `SettingsPanelSizeUpdate(None)` (divider reset) can clear it.
84    pub auto_width: f64,
85
86    /// Callback invoked when the user clicks a tab.
87    pub on_select_tab: Callback<SelectedTab>,
88
89    /// Callback invoked by tab subtrees reporting their natural width.
90    pub on_auto_width: Callback<f64>,
91
92    /// Fires when the outer split-panel divider is reset; threaded into
93    /// `ColumnSelector` so its inner `ScrollPanel` can drop its persistent
94    /// `viewport_width` and re-measure honestly. Without this, the
95    /// `auto_width` reset in `PerspectiveViewer` rebounds immediately as
96    /// the ScrollPanel republishes its stale cached width.
97    pub on_dimensions_reset: Rc<PubSub<()>>,
98
99    /// State
100    pub session: Session,
101    pub renderer: Renderer,
102    pub presentation: Presentation,
103    pub workspace: Workspace,
104}
105
106impl PartialEq for SettingsPanelProps {
107    fn eq(&self, rhs: &Self) -> bool {
108        self.is_debug == rhs.is_debug
109            && self.plugin_name == rhs.plugin_name
110            && self.available_plugins == rhs.available_plugins
111            && self.has_table == rhs.has_table
112            && self.named_column_count == rhs.named_column_count
113            && self.plugin_static_config == rhs.plugin_static_config
114            && self.view_config == rhs.view_config
115            && self.plugin_config == rhs.plugin_config
116            && self.drag_column == rhs.drag_column
117            && self.metadata == rhs.metadata
118            && self.open_column_settings == rhs.open_column_settings
119            && self.selected_theme == rhs.selected_theme
120            && self.selected_tab == rhs.selected_tab
121            && self.auto_width == rhs.auto_width
122    }
123}
124
125#[derive(Debug, PartialEq, Clone, Copy, Default)]
126pub enum SelectedTab {
127    #[default]
128    Query,
129    Plugin,
130    Debug,
131
132    /// The embedded LLM agent's chat panel. The variant exists in every
133    /// build; its tab button and body render only under the `llm-agent`
134    /// feature, and only once `agentConfig()` has been called.
135    Chat,
136}
137
138#[function_component]
139pub fn SettingsPanel(props: &SettingsPanelProps) -> Html {
140    let SettingsPanelProps {
141        presentation,
142        renderer,
143        session,
144        ..
145    } = &props;
146
147    let selected_column = {
148        let locator = props.open_column_settings.locator.clone();
149        let config = &props.view_config;
150        locator.filter(|locator| match locator {
151            ColumnLocator::Table(_name) => {
152                locator
153                    .name()
154                    .map(|n| {
155                        config.columns.iter().any(|maybe_col| {
156                            maybe_col.as_ref().map(|col| col == n).unwrap_or_default()
157                        }) || config.group_by.iter().any(|col| col == n)
158                            || config.split_by.iter().any(|col| col == n)
159                            || config.filter.iter().any(|col| col.column() == n)
160                            || config.sort.iter().any(|col| &col.0 == n)
161                    })
162                    .unwrap_or_default()
163                    && props.renderer.can_render_column_styles()
164            },
165            _ => true,
166        })
167    };
168
169    let plugin_name = props.plugin_name.clone();
170    let available_plugins = props.available_plugins.clone();
171    let selected = props.selected_tab;
172
173    // Shared trap-door width across tabs. Each tab subtree measures its
174    // natural width and feeds the result back through `on_auto_width`;
175    // the parent keeps the running max so a tab switch never shrinks the
176    // panel, and clears it on divider reset.
177    let width = props.auto_width;
178    let on_auto_width = props.on_auto_width.clone();
179
180    // Dispatch callback: captures engine handles, constructs config update,
181    // hands the apply+draw work to `tasks::pipeline`.
182    let on_select_plugin = {
183        clone!(renderer, session, presentation);
184        let session_metadata = props.metadata.clone();
185        let view_config = props.view_config.clone();
186        Callback::from(move |plugin_name: String| {
187            if session.is_errored() {
188                return;
189            }
190            // Pure resolve — the swap itself is committed inside the locked
191            // draw task by `update_plugin_and_render`, never staged on the
192            // `Renderer` where a concurrent draw could observe it.
193            let resolved_plugin =
194                renderer.resolve_plugin_update(&PluginUpdate::Update(plugin_name));
195            let prev_metadata = renderer.metadata();
196            let plugin_config = resolved_plugin
197                .as_ref()
198                .map(|(_, metadata)| &**metadata)
199                .unwrap_or(&*prev_metadata);
200            let rollup_features = session_metadata
201                .get_features()
202                .map(|x| x.get_group_rollup_modes())
203                .unwrap();
204
205            let group_rollups = plugin_config.get_group_rollups(&rollup_features);
206            let split_rollup_features = session_metadata
207                .get_features()
208                .map(|x| x.get_split_rollup_modes())
209                .unwrap();
210
211            let split_rollups = plugin_config.get_split_rollups(&split_rollup_features);
212            let mut update = ViewConfigUpdate {
213                group_rollup_mode: group_rollups.first().cloned(),
214                split_rollup_mode: split_rollups.first().cloned(),
215                ..ViewConfigUpdate::default()
216            };
217
218            update.set_update_column_defaults(
219                &session_metadata,
220                &view_config.columns,
221                plugin_config,
222            );
223
224            let plugin_idx = resolved_plugin.map(|(idx, _)| idx);
225            if let Ok(task) = update_plugin_and_render(&session, &renderer, update, plugin_idx) {
226                ApiFuture::spawn(task);
227            }
228
229            presentation.set_open_column_settings(None);
230        })
231    };
232
233    let cb1 = props.on_select_column.clone();
234    let set_debug = use_callback(
235        props.on_select_tab.clone(),
236        move |_: PointerEvent, on_select_tab| {
237            on_select_tab.emit(SelectedTab::Debug);
238            cb1.emit(None)
239        },
240    );
241
242    let cb2 = props.on_select_column.clone();
243    let set_plugin = use_callback(
244        props.on_select_tab.clone(),
245        move |_: PointerEvent, on_select_tab| {
246            on_select_tab.emit(SelectedTab::Plugin);
247            cb2.emit(None)
248        },
249    );
250
251    let set_query = use_callback(
252        props.on_select_tab.clone(),
253        |_: PointerEvent, on_select_tab| on_select_tab.emit(SelectedTab::Query),
254    );
255
256    let tab_class = |l_tab: SelectedTab, r_tab: SelectedTab| {
257        if l_tab == r_tab {
258            "settings_tab selected_tab"
259        } else {
260            "settings_tab"
261        }
262    };
263
264    // The chat tab is zero-affordance until `agentConfig()` is called; the
265    // subscription re-renders this panel when that happens (and as the
266    // transcript updates while the chat body is mounted).
267    #[cfg(feature = "llm-agent")]
268    let (chat_tab_button, chat_body) = {
269        let update = use_force_update();
270        let agent = presentation.agent.clone();
271        use_effect_with((), move |_| {
272            let sub = agent
273                .on_update
274                .add_notify_listener(&Callback::from(move |_| update.force_update()));
275
276            move || drop(sub)
277        });
278
279        let button = if presentation.agent.is_configured() {
280            let on_select_column = props.on_select_column.clone();
281            let set_chat = {
282                let on_select_tab = props.on_select_tab.clone();
283                Callback::from(move |_: PointerEvent| {
284                    on_select_tab.emit(SelectedTab::Chat);
285                    on_select_column.emit(None)
286                })
287            };
288
289            html! {
290                <div
291                    id="chat_tabbar_tab"
292                    class={tab_class(selected, SelectedTab::Chat)}
293                    onpointerdown={set_chat}
294                />
295            }
296        } else {
297            html! {}
298        };
299
300        let body = html! {
301            <crate::components::chat_panel::ChatPanel agent={presentation.agent.clone()} />
302        };
303
304        (button, body)
305    };
306
307    #[cfg(not(feature = "llm-agent"))]
308    let (chat_tab_button, chat_body) = (html! {}, html! {});
309
310    let on_open_expr_panel = use_callback(props.on_select_column.clone(), |c, on_select| {
311        on_select.emit(Some(c))
312    });
313
314    html! {
315        <div id="settings_panel" class="sidebar_column noselect split-panel orient-vertical">
316            if selected_column.is_none() {
317                <SidebarCloseButton
318                    id="settings_close_button"
319                    on_close_sidebar={&props.on_close.clone()}
320                />
321            }
322            <PluginSelector
323                {plugin_name}
324                {available_plugins}
325                {on_select_plugin}
326            />
327            <div id="settings_tab_bar" class="settings_tab_bar_scroll_offset">
328                <div
329                    id="query_tabbar_tab"
330                    class={tab_class(selected, SelectedTab::Query)}
331                    onpointerdown={set_query}
332                />
333                <div
334                    id="plugin_tabbar_tab"
335                    class={tab_class(selected, SelectedTab::Plugin)}
336                    onpointerdown={set_plugin}
337                />
338                <div
339                    id="debug_tabbar_tab"
340                    class={tab_class(selected, SelectedTab::Debug)}
341                    onpointerdown={set_debug}
342                />
343                { chat_tab_button }
344            </div>
345            if selected == SelectedTab::Query {
346                <ColumnSelector
347                    on_resize={&props.on_resize}
348                    {on_open_expr_panel}
349                    {selected_column}
350                    has_table={props.has_table.clone()}
351                    named_column_count={props.named_column_count}
352                    plugin_static_config={props.plugin_static_config.clone()}
353                    view_config={props.view_config.clone()}
354                    drag_column={props.drag_column.clone()}
355                    metadata={props.metadata.clone()}
356                    selected_theme={props.selected_theme.clone()}
357                    presentation={presentation.clone()}
358                    renderer={renderer.clone()}
359                    session={session.clone()}
360                    initial_width={width}
361                    on_auto_width={on_auto_width.clone()}
362                    on_dimensions_reset={&props.on_dimensions_reset}
363                />
364            } else if selected == SelectedTab::Plugin {
365                <PluginTab
366                    view_config={props.view_config.clone()}
367                    plugin_config={props.plugin_config.clone()}
368                    renderer={renderer.clone()}
369                    session={session.clone()}
370                // initial_width={width}
371                // on_auto_width={on_auto_width.clone()}
372                />
373            } else if selected == SelectedTab::Chat {
374                { chat_body }
375            } else {
376                <DebugPanel
377                    {presentation}
378                    {renderer}
379                    {session}
380                    workspace={props.workspace.clone()}
381                    initial_width={width}
382                    on_auto_width={on_auto_width.clone()}
383                />
384            }
385            // Sibling sizer keeps the panel width pinned across tab
386            // switches; lives outside the tab-body so it survives the
387            // tab subtree's unmount.
388            <div
389                class="scroll-panel-auto-width"
390                style={format!("width:{}px", width)}
391            />
392        </div>
393    }
394}