posthog_cli/experimental/tui/
query.rs

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