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 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(()); }
210
211 if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL {
212 self.bg_query_handle = None;
215 return Ok(()); }
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(()); }
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; return Ok(());
231 }
232
233 match self.focus {
234 Focus::Editor => {
235 text_area.input(key);
236 self.state_dirty = true; }
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 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
375fn 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}