Skip to main content

perspective_viewer/components/
debug_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::cell::Cell;
14use std::rc::Rc;
15
16use perspective_client::ExprValidationError;
17use perspective_js::utils::{ApiFuture, JsValueSerdeExt};
18use wasm_bindgen::prelude::*;
19use yew::prelude::*;
20
21use crate::components::code_editor::CodeEditor;
22use crate::config::*;
23use crate::js::{MimeType, copy_to_clipboard, paste_from_clipboard};
24use crate::presentation::*;
25use crate::renderer::*;
26use crate::session::*;
27use crate::utils::*;
28use crate::workspace::Workspace;
29
30#[derive(Clone, Properties)]
31pub struct DebugPanelProps {
32    pub presentation: Presentation,
33    pub renderer: Renderer,
34    pub session: Session,
35    pub workspace: Workspace,
36
37    /// Trap-door width pinned by the parent `SettingsPanel` so switching
38    /// tabs doesn't shrink the panel. Threaded into the hidden sizer
39    /// `<div class="scroll-panel-auto-width">`.
40    #[prop_or_default]
41    pub initial_width: f64,
42
43    /// Fires once on mount with this panel's measured natural width.
44    /// Routed up to `SettingsPanel` which keeps the running max.
45    #[prop_or_default]
46    pub on_auto_width: Callback<f64>,
47}
48
49impl PartialEq for DebugPanelProps {
50    fn eq(&self, rhs: &Self) -> bool {
51        self.initial_width == rhs.initial_width
52    }
53}
54
55/// Whether the editor holds text the panel has not successfully applied, read
56/// synchronously by the config-change listeners and again when a
57/// [`DebugPanelProps::set_text`] read lands, so neither a failed apply's own
58/// `view_config_changed` nor an in-flight config read discards it.
59type Dirty = Rc<Cell<bool>>;
60
61#[function_component(DebugPanel)]
62pub fn debug_panel(props: &DebugPanelProps) -> Html {
63    let expr = use_state_eq(|| Rc::new("".to_string()));
64    let error = use_state_eq(|| Option::<ExprValidationError>::None);
65    let select_all = use_memo((), |()| PubSub::default());
66    let modified = use_state_eq(|| false);
67    let dirty: Dirty = use_memo((), |()| Cell::new(false));
68
69    // Measure natural width on mount and route up to `SettingsPanel`.
70    let sizer = use_node_ref();
71    use_effect_with(expr.clone(), {
72        let sizer = sizer.clone();
73        let on_auto_width = props.on_auto_width.clone();
74        move |_| {
75            if let Some(elem) = sizer.cast::<web_sys::HtmlElement>() {
76                on_auto_width.emit(elem.get_bounding_client_rect().width());
77            }
78        }
79    });
80
81    use_effect_with((), {
82        clone!(error, modified, dirty);
83        let state = props.clone();
84        let text = expr.setter();
85        move |()| {
86            state.set_text(text.clone(), dirty.clone());
87            error.set(None);
88            let sub1 = state
89                .renderer
90                .style_changed
91                .add_listener(state.reset_callback(
92                    text.clone(),
93                    error.setter(),
94                    modified.setter(),
95                    dirty.clone(),
96                ));
97
98            let sub2 = state
99                .renderer
100                .reset_changed
101                .add_listener(state.reset_callback(
102                    text.clone(),
103                    error.setter(),
104                    modified.setter(),
105                    dirty.clone(),
106                ));
107
108            let sub3 = state
109                .session
110                .view_config_changed
111                .add_listener(state.reset_callback(
112                    text.clone(),
113                    error.setter(),
114                    modified.setter(),
115                    dirty.clone(),
116                ));
117
118            || {
119                drop(sub1);
120                drop(sub2);
121                drop(sub3);
122            }
123        }
124    });
125
126    let oninput = use_callback(expr.setter(), {
127        clone!(modified, dirty);
128        move |x, expr| {
129            dirty.set(true);
130            modified.set(true);
131            expr.set(x)
132        }
133    });
134
135    let onsave = use_callback((expr.clone(), error.clone(), props.clone()), {
136        clone!(modified, dirty);
137        move |_, (text, error, props)| props.on_save(text, text.setter(), error, &modified, &dirty)
138    });
139
140    let oncopy = use_callback(
141        (expr.clone(), select_all.callback()),
142        move |_, (text, select_all)| {
143            select_all.emit(());
144            let options = web_sys::BlobPropertyBag::new();
145            options.set_type("text/plain");
146            let blob_txt = (JsValue::from((***text).clone())).clone();
147            let blob_parts = js_sys::Array::from_iter([blob_txt].iter());
148            let blob = web_sys::Blob::new_with_str_sequence_and_options(&blob_parts, &options);
149            ApiFuture::spawn(copy_to_clipboard(
150                async move { Ok(blob?) },
151                MimeType::TextPlain,
152            ));
153        },
154    );
155
156    let onapply = use_callback((expr.clone(), error.clone(), props.clone()), {
157        clone!(modified, dirty);
158        move |_, (text, error, props)| props.on_save(text, text.setter(), error, &modified, &dirty)
159    });
160
161    let onreset = use_callback((expr.setter(), error.clone(), props.clone()), {
162        clone!(modified, dirty);
163        move |_, (text, error, props)| {
164            dirty.set(false);
165            props.set_text(text.clone(), dirty.clone());
166            error.set(None);
167            modified.set(false);
168        }
169    });
170
171    let onpaste = use_callback((expr.clone(), error.clone(), props.clone()), {
172        clone!(modified, dirty);
173        move |_, (text, error, props)| {
174            clone!(text, error, props, modified, dirty);
175            ApiFuture::spawn(async move {
176                if let Some(x) = paste_from_clipboard().await {
177                    let x = Rc::new(x);
178                    dirty.set(true);
179                    modified.set(true);
180                    error.set(None);
181                    text.set(x.clone());
182                    props.on_save(&x, text.setter(), &error, &modified, &dirty);
183                }
184
185                Ok(())
186            });
187        }
188    });
189
190    html! {
191        <>
192            <div id="debug-panel-overflow">
193                <div id="debug-panel" class="sidebar_column" ref={sizer}>
194                    <div id="debug-panel-controls">
195                        <button id="debug-panel-apply" disabled={!*modified} onclick={onapply} />
196                        <button id="debug-panel-reset" disabled={!*modified} onclick={onreset} />
197                        <button id="debug-panel-copy" onclick={oncopy} />
198                        <button id="debug-panel-paste" onclick={onpaste} />
199                    </div>
200                    <div id="debug-panel-editor">
201                        <CodeEditor
202                            expr={&*expr}
203                            disabled=false
204                            contained=true
205                            {oninput}
206                            {onsave}
207                            select_all={select_all.subscriber()}
208                            error={(*error).clone()}
209                        />
210                        if let Some(err) = &*error {
211                            <div id="debug-panel-error" class="error">{ &err.error_message }</div>
212                        }
213                    </div>
214                    <div
215                        class="scroll-panel-auto-width"
216                        style={format!("width:{}px", props.initial_width)}
217                    />
218                </div>
219            </div>
220        </>
221    }
222}
223
224impl DebugPanelProps {
225    /// Asynchronously read this panel's config into the editor, unless the
226    /// editor has unapplied text by the time the read lands.
227    fn set_text(&self, setter: UseStateSetter<Rc<String>>, dirty: Dirty) {
228        let props = self.clone();
229        ApiFuture::spawn(async move {
230            let config = crate::queries::get_viewer_config(
231                &props.session,
232                &props.renderer,
233                &props.presentation,
234            )
235            .await?
236            .map_table(|name| props.workspace.inline_table_ref(&name));
237            let json = JsValue::from_serde_ext(&config)?;
238            let js_string =
239                js_sys::JSON::stringify_with_replacer_and_space(&json, &JsValue::NULL, &2.into())?;
240
241            if !dirty.get() {
242                setter.set(Rc::new(js_string.as_string().unwrap()));
243            }
244
245            Ok(())
246        });
247    }
248
249    fn reset_callback(
250        &self,
251        text: UseStateSetter<Rc<String>>,
252        error: UseStateSetter<Option<ExprValidationError>>,
253        modified: UseStateSetter<bool>,
254        dirty: Dirty,
255    ) -> impl Fn(()) + use<> {
256        let props = self.clone();
257        move |_| {
258            if dirty.get() {
259                return;
260            }
261
262            error.set(None);
263            props.set_text(text.clone(), dirty.clone());
264            modified.set(false);
265        }
266    }
267
268    /// Validate and apply the editor's text as this panel's config, treating
269    /// an un-hosted `table` as a validation error rather than a pending bind.
270    fn on_save(
271        &self,
272        source: &Rc<String>,
273        text: UseStateSetter<Rc<String>>,
274        error: &UseStateHandle<Option<ExprValidationError>>,
275        modified: &UseStateHandle<bool>,
276        dirty: &Dirty,
277    ) {
278        let props = self.clone();
279        clone!(source, error, modified, dirty);
280        ApiFuture::spawn(async move {
281            let fail = |message: String, (line, column): (u32, u32)| {
282                dirty.set(true);
283                modified.set(true);
284                error.set(Some(ExprValidationError {
285                    error_message: message,
286                    line,
287                    column,
288                }));
289            };
290
291            let config: ViewerConfigUpdate = match serde_json::from_str(&source) {
292                Ok(config) => config,
293                Err(err) => {
294                    let position = (err.line() as u32 - 1, err.column() as u32 - 1);
295                    fail(err.to_string(), position);
296                    return Ok(());
297                },
298            };
299
300            let active = props.workspace.active_renderer().as_ref() == Some(&props.renderer);
301            let result = crate::tasks::restore_panel(
302                &props.session,
303                &props.renderer,
304                &props.presentation,
305                &props.workspace,
306                crate::tasks::RestoreMode::Existing { active },
307                config,
308                crate::tasks::RestoreErrors::Suppress,
309                MissingTable::Error,
310                None,
311            )
312            .await;
313
314            match result {
315                Ok(_) => {
316                    dirty.set(false);
317                    error.set(None);
318                    modified.set(false);
319                    props.set_text(text, dirty.clone());
320                },
321                Err(e) => {
322                    let message = format!("{e}");
323                    let position = if message.starts_with("Unknown table") {
324                        locate_key(&source, "table")
325                    } else {
326                        (0, 0)
327                    };
328
329                    fail(message, position)
330                },
331            }
332
333            Ok(())
334        });
335    }
336}
337
338/// The 0-based `(line, column)` of the first `"key"` in `text`, or `(0, 0)`
339/// when absent.
340fn locate_key(text: &str, key: &str) -> (u32, u32) {
341    let needle = format!("\"{key}\"");
342    text.lines()
343        .enumerate()
344        .find_map(|(line, content)| {
345            content
346                .find(&needle)
347                .map(|column| (line as u32, column as u32))
348        })
349        .unwrap_or((0, 0))
350}