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