perspective_viewer/components/
debug_panel.rs1use 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 #[prop_or_default]
41 pub initial_width: f64,
42
43 #[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
55type 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 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 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 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
338fn 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}