posthog_cli/experimental/tui/
query.rs1use 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 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(()); }
209
210 if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL {
211 self.bg_query_handle = None;
214 return Ok(()); }
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(()); }
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; return Ok(());
230 }
231
232 match self.focus {
233 Focus::Editor => {
234 text_area.input(key);
235 self.state_dirty = true; }
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 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
374fn 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}