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