posthog_cli/experimental/tui/
query.rs

1use std::{io::Stdout, thread::JoinHandle, time::Duration};
2
3use anyhow::Error;
4use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
5use ratatui::{
6    layout::{Alignment, Constraint, Direction, Layout, Rect},
7    prelude::CrosstermBackend,
8    style::{Color, Style, Stylize},
9    widgets::{Block, BorderType, Paragraph, Row, Table, TableState},
10    Frame, Terminal,
11};
12
13use serde::{Deserialize, Serialize};
14use tui_textarea::TextArea;
15
16use crate::{
17    experimental::query::{
18        run_query, HogQLQueryErrorResponse, HogQLQueryResponse, HogQLQueryResult,
19    },
20    utils::{auth::Token, homedir::posthog_home_dir},
21};
22
23pub struct QueryTui {
24    host: String,
25    creds: Token,
26    current_result: Option<HogQLQueryResult>,
27    lower_panel_state: Option<LowerPanelState>,
28    bg_query_handle: Option<JoinHandle<Result<HogQLQueryResult, Error>>>,
29    focus: Focus,
30    debug: bool,
31    state_dirty: bool,
32}
33
34#[allow(clippy::large_enum_variant)]
35enum LowerPanelState {
36    TableState(TableState),
37    DebugState(TextArea<'static>),
38}
39
40#[derive(Clone, Copy)]
41enum Focus {
42    Editor,
43    Output,
44}
45
46#[derive(Serialize, Deserialize)]
47struct PersistedEditorState {
48    lines: Vec<String>,
49    current_result: Option<HogQLQueryResult>,
50}
51
52impl QueryTui {
53    pub fn new(creds: Token, host: String, debug: bool) -> Self {
54        Self {
55            current_result: None,
56            lower_panel_state: None,
57            creds,
58            host,
59            focus: Focus::Editor,
60            debug,
61            bg_query_handle: None,
62            state_dirty: false,
63        }
64    }
65
66    fn draw_outer(&mut self, frame: &mut Frame) -> Rect {
67        let area = frame.area();
68
69        let outer = Layout::default()
70            .direction(Direction::Vertical)
71            .constraints([Constraint::Fill(1)].as_ref())
72            .split(area);
73
74        let mut top_title =
75            "Posthog Query Editor - Ctrl+R to run query, ESC to quit, Ctrl+F to switch focus"
76                .to_string();
77        if self.bg_query_handle.is_some() {
78            top_title.push_str(" (Running query, Ctrl+C to cancel)");
79        }
80
81        let border_color = if self.bg_query_handle.is_some() {
82            Color::LightBlue
83        } else {
84            Color::Black
85        };
86
87        let outer_block = Block::bordered()
88            .title_top(top_title)
89            .border_type(BorderType::Rounded)
90            .border_style(Style::new().bg(border_color))
91            .title_alignment(Alignment::Center);
92
93        let inner_area = outer_block.inner(outer[0]);
94
95        frame.render_widget(outer_block, outer[0]);
96
97        inner_area
98    }
99
100    fn save_editor_state(&self, lines: Vec<String>) -> Result<(), Error> {
101        if !self.state_dirty {
102            return Ok(());
103        }
104        let home_dir = posthog_home_dir();
105        let editor_state_path = home_dir.join("editor_state.json");
106        let state = PersistedEditorState {
107            lines,
108            current_result: self.current_result.clone(),
109        };
110
111        let state_str = serde_json::to_string(&state)?;
112        std::fs::write(editor_state_path, state_str)?;
113        Ok(())
114    }
115
116    fn load_editor_state(&mut self) -> Result<Vec<String>, Error> {
117        let home_dir = posthog_home_dir();
118        let editor_state_path = home_dir.join("editor_state.json");
119        if !editor_state_path.exists() {
120            return Ok(vec![]);
121        }
122
123        let state_str = std::fs::read_to_string(editor_state_path)?;
124        let Ok(state): Result<PersistedEditorState, _> = serde_json::from_str(&state_str) else {
125            return Ok(vec![]);
126        };
127        self.current_result = state.current_result;
128        Ok(state.lines)
129    }
130
131    fn draw_lower_panel(&mut self, frame: &mut Frame, area: Rect) {
132        let is_focus = matches!(self.focus, Focus::Output);
133        match (&self.current_result, self.debug) {
134            (Some(Ok(res)), false) => {
135                let table = get_response_table(res, is_focus);
136                let mut ts =
137                    if let Some(LowerPanelState::TableState(ts)) = self.lower_panel_state.take() {
138                        ts
139                    } else {
140                        TableState::default()
141                    };
142
143                frame.render_stateful_widget(table, area, &mut ts);
144                self.lower_panel_state = Some(LowerPanelState::TableState(ts));
145            }
146            (Some(Ok(res)), true) => {
147                let debug_display =
148                    if let Some(LowerPanelState::DebugState(ta)) = self.lower_panel_state.take() {
149                        ta
150                    } else {
151                        get_debug_display(res)
152                    };
153
154                let debug_display = style_debug_display(debug_display, is_focus);
155                frame.render_widget(&debug_display, area);
156                self.lower_panel_state = Some(LowerPanelState::DebugState(debug_display));
157            }
158            (Some(Err(err)), _) => {
159                let paragraph = get_error_display(err, is_focus);
160                frame.render_widget(paragraph, area);
161            }
162            (None, _) => {}
163        }
164    }
165
166    fn draw(&mut self, frame: &mut Frame, text_area: &TextArea) {
167        let inner_area = self.draw_outer(frame);
168
169        let mut panel_count: usize = 1;
170        if self.current_result.is_some() {
171            panel_count += 1;
172        }
173
174        // TODO - figure out nicer dynamic constraints?
175        let mut constraints = vec![];
176        constraints.extend(vec![Constraint::Fill(1); panel_count]);
177
178        let inner_panels = Layout::default()
179            .direction(Direction::Vertical)
180            .constraints(constraints)
181            .split(inner_area);
182
183        frame.render_widget(text_area, inner_panels[0]);
184        if inner_panels.len() > 1 {
185            self.draw_lower_panel(frame, inner_panels[1]);
186        }
187    }
188
189    fn handle_bg_query(&mut self) -> Result<(), Error> {
190        let Some(handle) = self.bg_query_handle.take() else {
191            return Ok(());
192        };
193
194        if !handle.is_finished() {
195            self.bg_query_handle = Some(handle);
196            return Ok(());
197        }
198
199        let res = handle.join().expect("Task did not panic")?;
200
201        self.current_result = Some(res);
202        self.state_dirty = true;
203        Ok(())
204    }
205
206    fn handle_keypress(&mut self, text_area: &mut TextArea, key: KeyEvent) -> Result<(), Error> {
207        if key.code == KeyCode::Char('r') && key.modifiers == KeyModifiers::CONTROL {
208            let lines = text_area.lines().to_vec();
209            self.spawn_bg_query(lines);
210            return Ok(()); // Simply starting the query doesn't modify the state
211        }
212
213        if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL {
214            // TODO - we don't have proper task cancellation here, but this "cancels" the query from the
215            // user's perspective - they will never see the results
216            self.bg_query_handle = None;
217            return Ok(()); // As above, this doesn't modify the state
218        }
219
220        if key.code == KeyCode::Char('f') && key.modifiers == KeyModifiers::CONTROL {
221            self.focus = match self.focus {
222                Focus::Editor => Focus::Output,
223                Focus::Output => Focus::Editor,
224            };
225            return Ok(()); // As above, this doesn't modify the state
226        }
227
228        if key.code == KeyCode::Char('q') && key.modifiers == KeyModifiers::CONTROL {
229            self.current_result = None;
230            self.lower_panel_state = None;
231            self.state_dirty = true; // We've discarded the current result
232            return Ok(());
233        }
234
235        match self.focus {
236            Focus::Editor => {
237                text_area.input(key);
238                self.state_dirty = true; // Keys into the editor modify the state
239            }
240            Focus::Output => {
241                self.handle_output_event(key);
242            }
243        }
244
245        Ok(())
246    }
247
248    fn handle_events(&mut self, text_area: &mut TextArea) -> Result<Option<String>, Error> {
249        self.handle_bg_query()?;
250        self.save_editor_state(text_area.lines().to_vec())?;
251        if !event::poll(Duration::from_millis(17))? {
252            return Ok(None);
253        }
254
255        if let Event::Key(key) = event::read()? {
256            if key.code == KeyCode::Esc {
257                let last_query = text_area.lines().join("\n");
258                return Ok(Some(last_query));
259            }
260
261            self.handle_keypress(text_area, key)?;
262        }
263
264        Ok(None)
265    }
266
267    fn handle_output_event(&mut self, key: KeyEvent) {
268        match &mut self.lower_panel_state {
269            Some(LowerPanelState::TableState(ref mut ts)) => {
270                if key.code == KeyCode::Down {
271                    ts.select_next();
272                } else if key.code == KeyCode::Up {
273                    ts.select_previous();
274                }
275            }
276            Some(LowerPanelState::DebugState(ta)) => {
277                ta.input(key);
278            }
279            _ => {}
280        }
281    }
282
283    fn enter_draw_loop(
284        &mut self,
285        mut terminal: Terminal<CrosstermBackend<Stdout>>,
286    ) -> Result<String, Error> {
287        let lines = self.load_editor_state()?;
288        let mut text_area = TextArea::new(lines);
289        loop {
290            terminal.draw(|frame| self.draw(frame, &text_area))?;
291            if let Some(query) = self.handle_events(&mut text_area)? {
292                return Ok(query);
293            }
294        }
295    }
296
297    fn spawn_bg_query(&mut self, lines: Vec<String>) {
298        let query = lines.join("\n");
299        let query_endpoint = format!("{}/api/environments/{}/query", self.host, self.creds.env_id);
300        let m_token = self.creds.token.clone();
301        let handle = std::thread::spawn(move || run_query(&query_endpoint, &m_token, &query));
302
303        // We drop any previously running thread handle here, but don't kill the thread... this is fine,
304        // I think. The alternative is to switch to tokio and get true task cancellation, but :shrug:,
305        // TODO later I guess
306        self.bg_query_handle = Some(handle);
307    }
308}
309
310pub fn start_query_editor(host: &str, token: Token, debug: bool) -> Result<String, Error> {
311    let terminal = ratatui::init();
312
313    let mut app = QueryTui::new(token, host.to_string(), debug);
314    let res = app.enter_draw_loop(terminal);
315    ratatui::restore();
316    res
317}
318
319fn get_response_table<'a>(response: &HogQLQueryResponse, is_focus: bool) -> Table<'a> {
320    let cols = &response.columns;
321    let widths = cols.iter().map(|_| Constraint::Fill(1)).collect::<Vec<_>>();
322    let mut rows: Vec<Row> = Vec::with_capacity(response.results.len());
323    for row in &response.results {
324        let mut row_data = Vec::with_capacity(cols.len());
325        for _ in cols {
326            let value = row[row_data.len()].to_string();
327            row_data.push(value.to_string());
328        }
329        rows.push(Row::new(row_data));
330    }
331
332    let border_color = if is_focus {
333        Color::Cyan
334    } else {
335        Color::DarkGray
336    };
337
338    let table = Table::new(rows, widths)
339        .column_spacing(1)
340        .header(Row::new(cols.clone()).style(Style::new().bold().bg(Color::LightBlue)))
341        .block(
342            ratatui::widgets::Block::default()
343                .title("Query Results (Ctrl+Q to clear)")
344                .title_style(Style::new().bold().fg(Color::White).bg(Color::DarkGray))
345                .borders(ratatui::widgets::Borders::ALL)
346                .border_style(Style::new().fg(Color::White).bg(border_color)),
347        )
348        .row_highlight_style(Style::new().bold().bg(Color::DarkGray))
349        .highlight_symbol(">");
350
351    table
352}
353
354fn get_error_display<'c>(err: &HogQLQueryErrorResponse, is_focus: bool) -> Paragraph<'c> {
355    let mut lines = vec![format!("Error: {}", err.error_type)];
356    lines.push(format!("Code: {}", err.code));
357    lines.push(format!("Detail: {}", err.detail));
358
359    let border_color = if is_focus {
360        Color::Cyan
361    } else {
362        Color::LightRed
363    };
364
365    Paragraph::new(lines.join("\n"))
366        .style(Style::new().fg(Color::Red))
367        .block(
368            Block::default()
369                .title("Error (Ctrl+Q to clear)")
370                .title_style(Style::new().bold().fg(Color::White).bg(Color::Red))
371                .borders(ratatui::widgets::Borders::ALL)
372                .border_style(Style::new().fg(Color::White).bg(border_color)),
373        )
374}
375
376// A function that returns a text area with the json
377fn get_debug_display(response: &HogQLQueryResponse) -> TextArea<'static> {
378    let json = serde_json::to_string_pretty(&response)
379        .expect("Can serialize response to json")
380        .lines()
381        .map(|s| s.to_string())
382        .collect();
383    let mut ta = TextArea::new(json);
384    ta.set_line_number_style(Style::new().bg(Color::DarkGray));
385    ta
386}
387
388fn style_debug_display(mut ta: TextArea, is_focus: bool) -> TextArea {
389    let border_color = if is_focus {
390        Color::Cyan
391    } else {
392        Color::DarkGray
393    };
394
395    ta.set_block(
396        Block::default()
397            .title("Debug (Ctrl+Q to clear)")
398            .title_style(Style::new().bold().fg(Color::White).bg(Color::Red))
399            .borders(ratatui::widgets::Borders::ALL)
400            .border_style(Style::new().fg(Color::White).bg(border_color)),
401    );
402
403    ta
404}