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