posthog_cli/experimental/tui/
query.rs1use 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 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(()); }
212
213 if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL {
214 self.bg_query_handle = None;
217 return Ok(()); }
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(()); }
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; return Ok(());
233 }
234
235 match self.focus {
236 Focus::Editor => {
237 text_area.input(key);
238 self.state_dirty = true; }
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 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
376fn 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}