1use crate::app::{App, Panel, ThemeMode};
2use crate::shape::{DetailData, Shape, Status, TableData};
3use ratatui::{
4 layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7 widgets::{
8 Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph,
9 Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, Wrap,
10 },
11 Frame,
12};
13use std::time::Duration;
14
15struct Palette {
16 text: Color,
17 dim: Color,
18 border: Color,
19 warn: Color,
20 ok: Color,
21 err: Color,
22 key: Color,
23 brand: Color,
24 clusters: Color,
25 jobs: Color,
26 pipelines: Color,
27 warehouses: Color,
28 catalog: Color,
29 grad_from: (u8, u8, u8),
31 grad_to: (u8, u8, u8),
32}
33
34const fn rgb(hex: u32) -> Color {
35 Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
36}
37
38const fn rgb3(hex: u32) -> (u8, u8, u8) {
39 ((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
40}
41
42fn palette(mode: ThemeMode) -> Palette {
43 match mode {
44 ThemeMode::Dark => Palette {
46 text: Color::White,
47 dim: Color::DarkGray,
48 border: Color::DarkGray,
49 warn: Color::Yellow,
50 ok: Color::Green,
51 err: Color::Red,
52 key: Color::Cyan,
53 brand: Color::Red,
54 clusters: Color::Cyan,
55 jobs: Color::Magenta,
56 pipelines: Color::Green,
57 warehouses: Color::Blue,
58 catalog: rgb(0xFF8C42),
59 grad_from: rgb3(0xFF3621),
60 grad_to: rgb3(0xFFA046),
61 },
62 ThemeMode::Light => Palette {
64 text: Color::Black,
65 dim: rgb(0x6B7280),
66 border: rgb(0x9CA3AF),
67 warn: rgb(0xB45309),
68 ok: rgb(0x15803D),
69 err: rgb(0xB91C1C),
70 key: rgb(0x0891B2),
71 brand: rgb(0xDC2626),
72 clusters: rgb(0x0891B2),
73 jobs: rgb(0xA21CAF),
74 pipelines: rgb(0x15803D),
75 warehouses: rgb(0x1D4ED8),
76 catalog: rgb(0xC2410C),
77 grad_from: rgb3(0xB91C1C),
78 grad_to: rgb3(0xC2410C),
79 },
80 ThemeMode::CatppuccinMocha => Palette {
81 text: rgb(0xCDD6F4),
82 dim: rgb(0x6C7086),
83 border: rgb(0x585B70),
84 warn: rgb(0xF9E2AF),
85 ok: rgb(0xA6E3A1),
86 err: rgb(0xF38BA8),
87 key: rgb(0x89DCEB),
88 brand: rgb(0xF38BA8),
89 clusters: rgb(0x89DCEB),
90 jobs: rgb(0xCBA6F7),
91 pipelines: rgb(0xA6E3A1),
92 warehouses: rgb(0x89B4FA),
93 catalog: rgb(0xFAB387),
94 grad_from: rgb3(0xF38BA8),
95 grad_to: rgb3(0xFAB387),
96 },
97 ThemeMode::CatppuccinLatte => Palette {
98 text: rgb(0x4C4F69),
99 dim: rgb(0x8C8FA1),
100 border: rgb(0xACB0BE),
101 warn: rgb(0xDF8E1D),
102 ok: rgb(0x40A02B),
103 err: rgb(0xD20F39),
104 key: rgb(0x04A5E5),
105 brand: rgb(0xD20F39),
106 clusters: rgb(0x04A5E5),
107 jobs: rgb(0x8839EF),
108 pipelines: rgb(0x40A02B),
109 warehouses: rgb(0x1E66F5),
110 catalog: rgb(0xFE640B),
111 grad_from: rgb3(0xD20F39),
112 grad_to: rgb3(0xFE640B),
113 },
114 ThemeMode::GruvboxDark => Palette {
115 text: rgb(0xEBDBB2),
116 dim: rgb(0x928374),
117 border: rgb(0x665C54),
118 warn: rgb(0xFABD2F),
119 ok: rgb(0xB8BB26),
120 err: rgb(0xFB4934),
121 key: rgb(0x8EC07C),
122 brand: rgb(0xFB4934),
123 clusters: rgb(0x8EC07C),
124 jobs: rgb(0xD3869B),
125 pipelines: rgb(0xB8BB26),
126 warehouses: rgb(0x83A598),
127 catalog: rgb(0xFE8019),
128 grad_from: rgb3(0xFB4934),
129 grad_to: rgb3(0xFE8019),
130 },
131 ThemeMode::Dracula => Palette {
132 text: rgb(0xF8F8F2),
133 dim: rgb(0x6272A4),
134 border: rgb(0x44475A),
135 warn: rgb(0xF1FA8C),
136 ok: rgb(0x50FA7B),
137 err: rgb(0xFF5555),
138 key: rgb(0x8BE9FD),
139 brand: rgb(0xFF5555),
140 clusters: rgb(0x8BE9FD),
141 jobs: rgb(0xFF79C6),
142 pipelines: rgb(0x50FA7B),
143 warehouses: rgb(0xBD93F9),
144 catalog: rgb(0xFFB86C),
145 grad_from: rgb3(0xFF5555),
146 grad_to: rgb3(0xFFB86C),
147 },
148 ThemeMode::Nord => Palette {
149 text: rgb(0xD8DEE9),
150 dim: rgb(0x4C566A),
151 border: rgb(0x434C5E),
152 warn: rgb(0xEBCB8B),
153 ok: rgb(0xA3BE8C),
154 err: rgb(0xBF616A),
155 key: rgb(0x88C0D0),
156 brand: rgb(0xBF616A),
157 clusters: rgb(0x88C0D0),
158 jobs: rgb(0xB48EAD),
159 pipelines: rgb(0xA3BE8C),
160 warehouses: rgb(0x81A1C1),
161 catalog: rgb(0xD08770),
162 grad_from: rgb3(0xBF616A),
163 grad_to: rgb3(0xD08770),
164 },
165 ThemeMode::TokyoNight => Palette {
166 text: rgb(0xC0CAF5),
167 dim: rgb(0x565F89),
168 border: rgb(0x3B4261),
169 warn: rgb(0xE0AF68),
170 ok: rgb(0x9ECE6A),
171 err: rgb(0xF7768E),
172 key: rgb(0x7DCFFF),
173 brand: rgb(0xF7768E),
174 clusters: rgb(0x7DCFFF),
175 jobs: rgb(0xBB9AF7),
176 pipelines: rgb(0x9ECE6A),
177 warehouses: rgb(0x7AA2F7),
178 catalog: rgb(0xFF9E64),
179 grad_from: rgb3(0xF7768E),
180 grad_to: rgb3(0xFF9E64),
181 },
182 }
183}
184
185fn accent(panel: Panel, p: &Palette) -> Color {
186 match panel {
187 Panel::Clusters => p.clusters,
188 Panel::Jobs => p.jobs,
189 Panel::Pipelines => p.pipelines,
190 Panel::Warehouses => p.warehouses,
191 Panel::Dashboards => p.key,
192 Panel::Catalog => p.catalog,
193 Panel::Secrets => p.warn,
194 }
195}
196
197fn pane_breadcrumb(app: &App, panel: Panel) -> String {
198 match panel {
199 Panel::Catalog => app.uc_path.join("."),
200 Panel::Secrets => app.secret_scope.clone().unwrap_or_default(),
201 _ => String::new(),
202 }
203}
204
205pub fn draw(f: &mut Frame, app: &App) {
206 let p = palette(app.theme);
207
208 if app.splash_active() {
209 draw_splash(f, f.area(), app, &p);
210 return;
211 }
212
213 let root = Layout::default()
214 .direction(Direction::Vertical)
215 .constraints([
216 Constraint::Length(3),
217 Constraint::Min(0),
218 Constraint::Length(1),
219 ])
220 .split(f.area());
221
222 draw_header(f, root[0], app, &p);
223 draw_footer(f, root[2], app, &p);
224
225 if app.sql.is_some() {
226 draw_sql(f, root[1], app, &p);
227 if app.sql_complete.is_some() {
228 draw_sql_complete(f, root[1], app, &p);
229 }
230 if app.wh_picker.is_some() {
232 draw_wh_picker(f, root[1], app, &p);
233 }
234 return;
235 }
236
237 if app.cost.is_some() {
238 draw_cost(f, root[1], app, &p);
239 return;
240 }
241
242 if app.preview.is_some() {
243 draw_preview(f, root[1], app, &p);
244 return;
245 }
246
247 if app.run_view.is_some() {
248 draw_run(f, root[1], app, &p);
249 return;
250 }
251
252 if app.detail.is_some() {
253 draw_detail(f, root[1], app, &p);
254 return;
255 }
256
257 if app.zoomed {
258 let idx = Panel::ALL
259 .iter()
260 .position(|pn| pn == &app.focus)
261 .unwrap_or(0);
262 draw_panel(
263 f,
264 root[1],
265 app.focus,
266 app.shapes[idx].as_ref(),
267 true,
268 Some(app.selection(idx)),
269 false,
270 &pane_breadcrumb(app, app.focus),
271 app.spinner(),
272 &app.filters[idx],
273 app.filter_entry,
274 &p,
275 );
276 if app.picker.is_some() {
278 draw_picker(f, root[1], app, &p);
279 }
280 if app.wh_picker.is_some() {
281 draw_wh_picker(f, root[1], app, &p);
282 }
283 if app.problems.is_some() {
284 draw_problems(f, root[1], app, &p);
285 }
286 if app.upcoming.is_some() {
287 draw_upcoming(f, root[1], app, &p);
288 }
289 if app.jump.is_some() {
290 draw_jump(f, root[1], app, &p);
291 }
292 if app.pane_cfg.is_some() {
293 draw_pane_cfg(f, root[1], app, &p);
294 }
295 if app.secret_form.is_some() {
296 draw_secret_form(f, root[1], app, &p);
297 }
298 if app.help {
299 draw_help(f, root[1], app, &p);
300 }
301 return;
302 }
303
304 let visible = app.visible_panes();
307 if visible.is_empty() {
308 let par = Paragraph::new("all panes hidden — press H to bring them back")
309 .style(Style::default().fg(p.dim))
310 .alignment(Alignment::Center);
311 f.render_widget(par, root[1]);
312 } else {
313 let rows_of = |n: usize| -> Vec<Constraint> {
314 (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
315 };
316 let left_count = visible.len().div_ceil(2);
317 let right_count = visible.len() - left_count;
318 let mut areas: Vec<Rect> = Vec::new();
319 if right_count == 0 {
320 areas.extend(
321 Layout::default()
322 .direction(Direction::Vertical)
323 .constraints(rows_of(left_count))
324 .split(root[1])
325 .iter(),
326 );
327 } else {
328 let body = Layout::default()
329 .direction(Direction::Horizontal)
330 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
331 .split(root[1]);
332 areas.extend(
333 Layout::default()
334 .direction(Direction::Vertical)
335 .constraints(rows_of(left_count))
336 .split(body[0])
337 .iter(),
338 );
339 areas.extend(
340 Layout::default()
341 .direction(Direction::Vertical)
342 .constraints(rows_of(right_count))
343 .split(body[1])
344 .iter(),
345 );
346 }
347
348 for (slot, &i) in visible.iter().enumerate() {
349 let panel = Panel::ALL[i];
350 let focused = app.focus == panel;
351 let shape = app.shapes[i].as_ref();
352 let selected = focused.then(|| app.selection(i));
353 let fresh = app.updated_at[i]
354 .map(|t| t.elapsed() < Duration::from_millis(1200))
355 .unwrap_or(false);
356 draw_panel(
357 f,
358 areas[slot],
359 panel,
360 shape,
361 focused,
362 selected,
363 fresh,
364 &pane_breadcrumb(app, panel),
365 app.spinner(),
366 &app.filters[i],
367 app.filter_entry && focused,
368 &p,
369 );
370 }
371 }
372
373 if app.picker.is_some() {
374 draw_picker(f, root[1], app, &p);
375 }
376 if app.wh_picker.is_some() {
377 draw_wh_picker(f, root[1], app, &p);
378 }
379 if app.problems.is_some() {
380 draw_problems(f, root[1], app, &p);
381 }
382 if app.upcoming.is_some() {
383 draw_upcoming(f, root[1], app, &p);
384 }
385 if app.jump.is_some() {
386 draw_jump(f, root[1], app, &p);
387 }
388 if app.pane_cfg.is_some() {
389 draw_pane_cfg(f, root[1], app, &p);
390 }
391 if app.secret_form.is_some() {
392 draw_secret_form(f, root[1], app, &p);
393 }
394 if app.help {
395 draw_help(f, root[1], app, &p);
396 }
397}
398
399fn draw_secret_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
400 let Some(form) = &app.secret_form else {
401 return;
402 };
403 let width = 60.min(area.width.saturating_sub(4));
404 let height = 6.min(area.height);
405 let popup = Rect {
406 x: area.x + (area.width.saturating_sub(width)) / 2,
407 y: area.y + (area.height.saturating_sub(height)) / 2,
408 width,
409 height,
410 };
411 f.render_widget(Clear, popup);
412 let title = match &form.scope {
413 None => "New secret scope ".to_string(),
414 Some(s) => format!("New secret in {s} "),
415 };
416 let block = Block::default()
417 .title(Line::from(vec![
418 Span::styled(" ◈ ", Style::default().fg(p.warn)),
419 Span::styled(
420 title,
421 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
422 ),
423 ]))
424 .borders(Borders::ALL)
425 .border_type(BorderType::Thick)
426 .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
427 .padding(Padding::new(1, 1, 1, 0));
428 let label0 = if form.scope.is_none() {
429 "name "
430 } else {
431 "key "
432 };
433 let mut lines = vec![Line::from(vec![
434 Span::styled(label0, Style::default().fg(p.dim)),
435 Span::styled(form.key.as_str(), Style::default().fg(p.text)),
436 if form.stage == 0 {
437 Span::styled("▏", Style::default().fg(p.warn))
438 } else {
439 Span::raw("")
440 },
441 ])];
442 if form.scope.is_some() {
443 lines.push(Line::from(vec![
445 Span::styled("value ", Style::default().fg(p.dim)),
446 Span::styled(
447 "•".repeat(form.value.chars().count()),
448 Style::default().fg(p.text),
449 ),
450 if form.stage == 1 {
451 Span::styled("▏", Style::default().fg(p.warn))
452 } else {
453 Span::raw("")
454 },
455 ]));
456 }
457 f.render_widget(Paragraph::new(lines).block(block), popup);
458}
459
460fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
461 let Some(selected) = app.pane_cfg else {
462 return;
463 };
464 let width = 52.min(area.width.saturating_sub(4));
465 let height = 11.min(area.height);
466 let popup = Rect {
467 x: area.x + (area.width.saturating_sub(width)) / 2,
468 y: area.y + (area.height.saturating_sub(height)) / 2,
469 width,
470 height,
471 };
472 f.render_widget(Clear, popup);
473 let block = Block::default()
474 .title(Line::from(vec![
475 Span::styled(" ▦ ", Style::default().fg(p.key)),
476 Span::styled(
477 "Panes ",
478 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
479 ),
480 ]))
481 .borders(Borders::ALL)
482 .border_type(BorderType::Thick)
483 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
484 .padding(Padding::new(1, 1, 1, 0));
485 let items: Vec<ListItem> = app
486 .pane_order
487 .iter()
488 .map(|&i| {
489 let panel = Panel::ALL[i];
490 let (mark, style) = if app.hidden[i] {
491 ("○ ", Style::default().fg(p.dim))
492 } else {
493 ("● ", Style::default().fg(p.ok))
494 };
495 ListItem::new(Line::from(vec![
496 Span::styled(mark, style),
497 Span::styled(
498 format!("{} ", panel.icon()),
499 Style::default().fg(accent(panel, p)),
500 ),
501 Span::styled(
502 panel.title(),
503 if app.hidden[i] {
504 Style::default().fg(p.dim)
505 } else {
506 Style::default().fg(p.text)
507 },
508 ),
509 ]))
510 })
511 .collect();
512 let list = List::new(items)
513 .block(block)
514 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
515 let mut state = ListState::default().with_selected(Some(selected));
516 f.render_stateful_widget(list, popup, &mut state);
517}
518
519fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
520 let width = 74.min(area.width.saturating_sub(4));
521 let height = area.height.saturating_sub(2);
522 let popup = Rect {
523 x: area.x + (area.width.saturating_sub(width)) / 2,
524 y: area.y + 1,
525 width,
526 height,
527 };
528 f.render_widget(Clear, popup);
529 let block = Block::default()
530 .title(Line::from(vec![
531 Span::styled(" ? ", Style::default().fg(p.key)),
532 Span::styled(
533 "Keys ",
534 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
535 ),
536 ]))
537 .borders(Borders::ALL)
538 .border_type(BorderType::Thick)
539 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
540 .padding(Padding::new(1, 1, 1, 0));
541
542 let sections: &[(&str, &[(&str, &str)])] = &[
543 (
544 "Global",
545 &[
546 ("tab / l, shift+tab / h", "focus next / previous pane"),
547 ("j / k", "select item"),
548 ("enter", "open details / drill down"),
549 ("/", "filter the focused pane (esc clears)"),
550 ("ctrl+p", "command palette: fuzzy-jump anywhere"),
551 ("!", "problems: everything failing, enter jumps"),
552 ("u", "upcoming runs: what fires next, soonest first"),
553 (":", "SQL console (prefilled on a catalog table)"),
554 ("$", "cost view: DBUs, dollars, top spenders"),
555 ("H", "arrange panes: hide and reorder"),
556 ("z", "zoom the focused pane"),
557 ("w", "switch workspace profile"),
558 ("t", "cycle color theme"),
559 ("r", "refresh now"),
560 ("?", "this help"),
561 ("q / ctrl+c", "quit"),
562 ],
563 ),
564 (
565 "Focused pane",
566 &[
567 ("s", "start / stop / run the selected item"),
568 ("g", "access: grants and permissions"),
569 ("o", "open in the workspace web UI"),
570 ],
571 ),
572 (
573 "Unity Catalog pane",
574 &[
575 ("enter / backspace", "drill down / up (into volumes too)"),
576 ("p / P", "preview table rows (P picks the warehouse)"),
577 ("L", "lineage: upstream and downstream tables"),
578 (":", "query the selected table"),
579 ("enter on a file", "peek at its first 200 lines"),
580 ],
581 ),
582 (
583 "Details & runs",
584 &[
585 ("enter", "job/pipeline detail → latest run/update"),
586 ("h / l", "older / newer run"),
587 ("o", "full task output — keeps tailing while live"),
588 ("t", "timeline: per-task Gantt of the run"),
589 ("d", "dag: task dependency tree of the run"),
590 ("r", "repair a failed run — reruns only failed tasks"),
591 ("s", "cancel the shown run"),
592 ("j / k, J", "scroll · toggle raw JSON"),
593 ],
594 ),
595 (
596 "Table previews",
597 &[
598 ("j / k", "scroll rows"),
599 ("← / →", "page columns (wide tables)"),
600 ("/", "filter columns by name"),
601 ("v / enter", "record view: one row, fields stacked"),
602 ("e", "export rows to CSV"),
603 ],
604 ),
605 (
606 "Secret scopes pane",
607 &[
608 ("enter / backspace", "open a scope / back to scopes"),
609 ("a", "create a scope / add a secret (value masked)"),
610 ("x", "delete the selected scope or secret"),
611 ("g", "scope ACLs"),
612 ],
613 ),
614 (
615 "SQL console",
616 &[
617 ("enter", "run the statement"),
618 ("tab", "complete catalog/schema/table/column names"),
619 ("↑ / ↓, ctrl+r", "history · incremental search"),
620 ("ctrl+x", "compose in $EDITOR"),
621 ("ctrl+s", "export results to CSV"),
622 ("pgup / pgdn", "scroll results"),
623 ("shift+← / →", "page result columns"),
624 ("esc", "cancel a running query, else close"),
625 ],
626 ),
627 ];
628 let mut lines: Vec<Line> = Vec::new();
629 for (title, keys) in sections {
630 lines.push(Line::from(Span::styled(
631 *title,
632 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
633 )));
634 for (k, desc) in *keys {
635 lines.push(Line::from(vec![
636 Span::styled(format!(" {k:<24}"), Style::default().fg(p.warn)),
637 Span::styled(*desc, Style::default().fg(p.text)),
638 ]));
639 }
640 lines.push(Line::default());
641 }
642 let total = lines.len();
643 let par = Paragraph::new(lines)
644 .scroll((app.help_scroll, 0))
645 .block(block);
646 f.render_widget(par, popup);
647 scrollbar(
649 f,
650 popup,
651 total,
652 popup.height.saturating_sub(3) as usize,
653 app.help_scroll as usize,
654 p,
655 );
656}
657
658fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
659 let Some(jump) = &app.jump else {
660 return;
661 };
662 let matches = app.jump_matches();
663 let width = 70.min(area.width.saturating_sub(4));
664 let height = (matches.len() as u16 + 5).min(area.height);
665 let popup = Rect {
666 x: area.x + (area.width.saturating_sub(width)) / 2,
667 y: area.y + 2.min(area.height.saturating_sub(height)),
668 width,
669 height,
670 };
671 f.render_widget(Clear, popup);
672 let block = Block::default()
673 .title(Line::from(vec![
674 Span::styled(" ⌕ ", Style::default().fg(p.key)),
675 Span::styled(
676 "Jump to ",
677 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
678 ),
679 ]))
680 .borders(Borders::ALL)
681 .border_type(BorderType::Thick)
682 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
683 .padding(Padding::horizontal(1));
684 let inner = block.inner(popup);
685 f.render_widget(block, popup);
686
687 let parts = Layout::default()
688 .direction(Direction::Vertical)
689 .constraints([Constraint::Length(2), Constraint::Min(0)])
690 .split(inner);
691 f.render_widget(
692 Paragraph::new(Line::from(vec![
693 Span::styled("❯ ", Style::default().fg(p.key)),
694 Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
695 Span::styled("▏", Style::default().fg(p.warn)),
696 ])),
697 parts[0],
698 );
699
700 if matches.is_empty() {
701 f.render_widget(
702 Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
703 parts[1],
704 );
705 return;
706 }
707 let items: Vec<ListItem> = matches
708 .iter()
709 .map(|(panel_idx, name, label)| {
710 let panel = Panel::ALL[*panel_idx];
711 ListItem::new(Line::from(vec![
712 Span::styled(
713 format!("{} ", panel.icon()),
714 Style::default().fg(accent(panel, p)),
715 ),
716 Span::styled(format!("{name} "), Style::default().fg(p.text)),
717 Span::styled(label.clone(), Style::default().fg(p.dim)),
718 ]))
719 })
720 .collect();
721 let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
722 let mut state = ListState::default().with_selected(Some(jump.index));
723 f.render_stateful_widget(list, parts[1], &mut state);
724}
725
726fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
727 let Some(pr) = &app.problems else {
728 return;
729 };
730 let truncate = |s: &str, max: usize| -> String {
731 if s.chars().count() <= max {
732 s.to_string()
733 } else {
734 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
735 format!("{cut}…")
736 }
737 };
738 let name_w = pr
741 .items
742 .iter()
743 .map(|i| i.name.chars().count())
744 .max()
745 .unwrap_or(10)
746 .clamp(10, 40);
747 let note_w = pr
748 .items
749 .iter()
750 .map(|i| i.note.chars().count())
751 .max()
752 .unwrap_or(0);
753 let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
755 let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
756 let note_space = (width as usize).saturating_sub(name_w + 30);
758 let popup = Rect {
759 x: area.x + (area.width.saturating_sub(width)) / 2,
760 y: area.y + (area.height.saturating_sub(height)) / 2,
761 width,
762 height,
763 };
764 f.render_widget(Clear, popup);
765 let block = Block::default()
766 .title(Line::from(vec![
767 Span::styled(" ✗ ", Style::default().fg(p.err)),
768 Span::styled(
769 format!("Problems · {} ", pr.items.len()),
770 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
771 ),
772 ]))
773 .borders(Borders::ALL)
774 .border_type(BorderType::Rounded)
775 .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
776 .padding(Padding::new(1, 1, 1, 1));
777
778 if pr.items.is_empty() {
779 let par = Paragraph::new("✓ all clear — nothing failing right now")
780 .style(Style::default().fg(p.ok))
781 .block(block);
782 f.render_widget(par, popup);
783 return;
784 }
785
786 let items: Vec<ListItem> = pr
787 .items
788 .iter()
789 .map(|problem| {
790 let panel = Panel::ALL[problem.panel];
791 ListItem::new(Line::from(vec![
792 Span::styled("✗ ", Style::default().fg(p.err)),
793 Span::styled(
794 format!(
795 "{:<width$}",
796 truncate(&problem.name, name_w),
797 width = name_w + 2
798 ),
799 Style::default().fg(p.text),
800 ),
801 Span::styled(
802 format!("{} {:<18}", panel.icon(), panel.title()),
803 Style::default().fg(accent(panel, p)),
804 ),
805 Span::styled(
806 truncate(&problem.note, note_space),
807 Style::default().fg(p.dim),
808 ),
809 ]))
810 })
811 .collect();
812 let list = List::new(items)
813 .block(block)
814 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
815 let mut state = ListState::default().with_selected(Some(pr.index));
816 f.render_stateful_widget(list, popup, &mut state);
817}
818
819fn draw_upcoming(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
822 let Some(u) = &app.upcoming else {
823 return;
824 };
825 let label_w = u
826 .items
827 .iter()
828 .map(|i| i.next.label.chars().count())
829 .max()
830 .unwrap_or(8)
831 .clamp(8, 18);
832 let name_w = u
833 .items
834 .iter()
835 .map(|i| i.name.chars().count())
836 .max()
837 .unwrap_or(10)
838 .clamp(10, 40);
839 let desc_w = u
840 .items
841 .iter()
842 .map(|i| i.next.desc.chars().count())
843 .max()
844 .unwrap_or(0);
845 let width = ((label_w + name_w + desc_w + 12) as u16).min(area.width.saturating_sub(4));
846 let height = (u.items.len().max(1) as u16 + 4).min(area.height);
847 let desc_space = (width as usize).saturating_sub(label_w + name_w + 10);
848 let popup = Rect {
849 x: area.x + (area.width.saturating_sub(width)) / 2,
850 y: area.y + (area.height.saturating_sub(height)) / 2,
851 width,
852 height,
853 };
854 f.render_widget(Clear, popup);
855 let block = Block::default()
856 .title(Line::from(vec![
857 Span::styled(" ⏱ ", Style::default().fg(p.key)),
858 Span::styled(
859 format!("Upcoming runs · {} ", u.items.len()),
860 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
861 ),
862 ]))
863 .borders(Borders::ALL)
864 .border_type(BorderType::Rounded)
865 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
866 .padding(Padding::new(1, 1, 1, 1));
867
868 if u.loading {
869 let par = Paragraph::new(format!("{} reading job schedules…", app.spinner()))
870 .style(Style::default().fg(p.warn))
871 .block(block);
872 f.render_widget(par, popup);
873 return;
874 }
875 if u.items.is_empty() {
876 let par = Paragraph::new("no scheduled or triggered jobs — everything runs on demand")
877 .style(Style::default().fg(p.dim))
878 .block(block);
879 f.render_widget(par, popup);
880 return;
881 }
882
883 let truncate = |s: &str, max: usize| -> String {
884 if s.chars().count() <= max {
885 s.to_string()
886 } else {
887 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
888 format!("{cut}…")
889 }
890 };
891 let items: Vec<ListItem> = u
892 .items
893 .iter()
894 .map(|it| {
895 let when_color = if it.next.paused {
896 p.dim
897 } else if it.next.at_ms.is_some() {
898 p.ok
899 } else {
900 p.warn
901 };
902 ListItem::new(Line::from(vec![
903 Span::styled(
904 format!(
905 "{:<width$}",
906 truncate(&it.next.label, label_w),
907 width = label_w + 2
908 ),
909 Style::default().fg(when_color),
910 ),
911 Span::styled(
912 format!("{:<width$}", truncate(&it.name, name_w), width = name_w + 2),
913 Style::default().fg(p.text),
914 ),
915 Span::styled(
916 truncate(&it.next.desc, desc_space),
917 Style::default().fg(p.dim),
918 ),
919 ]))
920 })
921 .collect();
922 let list = List::new(items)
923 .block(block)
924 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
925 let mut state = ListState::default().with_selected(Some(u.index));
926 f.render_stateful_widget(list, popup, &mut state);
927}
928
929fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
930 let Some(picker) = &app.wh_picker else {
931 return;
932 };
933 let warehouses = app.warehouses();
934 let name_w = warehouses
935 .iter()
936 .map(|(n, _, _)| n.chars().count())
937 .max()
938 .unwrap_or(12)
939 .max(12);
940 let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
942 let height = (warehouses.len() as u16 + 4).min(area.height);
943 let popup = Rect {
944 x: area.x + (area.width.saturating_sub(width)) / 2,
945 y: area.y + (area.height.saturating_sub(height)) / 2,
946 width,
947 height,
948 };
949 f.render_widget(Clear, popup);
950 let block = Block::default()
951 .title(Line::from(vec![
952 Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
953 Span::styled(
954 "Run query on ",
955 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
956 ),
957 ]))
958 .borders(Borders::ALL)
959 .border_type(BorderType::Rounded)
960 .border_style(
961 Style::default()
962 .fg(p.warehouses)
963 .add_modifier(Modifier::BOLD),
964 )
965 .padding(Padding::new(1, 1, 1, 1));
966 let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
967 let items: Vec<ListItem> = warehouses
968 .iter()
969 .map(|(name, id, running)| {
970 let dot = if *running { p.ok } else { p.dim };
971 let marker = if current_id == Some(id.as_str()) {
972 "» "
973 } else {
974 " "
975 };
976 let state = if *running { "running" } else { "idle " };
977 ListItem::new(Line::from(vec![
978 Span::styled(marker, Style::default().fg(p.key)),
979 Span::styled("● ", Style::default().fg(dot)),
980 Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
981 Span::styled(
982 format!(" {state}"),
983 Style::default().fg(if *running { p.ok } else { p.dim }),
984 ),
985 Span::styled(format!(" {id}"), Style::default().fg(p.dim)),
986 ]))
987 })
988 .collect();
989 let list = List::new(items)
990 .block(block)
991 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
992 let mut state = ListState::default().with_selected(Some(picker.index));
993 f.render_stateful_widget(list, popup, &mut state);
994}
995
996const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
997const WORDMARK_BOT: &str = "█▄▀ █▀█ █ █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
998
999fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1000 let lines_total = 9u16;
1001 let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
1002 let frame = app.spinner_frame();
1003
1004 let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
1006 for (i, row) in bricks.iter().enumerate() {
1007 let line = Line::from(Span::styled(
1008 *row,
1009 Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
1010 ));
1011 let rect = Rect {
1012 x: area.x,
1013 y: top + i as u16,
1014 width: area.width,
1015 height: 1,
1016 };
1017 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1018 }
1019
1020 let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
1022 for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
1023 let spans: Vec<Span> = text
1024 .chars()
1025 .enumerate()
1026 .map(|(i, c)| {
1027 let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
1028 let style = if dist < 3 {
1029 Style::default()
1030 .fg(Color::White)
1031 .add_modifier(Modifier::BOLD)
1032 } else {
1033 Style::default().fg(p.brand)
1034 };
1035 Span::styled(c.to_string(), style)
1036 })
1037 .collect();
1038 let rect = Rect {
1039 x: area.x,
1040 y: top + row,
1041 width: area.width,
1042 height: 1,
1043 };
1044 f.render_widget(
1045 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
1046 rect,
1047 );
1048 }
1049
1050 let tagline = Line::from(vec![
1051 Span::styled("the ", Style::default().fg(p.dim)),
1052 Span::styled(
1053 "Lakehouse",
1054 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1055 ),
1056 Span::styled(" in your terminal", Style::default().fg(p.dim)),
1057 ]);
1058 let version = Line::from(Span::styled(
1059 format!("v{}", env!("CARGO_PKG_VERSION")),
1060 Style::default().fg(p.dim),
1061 ));
1062 for (offset, line) in [(7u16, tagline), (8u16, version)] {
1063 let rect = Rect {
1064 x: area.x,
1065 y: top + offset,
1066 width: area.width,
1067 height: 1,
1068 };
1069 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1070 }
1071}
1072
1073fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1074 let selected = app.picker.unwrap_or(0);
1075 let width = (app
1076 .profiles
1077 .iter()
1078 .map(|s| s.len())
1079 .max()
1080 .unwrap_or(10)
1081 .max(20) as u16
1082 + 6)
1083 .min(area.width);
1084 let height = (app.profiles.len() as u16 + 2).min(area.height);
1085 let popup = Rect {
1086 x: area.x + (area.width.saturating_sub(width)) / 2,
1087 y: area.y + (area.height.saturating_sub(height)) / 2,
1088 width,
1089 height,
1090 };
1091 f.render_widget(Clear, popup);
1092 let block = Block::default()
1093 .title(Line::from(Span::styled(
1094 " ⌂ Workspace ",
1095 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1096 )))
1097 .borders(Borders::ALL)
1098 .border_type(BorderType::Rounded)
1099 .border_style(Style::default().fg(p.key))
1100 .padding(Padding::horizontal(1));
1101 let items: Vec<ListItem> = app
1102 .profiles
1103 .iter()
1104 .map(|name| {
1105 let current = app.profile.as_deref() == Some(name.as_str());
1106 let marker = if current { "● " } else { " " };
1107 ListItem::new(Line::from(vec![
1108 Span::styled(marker, Style::default().fg(p.ok)),
1109 Span::styled(name.as_str(), Style::default().fg(p.text)),
1110 ]))
1111 })
1112 .collect();
1113 let list = List::new(items)
1114 .block(block)
1115 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1116 let mut state = ListState::default().with_selected(Some(selected));
1117 f.render_stateful_widget(list, popup, &mut state);
1118}
1119
1120fn bucket_color(bucket: &str, p: &Palette) -> Color {
1121 match bucket {
1122 "Jobs" => p.jobs,
1123 "SQL" => p.warehouses,
1124 "All-Purpose" => p.clusters,
1125 "DLT" => p.pipelines,
1126 _ => p.dim,
1127 }
1128}
1129
1130fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1131 let cv = app.cost.as_ref().unwrap();
1132 let scope = match &cv.data {
1133 Some(Ok(d)) if d.scoped => " · this workspace",
1134 Some(Ok(_)) => " · all workspaces",
1135 _ => "",
1136 };
1137 let title = Line::from(vec![
1138 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1139 Span::styled(
1140 format!("Usage · last 14 days{scope} "),
1141 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1142 ),
1143 Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1144 ]);
1145 let block = Block::default()
1146 .title(title)
1147 .borders(Borders::ALL)
1148 .border_type(BorderType::Rounded)
1149 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1150 .padding(Padding::new(1, 1, 1, 1));
1151
1152 match &cv.data {
1153 None => {
1154 let par = Paragraph::new(format!(
1155 "{} querying system.billing.usage — the warehouse may need to start…",
1156 app.spinner()
1157 ))
1158 .style(Style::default().fg(p.warn))
1159 .block(block);
1160 f.render_widget(par, area);
1161 }
1162 Some(Err(e)) => {
1163 let par = Paragraph::new(format!(
1164 "✗ {e}\n\nsystem tables need to be enabled and readable \
1165 (grants on the `system` catalog)"
1166 ))
1167 .style(Style::default().fg(p.err))
1168 .wrap(Wrap { trim: false })
1169 .block(block);
1170 f.render_widget(par, area);
1171 }
1172 Some(Ok(data)) if data.days.is_empty() => {
1173 let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1174 .style(Style::default().fg(p.dim))
1175 .block(block);
1176 f.render_widget(par, area);
1177 }
1178 Some(Ok(data)) => {
1179 let mut lines: Vec<Line> = Vec::new();
1180
1181 let mut legend = vec![Span::raw("")];
1183 for (bucket, total, usd) in &data.buckets {
1184 legend.push(Span::styled(
1185 "■ ",
1186 Style::default().fg(bucket_color(bucket, p)),
1187 ));
1188 let amount = if data.priced {
1189 format!("{bucket} {total:.1} (${usd:.0}) ")
1190 } else {
1191 format!("{bucket} {total:.1} ")
1192 };
1193 legend.push(Span::styled(amount, Style::default().fg(p.text)));
1194 }
1195 let sigma = if data.priced {
1196 format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1197 } else {
1198 format!("Σ {:.1} DBU", data.total)
1199 };
1200 legend.push(Span::styled(
1201 sigma,
1202 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1203 ));
1204 lines.push(Line::from(legend));
1205 if data.priced {
1206 lines.push(Line::from(Span::styled(
1207 " list prices before discounts",
1208 Style::default().fg(p.dim),
1209 )));
1210 }
1211 if !data.scoped {
1212 lines.push(Line::from(Span::styled(
1213 " couldn't resolve this workspace's id — showing the whole account",
1214 Style::default().fg(p.warn),
1215 )));
1216 }
1217 lines.push(Line::default());
1218
1219 let max_day = data
1220 .days
1221 .iter()
1222 .map(|d| d.total)
1223 .fold(0.0_f64, f64::max)
1224 .max(f64::EPSILON);
1225 let inner_w = area.width.saturating_sub(4) as usize;
1226 let bar_w = inner_w.saturating_sub(22).max(10);
1227
1228 for day in &data.days {
1229 let mut spans = vec![Span::styled(
1230 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1232 Style::default().fg(p.dim),
1233 )];
1234 for (bucket, value) in &day.by_bucket {
1235 let chars = ((value / max_day) * bar_w as f64)
1236 .round()
1237 .max(if *value > 0.0 { 1.0 } else { 0.0 })
1238 as usize;
1239 spans.push(Span::styled(
1240 "█".repeat(chars),
1241 Style::default().fg(bucket_color(bucket, p)),
1242 ));
1243 }
1244 let day_label = if data.priced {
1245 format!(" {:.1} · ${:.2}", day.total, day.total_usd)
1246 } else {
1247 format!(" {:.1}", day.total)
1248 };
1249 spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1250 lines.push(Line::from(spans));
1251 }
1252
1253 if !data.spenders.is_empty() {
1254 lines.push(Line::default());
1255 lines.push(Line::from(Span::styled(
1256 "TOP SPENDERS · 14 days",
1257 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1258 )));
1259 let name_w = data
1260 .spenders
1261 .iter()
1262 .map(|s| {
1263 app.resource_name(&s.kind, &s.id)
1264 .unwrap_or_else(|| s.id.clone())
1265 .chars()
1266 .count()
1267 })
1268 .max()
1269 .unwrap_or(0)
1270 .max(8);
1271 for (i, s) in data.spenders.iter().enumerate() {
1272 let name = app
1273 .resource_name(&s.kind, &s.id)
1274 .unwrap_or_else(|| s.id.clone());
1275 let kind_color = match s.kind.as_str() {
1276 "job" => p.jobs,
1277 "cluster" => p.clusters,
1278 "warehouse" => p.warehouses,
1279 _ => p.dim,
1280 };
1281 let amount = if data.priced {
1282 format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1283 } else {
1284 format!("{:>8.1} DBU", s.dbus)
1285 };
1286 lines.push(Line::from(vec![
1287 Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1288 Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1289 Span::styled(
1290 format!("{:<width$}", name, width = name_w + 2),
1291 Style::default().fg(p.text),
1292 ),
1293 Span::styled(amount, Style::default().fg(p.text)),
1294 ]));
1295 }
1296 }
1297
1298 let par = Paragraph::new(lines).block(block);
1299 f.render_widget(par, area);
1300 }
1301 }
1302}
1303
1304fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1305 let console = app.sql.as_ref().unwrap();
1306 let parts = Layout::default()
1307 .direction(Direction::Vertical)
1308 .constraints([Constraint::Length(3), Constraint::Min(0)])
1309 .split(area);
1310
1311 let via = if console.warehouse.is_empty() {
1312 String::new()
1313 } else {
1314 format!("via {} ", console.warehouse)
1315 };
1316 let prompt_block = Block::default()
1317 .title(Line::from(vec![
1318 Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1319 Span::styled(
1320 "SQL Console ",
1321 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1322 ),
1323 Span::styled(via, Style::default().fg(p.dim)),
1324 ]))
1325 .borders(Borders::ALL)
1326 .border_type(BorderType::Thick)
1327 .border_style(
1328 Style::default()
1329 .fg(p.warehouses)
1330 .add_modifier(Modifier::BOLD),
1331 )
1332 .padding(Padding::horizontal(1));
1333 if let Some((query, _)) = &app.hist_search {
1334 let matched = app
1336 .hist_search_current()
1337 .map(|s| s.replace('\n', " "))
1338 .unwrap_or_default();
1339 let mut spans = vec![
1340 Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1341 Span::styled(
1342 query.as_str(),
1343 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1344 ),
1345 Span::styled("`: ", Style::default().fg(p.dim)),
1346 ];
1347 spans.extend(sql_spans(&matched, p));
1348 let prompt = Paragraph::new(Line::from(spans)).block(prompt_block);
1349 f.render_widget(prompt, parts[0]);
1350 } else {
1351 let caret_byte = console
1355 .input
1356 .char_indices()
1357 .nth(console.cursor)
1358 .map(|(i, _)| i)
1359 .unwrap_or(console.input.len());
1360 let (before, after) = console.input.split_at(caret_byte);
1361 let inner_w = parts[0].width.saturating_sub(4) as usize; let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1363 let mut spans = vec![Span::styled("❯ ", Style::default().fg(p.key))];
1366 spans.extend(sql_spans(&before.replace('\n', " "), p));
1367 spans.push(Span::styled("▏", Style::default().fg(p.warn)));
1368 spans.extend(sql_spans(&after.replace('\n', " "), p));
1369 let prompt = Paragraph::new(Line::from(spans))
1370 .scroll((0, hscroll))
1371 .block(prompt_block);
1372 f.render_widget(prompt, parts[0]);
1373 }
1374
1375 let row_info = match &console.data {
1376 Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1377 _ => String::new(),
1378 };
1379 let mut results_title = vec![
1380 Span::styled(" Results ", Style::default().fg(p.text)),
1381 Span::styled(row_info, Style::default().fg(p.dim)),
1382 ];
1383 let avail = parts[1].width.saturating_sub(4) as usize;
1385 let sliced = match &console.data {
1386 Some(Ok(t)) if !t.headers.is_empty() => {
1387 let cols: Vec<usize> = (0..t.headers.len()).collect();
1388 let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
1389 if shown.len() < cols.len() {
1390 let first = console.col.min(cols.len() - 1) + 1;
1391 results_title.push(Span::styled(
1392 format!(
1393 "· cols {}–{} of {} (⇧←/→) ",
1394 first,
1395 first + shown.len() - 1,
1396 cols.len()
1397 ),
1398 Style::default().fg(p.dim),
1399 ));
1400 }
1401 Some((shown, constraints))
1402 }
1403 _ => None,
1404 };
1405 let results_block = Block::default()
1406 .title(Line::from(results_title))
1407 .borders(Borders::ALL)
1408 .border_type(BorderType::Rounded)
1409 .border_style(Style::default().fg(p.border))
1410 .padding(Padding::horizontal(1));
1411
1412 if console.running {
1413 let par = Paragraph::new(format!(
1414 "{} running on {} — the warehouse may need a moment to start…",
1415 app.spinner(),
1416 console.warehouse
1417 ))
1418 .style(Style::default().fg(p.warn))
1419 .block(results_block);
1420 f.render_widget(par, parts[1]);
1421 return;
1422 }
1423 match &console.data {
1424 None => {
1425 let par = Paragraph::new(
1426 "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1427 )
1428 .style(Style::default().fg(p.dim))
1429 .block(results_block);
1430 f.render_widget(par, parts[1]);
1431 }
1432 Some(Err(e)) => {
1433 let par = Paragraph::new(format!("✗ {e}"))
1434 .style(Style::default().fg(p.err))
1435 .wrap(Wrap { trim: false })
1436 .block(results_block);
1437 f.render_widget(par, parts[1]);
1438 }
1439 Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1440 let par = Paragraph::new("✓ statement succeeded — no result set")
1441 .style(Style::default().fg(p.ok))
1442 .block(results_block);
1443 f.render_widget(par, parts[1]);
1444 }
1445 Some(Ok(data)) => {
1446 let (shown, constraints) = sliced.unwrap_or_default();
1447 let header_cells: Vec<Cell> = shown
1448 .iter()
1449 .map(|&i| {
1450 Cell::from(data.headers[i].as_str()).style(
1451 Style::default()
1452 .fg(p.warehouses)
1453 .add_modifier(Modifier::BOLD),
1454 )
1455 })
1456 .collect();
1457 let header = Row::new(header_cells);
1458 let rows: Vec<Row> = data
1459 .rows
1460 .iter()
1461 .skip(console.scroll)
1462 .map(|r| {
1463 Row::new(
1464 shown
1465 .iter()
1466 .map(|&i| {
1467 Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
1468 .style(Style::default().fg(p.text))
1469 })
1470 .collect::<Vec<_>>(),
1471 )
1472 })
1473 .collect();
1474 let table = Table::new(rows, constraints)
1475 .header(header)
1476 .column_spacing(1)
1477 .block(results_block);
1478 f.render_widget(table, parts[1]);
1479 scrollbar(
1481 f,
1482 parts[1],
1483 data.rows.len(),
1484 parts[1].height.saturating_sub(3) as usize,
1485 console.scroll,
1486 p,
1487 );
1488 }
1489 }
1490}
1491
1492fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1494 let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
1495 return;
1496 };
1497 let inner_w = area.width.saturating_sub(4) as usize;
1499 let hscroll = (console.cursor + 3).saturating_sub(inner_w);
1500 let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
1501
1502 let lines: Vec<Line> = if comp.loading {
1503 vec![Line::from(Span::styled(
1504 format!("{} fetching names…", app.spinner()),
1505 Style::default().fg(p.warn),
1506 ))]
1507 } else {
1508 let visible = 8.min(comp.items.len());
1510 let skip = comp.index.saturating_sub(visible.saturating_sub(1));
1511 comp.items
1512 .iter()
1513 .enumerate()
1514 .skip(skip)
1515 .take(visible)
1516 .map(|(i, item)| {
1517 let style = if i == comp.index {
1518 Style::default()
1519 .fg(p.key)
1520 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1521 } else {
1522 Style::default().fg(p.text)
1523 };
1524 Line::from(Span::styled(format!(" {item} "), style))
1525 })
1526 .collect()
1527 };
1528 let widest = comp
1529 .items
1530 .iter()
1531 .map(|i| i.chars().count())
1532 .max()
1533 .unwrap_or(16);
1534 let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
1535 let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
1536 let popup = Rect {
1537 x: anchor.min(area.x + area.width.saturating_sub(width)),
1538 y: area.y + 3,
1539 width,
1540 height,
1541 };
1542 let title = if comp.loading {
1543 String::new()
1544 } else {
1545 format!(" {}/{} ", comp.index + 1, comp.items.len())
1546 };
1547 let block = Block::default()
1548 .title(Span::styled(title, Style::default().fg(p.dim)))
1549 .borders(Borders::ALL)
1550 .border_type(BorderType::Rounded)
1551 .border_style(Style::default().fg(p.key));
1552 f.render_widget(Clear, popup);
1553 f.render_widget(Paragraph::new(lines).block(block), popup);
1554}
1555
1556fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
1559 cols.iter()
1560 .map(|&i| {
1561 let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
1562 for row in data.rows.iter().take(50) {
1563 if let Some(c) = row.get(i) {
1564 w = w.max(c.chars().count());
1565 }
1566 }
1567 w.clamp(4, 36) as u16
1568 })
1569 .collect()
1570}
1571
1572fn grid_slice(
1577 data: &TableData,
1578 cols: &[usize],
1579 col: usize,
1580 avail: usize,
1581) -> (Vec<usize>, Vec<Constraint>) {
1582 let widths = grid_widths(data, cols);
1583 let col = col.min(cols.len().saturating_sub(1));
1584 let mut take = 0usize;
1585 let mut used = 0usize;
1586 for w in widths.iter().skip(col) {
1587 let next = used + *w as usize + if take > 0 { 1 } else { 0 };
1588 if take > 0 && next > avail {
1589 break;
1590 }
1591 used = next;
1592 take += 1;
1593 }
1594 let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
1595 let constraints: Vec<Constraint> = widths
1596 .iter()
1597 .skip(col)
1598 .take(take)
1599 .map(|&w| Constraint::Length(w))
1600 .collect();
1601 (shown, constraints)
1602}
1603
1604fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1605 let pv = app.preview.as_ref().unwrap();
1606 let acc = p.catalog;
1607 let mut title_spans: Vec<Span<'static>> = vec![
1608 Span::styled(" ◫ ", Style::default().fg(acc)),
1609 Span::styled(
1610 format!("{} · preview ", pv.name),
1611 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1612 ),
1613 Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1614 ];
1615 let make_block = |spans: Vec<Span<'static>>| {
1616 Block::default()
1617 .title(Line::from(spans))
1618 .borders(Borders::ALL)
1619 .border_type(BorderType::Rounded)
1620 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1621 .padding(Padding::horizontal(1))
1622 };
1623
1624 match &pv.data {
1625 None => {
1626 let par = Paragraph::new(format!(
1627 "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1628 app.spinner()
1629 ))
1630 .style(Style::default().fg(p.warn))
1631 .block(make_block(title_spans));
1632 f.render_widget(par, area);
1633 }
1634 Some(Err(e)) => {
1635 let text = format!(
1636 "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1637 pv.warehouse,
1638 pv.warehouse_id,
1639 app.profile.as_deref().unwrap_or("default"),
1640 app.host.as_deref().unwrap_or("unknown"),
1641 );
1642 let par = Paragraph::new(text)
1643 .style(Style::default().fg(p.err))
1644 .wrap(Wrap { trim: false })
1645 .block(make_block(title_spans));
1646 f.render_widget(par, area);
1647 }
1648 Some(Ok(data)) => {
1649 title_spans.push(Span::styled(
1650 format!("· {} rows ", data.rows.len()),
1651 Style::default().fg(p.dim),
1652 ));
1653 let cols = pv.visible_cols();
1654 if !pv.filter.is_empty() {
1655 title_spans.push(Span::styled(
1656 format!("· cols /{} ", pv.filter),
1657 Style::default().fg(p.warn),
1658 ));
1659 }
1660 if cols.is_empty() {
1661 let par = Paragraph::new(format!(
1662 "no columns match /{} — backspace to widen, esc to clear",
1663 pv.filter
1664 ))
1665 .style(Style::default().fg(p.warn))
1666 .block(make_block(title_spans));
1667 f.render_widget(par, area);
1668 return;
1669 }
1670 if pv.record {
1671 let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
1674 title_spans.push(Span::styled(
1675 format!("· row {}/{} ", row_n + 1, data.rows.len()),
1676 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1677 ));
1678 let name_w = cols
1679 .iter()
1680 .map(|&i| data.headers[i].chars().count())
1681 .max()
1682 .unwrap_or(0)
1683 .min(32);
1684 let empty: Vec<String> = Vec::new();
1685 let row = data.rows.get(row_n).unwrap_or(&empty);
1686 let lines: Vec<Line> = cols
1687 .iter()
1688 .map(|&i| {
1689 let value = row.get(i).map(String::as_str).unwrap_or("");
1690 let vstyle = if value == "␀" {
1691 Style::default().fg(p.dim)
1692 } else {
1693 Style::default().fg(p.text)
1694 };
1695 Line::from(vec![
1696 Span::styled(
1697 format!("{:<name_w$} ", data.headers[i]),
1698 Style::default().fg(acc),
1699 ),
1700 Span::styled(value.to_string(), vstyle),
1701 ])
1702 })
1703 .collect();
1704 let total = lines.len();
1705 let par = Paragraph::new(lines)
1706 .scroll((pv.rscroll, 0))
1707 .block(make_block(title_spans));
1708 f.render_widget(par, area);
1709 scrollbar(
1710 f,
1711 area,
1712 total,
1713 area.height.saturating_sub(2) as usize,
1714 pv.rscroll as usize,
1715 p,
1716 );
1717 return;
1718 }
1719 let avail = area.width.saturating_sub(4) as usize; let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
1721 if shown.len() < cols.len() {
1722 let first = pv.col.min(cols.len() - 1) + 1;
1723 title_spans.push(Span::styled(
1724 format!(
1725 "· cols {}–{} of {} ",
1726 first,
1727 first + shown.len() - 1,
1728 cols.len()
1729 ),
1730 Style::default().fg(p.dim),
1731 ));
1732 }
1733 let header = Row::new(
1734 shown
1735 .iter()
1736 .map(|&i| {
1737 Cell::from(data.headers[i].clone())
1738 .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1739 })
1740 .collect::<Vec<_>>(),
1741 );
1742 let rows: Vec<Row> = data
1743 .rows
1744 .iter()
1745 .skip(pv.scroll)
1746 .map(|r| {
1747 Row::new(
1748 shown
1749 .iter()
1750 .map(|&i| {
1751 Cell::from(r.get(i).cloned().unwrap_or_default())
1752 .style(Style::default().fg(p.text))
1753 })
1754 .collect::<Vec<_>>(),
1755 )
1756 })
1757 .collect();
1758 let table = Table::new(rows, constraints)
1759 .header(header)
1760 .column_spacing(1)
1761 .block(make_block(title_spans));
1762 f.render_widget(table, area);
1763 scrollbar(
1765 f,
1766 area,
1767 data.rows.len(),
1768 area.height.saturating_sub(3) as usize,
1769 pv.scroll,
1770 p,
1771 );
1772 }
1773 }
1774}
1775
1776fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1777 let d = app.detail.as_ref().unwrap();
1778 let acc = accent(d.panel, p);
1779 let title = Line::from(vec![
1780 Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1781 Span::styled(
1782 format!("{} · {} ", d.panel.title(), d.name),
1783 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1784 ),
1785 ]);
1786 let block = Block::default()
1787 .title(title)
1788 .borders(Borders::ALL)
1789 .border_type(BorderType::Rounded)
1790 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1791 .padding(Padding::horizontal(1));
1792
1793 let Some(data) = &d.data else {
1794 let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1795 .style(Style::default().fg(p.warn))
1796 .block(block);
1797 f.render_widget(par, area);
1798 return;
1799 };
1800
1801 if d.show_raw || data.summary.is_empty() {
1803 let par = Paragraph::new(data.raw.as_str())
1804 .style(Style::default().fg(p.text))
1805 .wrap(Wrap { trim: false })
1806 .scroll((d.scroll, 0))
1807 .block(block);
1808 f.render_widget(par, area);
1809 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
1810 scrollbar(
1811 f,
1812 area,
1813 total,
1814 area.height.saturating_sub(2) as usize,
1815 d.scroll as usize,
1816 p,
1817 );
1818 return;
1819 }
1820
1821 let mut lines: Vec<Line> = data
1822 .summary
1823 .iter()
1824 .map(|(k, v)| {
1825 Line::from(vec![
1826 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1827 Span::styled(v.as_str(), Style::default().fg(p.text)),
1828 ])
1829 })
1830 .collect();
1831 if !data.activity.is_empty() {
1832 lines.push(Line::default());
1833 lines.push(Line::from(Span::styled(
1834 d.section,
1835 Style::default().fg(acc).add_modifier(Modifier::BOLD),
1836 )));
1837 for (status, text) in &data.activity {
1838 let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
1840 if treeish {
1841 lines.push(Line::from(Span::styled(
1842 text.as_str(),
1843 Style::default().fg(status_color(status, p)),
1844 )));
1845 } else {
1846 lines.push(Line::from(vec![
1847 Span::styled("● ", Style::default().fg(status_color(status, p))),
1848 Span::styled(text.as_str(), Style::default().fg(p.text)),
1849 ]));
1850 }
1851 }
1852 }
1853 let total = lines.len();
1854 let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1855 f.render_widget(par, area);
1856 scrollbar(
1857 f,
1858 area,
1859 total,
1860 area.height.saturating_sub(2) as usize,
1861 d.scroll as usize,
1862 p,
1863 );
1864}
1865
1866fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1867 let rv = app.run_view.as_ref().unwrap();
1868 let acc = accent(rv.panel, p);
1869 let noun = if rv.panel == Panel::Jobs {
1870 "run"
1871 } else {
1872 "update"
1873 };
1874 let mut title_spans = vec![
1875 Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1876 Span::styled(
1877 format!("{} · {noun} ", rv.owner_name),
1878 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1879 ),
1880 ];
1881 if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1882 title_spans.push(Span::styled(
1883 format!("{run_id} "),
1884 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1885 ));
1886 title_spans.push(Span::styled(
1887 format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1888 Style::default().fg(p.dim),
1889 ));
1890 }
1891 if rv.live {
1892 title_spans.push(Span::styled(
1893 format!("{} live ", app.spinner()),
1894 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1895 ));
1896 }
1897 if rv.show_output {
1898 title_spans.push(Span::styled(
1899 if rv.live {
1900 "· output (tailing) "
1901 } else {
1902 "· output "
1903 },
1904 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1905 ));
1906 }
1907 if rv.show_timeline {
1908 title_spans.push(Span::styled(
1909 "· timeline ",
1910 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1911 ));
1912 }
1913 if rv.show_dag {
1914 title_spans.push(Span::styled(
1915 "· dag ",
1916 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1917 ));
1918 }
1919 let block = Block::default()
1920 .title(Line::from(title_spans))
1921 .borders(Borders::ALL)
1922 .border_type(BorderType::Rounded)
1923 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1924 .padding(Padding::horizontal(1));
1925
1926 if rv.show_output {
1928 let par = match &rv.output {
1929 Some(text) => {
1930 let lines: Vec<Line> = text.lines().map(|l| output_line(l, p)).collect();
1931 Paragraph::new(lines)
1932 .wrap(Wrap { trim: false })
1933 .scroll((rv.scroll, 0))
1934 .block(block)
1935 }
1936 None => Paragraph::new(format!(
1937 "{} fetching task output — one call per task…",
1938 app.spinner()
1939 ))
1940 .style(Style::default().fg(p.warn))
1941 .block(block),
1942 };
1943 f.render_widget(par, area);
1944 if let Some(text) = &rv.output {
1945 let total = wrapped_height(text, area.width.saturating_sub(4) as usize);
1946 scrollbar(
1947 f,
1948 area,
1949 total,
1950 area.height.saturating_sub(2) as usize,
1951 rv.scroll as usize,
1952 p,
1953 );
1954 }
1955 return;
1956 }
1957
1958 let Some(data) = &rv.data else {
1959 let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
1960 .style(Style::default().fg(p.warn))
1961 .block(block);
1962 f.render_widget(par, area);
1963 return;
1964 };
1965
1966 if rv.show_timeline {
1967 draw_run_timeline(f, area, rv.scroll, data, block, p);
1968 return;
1969 }
1970
1971 if rv.show_dag {
1972 draw_run_dag(f, area, rv.scroll, data, block, p);
1973 return;
1974 }
1975
1976 if rv.show_raw || data.summary.is_empty() {
1977 let par = Paragraph::new(data.raw.as_str())
1978 .style(Style::default().fg(p.text))
1979 .wrap(Wrap { trim: false })
1980 .scroll((rv.scroll, 0))
1981 .block(block);
1982 f.render_widget(par, area);
1983 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
1984 scrollbar(
1985 f,
1986 area,
1987 total,
1988 area.height.saturating_sub(2) as usize,
1989 rv.scroll as usize,
1990 p,
1991 );
1992 return;
1993 }
1994
1995 let mut lines: Vec<Line> = data
1996 .summary
1997 .iter()
1998 .map(|(k, v)| {
1999 Line::from(vec![
2000 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2001 Span::styled(v.as_str(), Style::default().fg(p.text)),
2002 ])
2003 })
2004 .collect();
2005 if !data.activity.is_empty() {
2006 lines.push(Line::default());
2007 lines.push(Line::from(Span::styled(
2008 if rv.panel == Panel::Jobs {
2009 "Tasks"
2010 } else {
2011 "Event log"
2012 },
2013 Style::default().fg(acc).add_modifier(Modifier::BOLD),
2014 )));
2015 for (status, text) in &data.activity {
2016 if text.starts_with(" ↳") {
2018 lines.push(Line::from(Span::styled(
2019 text.as_str(),
2020 Style::default().fg(p.err),
2021 )));
2022 } else {
2023 lines.push(Line::from(vec![
2024 Span::styled("● ", Style::default().fg(status_color(status, p))),
2025 Span::styled(text.as_str(), Style::default().fg(p.text)),
2026 ]));
2027 }
2028 }
2029 }
2030 let total = lines.len();
2031 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2032 f.render_widget(par, area);
2033 scrollbar(
2034 f,
2035 area,
2036 total,
2037 area.height.saturating_sub(2) as usize,
2038 rv.scroll as usize,
2039 p,
2040 );
2041}
2042
2043fn draw_run_timeline(
2046 f: &mut Frame,
2047 area: Rect,
2048 scroll: u16,
2049 data: &DetailData,
2050 block: Block,
2051 p: &Palette,
2052) {
2053 let tasks = crate::fetchers::runs::timeline(&data.raw);
2054 let now = std::time::SystemTime::now()
2055 .duration_since(std::time::UNIX_EPOCH)
2056 .map(|d| d.as_millis() as u64)
2057 .unwrap_or(0);
2058 let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
2059 let Some(t0) = t0 else {
2060 let par = Paragraph::new(
2061 "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
2062 )
2063 .style(Style::default().fg(p.dim))
2064 .wrap(Wrap { trim: false })
2065 .block(block);
2066 f.render_widget(par, area);
2067 return;
2068 };
2069 let t1 = tasks
2070 .iter()
2071 .filter(|t| t.start > 0)
2072 .map(|t| t.end.unwrap_or(now).max(t.start))
2073 .max()
2074 .unwrap_or(t0);
2075 let span = (t1 - t0).max(1);
2076
2077 let name_w = tasks
2078 .iter()
2079 .map(|t| t.key.chars().count())
2080 .max()
2081 .unwrap_or(4)
2082 .clamp(4, 24);
2083 let inner_w = area.width.saturating_sub(4) as usize;
2084 let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
2086
2087 let mut lines: Vec<Line> = vec![
2088 Line::from(Span::styled(
2089 format!(
2090 "◷ {} task{} · span {}",
2091 tasks.len(),
2092 if tasks.len() == 1 { "" } else { "s" },
2093 crate::shape::fmt_duration_ms(span)
2094 ),
2095 Style::default().fg(p.dim),
2096 )),
2097 Line::default(),
2098 ];
2099 for t in &tasks {
2100 let mut key: String = t.key.chars().take(name_w).collect();
2101 if key.len() < t.key.len() {
2102 key.pop();
2103 key.push('…');
2104 }
2105 let name = Span::styled(format!("{key:<name_w$} "), Style::default().fg(p.text));
2106 if t.start == 0 {
2107 lines.push(Line::from(vec![
2108 name,
2109 Span::styled(
2110 format!("– {}", t.status.label().to_lowercase()),
2111 Style::default().fg(p.dim),
2112 ),
2113 ]));
2114 continue;
2115 }
2116 let end = t.end.unwrap_or(now).max(t.start);
2117 let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
2118 let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
2119 let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
2120 let dur = format!(
2121 " {}{}",
2122 crate::shape::fmt_duration_ms(end - t.start),
2123 if t.end.is_none() { "…" } else { "" }
2124 );
2125 lines.push(Line::from(vec![
2126 name,
2127 Span::styled(" ".repeat(off), Style::default()),
2128 Span::styled(
2129 "█".repeat(len),
2130 Style::default().fg(status_color(&t.status, p)),
2131 ),
2132 Span::styled(dur, Style::default().fg(p.dim)),
2133 ]));
2134 }
2135 let total = lines.len();
2136 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2137 f.render_widget(par, area);
2138 scrollbar(
2139 f,
2140 area,
2141 total,
2142 area.height.saturating_sub(2) as usize,
2143 scroll as usize,
2144 p,
2145 );
2146}
2147
2148fn draw_run_dag(
2151 f: &mut Frame,
2152 area: Rect,
2153 scroll: u16,
2154 data: &DetailData,
2155 block: Block,
2156 p: &Palette,
2157) {
2158 let rows = crate::fetchers::runs::dag(&data.raw);
2159 if rows.is_empty() {
2160 let par = Paragraph::new(
2161 "no task graph recorded — legacy single-task runs carry none (J shows the raw JSON)",
2162 )
2163 .style(Style::default().fg(p.dim))
2164 .wrap(Wrap { trim: false })
2165 .block(block);
2166 f.render_widget(par, area);
2167 return;
2168 }
2169 let mut lines: Vec<Line> = vec![
2170 Line::from(Span::styled(
2171 format!(
2172 "⧉ {} task{} · order of execution, top to bottom",
2173 rows.len(),
2174 if rows.len() == 1 { "" } else { "s" },
2175 ),
2176 Style::default().fg(p.dim),
2177 )),
2178 Line::default(),
2179 ];
2180 for r in &rows {
2181 let mut spans = vec![
2182 Span::styled(r.prefix.clone(), Style::default().fg(p.dim)),
2183 Span::styled("● ", Style::default().fg(status_color(&r.status, p))),
2184 Span::styled(r.key.clone(), Style::default().fg(p.text)),
2185 Span::styled(
2186 format!(" {}", r.status.label().to_lowercase()),
2187 Style::default().fg(status_color(&r.status, p)),
2188 ),
2189 ];
2190 if let Some(d) = r.duration {
2191 spans.push(Span::styled(
2192 format!(" · {}", crate::shape::fmt_duration_ms(d)),
2193 Style::default().fg(p.dim),
2194 ));
2195 }
2196 if !r.also_after.is_empty() {
2197 spans.push(Span::styled(
2198 format!(" (also after {})", r.also_after.join(", ")),
2199 Style::default().fg(p.dim),
2200 ));
2201 }
2202 lines.push(Line::from(spans));
2203 }
2204 let total = lines.len();
2205 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2206 f.render_widget(par, area);
2207 scrollbar(
2208 f,
2209 area,
2210 total,
2211 area.height.saturating_sub(2) as usize,
2212 scroll as usize,
2213 p,
2214 );
2215}
2216
2217fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2218 let block = Block::default()
2219 .borders(Borders::ALL)
2220 .border_type(BorderType::Rounded)
2221 .border_style(Style::default().fg(p.border));
2222 let inner = block.inner(area);
2223 f.render_widget(block, area);
2224
2225 let (from, to) = (p.grad_from, p.grad_to);
2229 let grad = |t: f32| {
2230 Color::Rgb(
2231 (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
2232 (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
2233 (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
2234 )
2235 };
2236 let mut left = vec![
2237 Span::styled(
2238 " ◢",
2239 Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
2240 ),
2241 Span::styled(
2242 "◤ ",
2243 Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
2244 ),
2245 ];
2246 let word: Vec<char> = "DATABRICKS".chars().collect();
2247 let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
2248 for (i, ch) in word.iter().enumerate() {
2249 let t = i as f32 / (word.len() - 1) as f32;
2250 let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
2251 Style::default().fg(p.text).add_modifier(Modifier::BOLD)
2252 } else {
2253 Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
2254 };
2255 left.push(Span::styled(ch.to_string(), style));
2256 }
2257 left.push(Span::raw(" "));
2258 for ch in "LAKEHOUSE".chars() {
2259 left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
2260 }
2261 left.push(Span::styled(
2262 format!("· v{}", env!("CARGO_PKG_VERSION")),
2263 Style::default().fg(p.dim),
2264 ));
2265 if let Some(profile) = &app.profile {
2266 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2267 left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
2268 left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
2269 }
2270 if let Some(Shape::Badge(b)) = &app.user_badge {
2271 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2272 left.push(Span::styled(
2273 format!("{} {}", b.label, b.value),
2274 Style::default().fg(p.key),
2275 ));
2276 }
2277 if app.zoomed {
2278 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2279 left.push(Span::styled(
2280 format!("⛶ {}", app.focus.title()),
2281 Style::default()
2282 .fg(accent(app.focus, p))
2283 .add_modifier(Modifier::BOLD),
2284 ));
2285 }
2286
2287 let (ok, pending, failed, idle) = app.status_counts();
2289 for (count, glyph, color) in [
2290 (ok, "●", p.ok),
2291 (pending, "◐", p.warn),
2292 (failed, "✗", p.err),
2293 (idle, "○", p.dim),
2294 ] {
2295 if count > 0 {
2296 left.push(Span::styled(" ", Style::default()));
2297 left.push(Span::styled(
2298 format!("{glyph} {count}"),
2299 Style::default().fg(color),
2300 ));
2301 }
2302 }
2303 f.render_widget(Paragraph::new(Line::from(left)), inner);
2304
2305 let right = if app.loading {
2306 Line::from(Span::styled(
2307 format!("{} refreshing ", app.spinner()),
2308 Style::default().fg(p.warn),
2309 ))
2310 } else {
2311 Line::from(Span::styled(
2312 format!("updated {}s ago ", app.last_refresh_age().as_secs()),
2313 Style::default().fg(p.dim),
2314 ))
2315 };
2316 f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
2317}
2318
2319fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2320 let key =
2321 |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
2322 let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
2323
2324 if let Some(confirm) = &app.confirm {
2325 let line = Line::from(vec![
2326 Span::styled(
2327 format!(" ⚠ {} ", confirm.message),
2328 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2329 ),
2330 key("y"),
2331 dim(" confirm · any other key cancels"),
2332 ]);
2333 f.render_widget(Paragraph::new(line), area);
2334 return;
2335 }
2336
2337 if app.filter_entry {
2338 let line = Line::from(vec![
2339 Span::styled(
2340 format!(" /{}", app.active_filter()),
2341 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2342 ),
2343 Span::styled("▏", Style::default().fg(p.warn)),
2344 dim(" type to filter "),
2345 key("enter"),
2346 dim(" apply "),
2347 key("esc"),
2348 dim(" clear"),
2349 ]);
2350 f.render_widget(Paragraph::new(line), area);
2351 return;
2352 }
2353
2354 if let Some(pv) = &app.preview {
2355 if pv.filter_entry {
2356 let line = Line::from(vec![
2357 Span::styled(
2358 format!(" cols /{}", pv.filter),
2359 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2360 ),
2361 Span::styled("▏", Style::default().fg(p.warn)),
2362 dim(" type to filter columns "),
2363 key("enter"),
2364 dim(" apply "),
2365 key("esc"),
2366 dim(" clear"),
2367 ]);
2368 f.render_widget(Paragraph::new(line), area);
2369 return;
2370 }
2371 }
2372
2373 let mut spans = if app.secret_form.is_some() {
2374 vec![
2375 dim(" type "),
2376 key("enter"),
2377 dim(" next / save "),
2378 key("esc"),
2379 dim(" cancel"),
2380 ]
2381 } else if app.help {
2382 vec![
2383 dim(" "),
2384 key("j"),
2385 dim("/"),
2386 key("k"),
2387 dim(" scroll any other key closes"),
2388 ]
2389 } else if app.pane_cfg.is_some() {
2390 vec![
2391 dim(" "),
2392 key("j"),
2393 dim("/"),
2394 key("k"),
2395 dim(" select "),
2396 key("space"),
2397 dim(" show/hide "),
2398 key("J"),
2399 dim("/"),
2400 key("K"),
2401 dim(" move "),
2402 key("esc"),
2403 dim(" done"),
2404 ]
2405 } else if app.jump.is_some() {
2406 vec![
2407 dim(" type to search everything "),
2408 key("\u{2191}"),
2409 dim("/"),
2410 key("\u{2193}"),
2411 dim(" select "),
2412 key("enter"),
2413 dim(" jump "),
2414 key("esc"),
2415 dim(" close"),
2416 ]
2417 } else if app.problems.is_some() {
2418 vec![
2419 dim(" "),
2420 key("j"),
2421 dim("/"),
2422 key("k"),
2423 dim(" select "),
2424 key("enter"),
2425 dim(" jump to item "),
2426 key("esc"),
2427 dim(" close"),
2428 ]
2429 } else if app.upcoming.is_some() {
2430 vec![
2431 dim(" "),
2432 key("j"),
2433 dim("/"),
2434 key("k"),
2435 dim(" select "),
2436 key("enter"),
2437 dim(" jump to job "),
2438 key("esc"),
2439 dim(" close"),
2440 ]
2441 } else if app.cost.is_some() {
2442 vec![
2443 dim(" "),
2444 key("esc"),
2445 dim(" back "),
2446 key("q"),
2447 dim(" quit"),
2448 ]
2449 } else if app.wh_picker.is_some() {
2450 vec![
2451 dim(" "),
2452 key("j"),
2453 dim("/"),
2454 key("k"),
2455 dim(" select "),
2456 key("enter"),
2457 dim(" run preview "),
2458 key("esc"),
2459 dim(" cancel"),
2460 ]
2461 } else if app.sql.is_some() {
2462 if app.hist_search.is_some() {
2463 vec![
2464 dim(" type to search history "),
2465 key("^r"),
2466 dim(" older match "),
2467 key("enter"),
2468 dim(" accept "),
2469 key("esc"),
2470 dim(" cancel"),
2471 ]
2472 } else {
2473 vec![
2474 dim(" "),
2475 key("enter"),
2476 dim(" run "),
2477 key("tab"),
2478 dim(" complete "),
2479 key("↑"),
2480 dim("/"),
2481 key("↓"),
2482 dim(" history "),
2483 key("^r"),
2484 dim(" search "),
2485 key("^x"),
2486 dim(" editor "),
2487 key("pgup"),
2488 dim("/"),
2489 key("pgdn"),
2490 dim(" scroll "),
2491 key("⇧←→"),
2492 dim(" cols "),
2493 key("^s"),
2494 dim(" export "),
2495 key("esc"),
2496 dim(if app.sql.as_ref().is_some_and(|c| c.running) {
2497 " cancel query"
2498 } else {
2499 " close"
2500 }),
2501 ]
2502 }
2503 } else if let Some(pv) = &app.preview {
2504 if pv.record {
2505 vec![
2506 dim(" "),
2507 key("j"),
2508 dim("/"),
2509 key("k"),
2510 dim(" fields "),
2511 key("h"),
2512 dim("/"),
2513 key("l"),
2514 dim(" prev/next row "),
2515 key("v"),
2516 dim(" grid "),
2517 key("/"),
2518 dim(" filter cols "),
2519 key("esc"),
2520 dim(" back"),
2521 ]
2522 } else {
2523 vec![
2524 dim(" "),
2525 key("esc"),
2526 dim(" back "),
2527 key("j"),
2528 dim("/"),
2529 key("k"),
2530 dim(" rows "),
2531 key("←"),
2532 dim("/"),
2533 key("→"),
2534 dim(" columns "),
2535 key("v"),
2536 dim(" row view "),
2537 key("/"),
2538 dim(" filter cols "),
2539 key("e"),
2540 dim(" export csv "),
2541 key("q"),
2542 dim(" quit"),
2543 ]
2544 }
2545 } else if app.picker.is_some() {
2546 vec![
2547 dim(" "),
2548 key("j"),
2549 dim("/"),
2550 key("k"),
2551 dim(" select "),
2552 key("enter"),
2553 dim(" switch workspace "),
2554 key("esc"),
2555 dim(" cancel"),
2556 ]
2557 } else if let Some(rv) = &app.run_view {
2558 if rv.show_output {
2559 vec![
2560 dim(" "),
2561 key("esc"),
2562 dim(" back to run "),
2563 key("j"),
2564 dim("/"),
2565 key("k"),
2566 dim(" scroll "),
2567 key("q"),
2568 dim(" quit"),
2569 ]
2570 } else {
2571 let mut spans = vec![
2572 dim(" "),
2573 key("esc"),
2574 dim(" back "),
2575 key("h"),
2576 dim("/"),
2577 key("l"),
2578 dim(" older/newer "),
2579 ];
2580 if rv.panel == Panel::Jobs {
2581 spans.push(key("o"));
2582 spans.push(dim(" output "));
2583 spans.push(key("t"));
2584 spans.push(dim(" timeline "));
2585 spans.push(key("d"));
2586 spans.push(dim(" dag "));
2587 spans.push(key("r"));
2588 spans.push(dim(" repair "));
2589 }
2590 spans.extend([
2591 key("s"),
2592 dim(" cancel "),
2593 key("j"),
2594 dim("/"),
2595 key("k"),
2596 dim(" scroll "),
2597 key("J"),
2598 dim(" raw "),
2599 key("q"),
2600 dim(" quit"),
2601 ]);
2602 spans
2603 }
2604 } else if app.detail.is_some() {
2605 let mut spans = vec![dim(" "), key("esc"), dim(" back ")];
2606 match app.detail.as_ref() {
2607 Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
2608 spans.push(key("enter"));
2609 spans.push(dim(" latest run "));
2610 }
2611 Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
2612 spans.push(key("enter"));
2613 spans.push(dim(" latest update "));
2614 }
2615 _ => {}
2616 }
2617 spans.extend([
2618 key("j"),
2619 dim("/"),
2620 key("k"),
2621 dim(" scroll "),
2622 key("J"),
2623 dim(" raw "),
2624 key("o"),
2625 dim(" open "),
2626 key("q"),
2627 dim(" quit"),
2628 ]);
2629 spans
2630 } else {
2631 let mut spans = vec![
2632 dim(" "),
2633 key("tab"),
2634 dim("/"),
2635 key("h"),
2636 dim("/"),
2637 key("l"),
2638 dim(" switch "),
2639 key("j"),
2640 dim("/"),
2641 key("k"),
2642 dim(" select "),
2643 key("/"),
2644 dim(if app.active_filter().is_empty() {
2645 " filter "
2646 } else {
2647 " filter · esc clears "
2648 }),
2649 ];
2650 let enter_label = match app.focus {
2652 Panel::Secrets if app.secret_scope.is_some() => None,
2653 Panel::Secrets => Some(" open scope "),
2654 Panel::Catalog if app.uc_path.len() < 2 => Some(" open "),
2655 _ => Some(" details "),
2656 };
2657 if let Some(label) = enter_label {
2658 spans.push(key("enter"));
2659 spans.push(dim(label));
2660 }
2661 match app.focus {
2663 Panel::Catalog => {
2664 if !app.uc_path.is_empty() {
2665 spans.push(key("bksp"));
2666 spans.push(dim(" up "));
2667 }
2668 if app.uc_path.len() == 2 {
2669 spans.push(key("p"));
2670 spans.push(dim(" preview "));
2671 spans.push(key("L"));
2672 spans.push(dim(" lineage "));
2673 spans.push(key(":"));
2674 spans.push(dim(" query table "));
2675 }
2676 }
2677 Panel::Dashboards => {}
2678 Panel::Secrets => {
2679 if app.secret_scope.is_some() {
2680 spans.push(key("bksp"));
2681 spans.push(dim(" up "));
2682 }
2683 spans.push(key("a"));
2684 spans.push(dim(if app.secret_scope.is_some() {
2685 " add secret "
2686 } else {
2687 " new scope "
2688 }));
2689 spans.push(key("x"));
2690 spans.push(dim(" delete "));
2691 }
2692 _ => {
2693 spans.push(key("s"));
2694 spans.push(dim(" action "));
2695 }
2696 }
2697 spans.extend([
2698 key("g"),
2699 dim(" access "),
2700 key("$"),
2701 dim(" cost "),
2702 key("!"),
2703 dim(" problems "),
2704 key("u"),
2705 dim(" upcoming "),
2706 ]);
2707 if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
2709 spans.push(key(":"));
2710 spans.push(dim(" sql "));
2711 }
2712 spans.extend([
2713 key("o"),
2714 dim(" open "),
2715 key("z"),
2716 dim(if app.zoomed { " unzoom " } else { " zoom " }),
2717 key("w"),
2718 dim(" workspace "),
2719 key("t"),
2720 dim(" theme "),
2721 key("r"),
2722 dim(" refresh "),
2723 key("?"),
2724 dim(" help "),
2725 key("q"),
2726 dim(" quit"),
2727 ]);
2728 spans
2729 };
2730 if let Some((msg, _)) = &app.flash {
2732 let color = if msg.starts_with('✗') { p.err } else { p.ok };
2733 let mut prefixed = vec![
2734 Span::styled(
2735 format!(" {msg}"),
2736 Style::default().fg(color).add_modifier(Modifier::BOLD),
2737 ),
2738 dim(" · "),
2739 ];
2740 prefixed.append(&mut spans);
2741 spans = prefixed;
2742 }
2743 f.render_widget(Paragraph::new(Line::from(spans)), area);
2744}
2745
2746#[allow(clippy::too_many_arguments)]
2747fn draw_panel(
2748 f: &mut Frame,
2749 area: Rect,
2750 panel: Panel,
2751 shape: Option<&Shape>,
2752 focused: bool,
2753 selected: Option<usize>,
2754 fresh: bool,
2755 breadcrumb: &str,
2756 spinner: &str,
2757 filter: &str,
2758 entering: bool,
2759 p: &Palette,
2760) {
2761 let accent = accent(panel, p);
2762 let dimmed = |s: Style| {
2765 if focused {
2766 s
2767 } else {
2768 s.add_modifier(Modifier::DIM)
2769 }
2770 };
2771 let (border_style, title_style) = if focused {
2772 (
2773 Style::default().fg(accent).add_modifier(Modifier::BOLD),
2774 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2775 )
2776 } else {
2777 (Style::default().fg(p.border), Style::default().fg(p.dim))
2778 };
2779 let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
2780 Some(Shape::List(items)) => Some(
2781 items
2782 .iter()
2783 .filter(|it| crate::shape::item_matches(it, filter))
2784 .collect(),
2785 ),
2786 _ => None,
2787 };
2788 let count = match (&visible, shape) {
2789 (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
2791 format!(" · {}/{}", v.len(), items.len())
2792 }
2793 (Some(v), _) => format!(" · {}", v.len()),
2794 (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
2795 _ => String::new(),
2796 };
2797 let crumb = if !breadcrumb.is_empty() {
2798 format!(" ▸ {breadcrumb}")
2799 } else {
2800 String::new()
2801 };
2802 let title_style = if fresh {
2804 Style::default()
2805 .bg(accent)
2806 .fg(Color::Black)
2807 .add_modifier(Modifier::BOLD)
2808 } else {
2809 title_style
2810 };
2811 let mut title_spans = vec![
2812 Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
2813 Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
2814 ];
2815 if entering || !filter.is_empty() {
2816 title_spans.push(Span::styled(
2817 format!("/{}{} ", filter, if entering { "▏" } else { "" }),
2818 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2819 ));
2820 }
2821 let title = Line::from(title_spans);
2822 let block = Block::default()
2823 .title(title)
2824 .borders(Borders::ALL)
2825 .border_type(if focused {
2826 BorderType::Thick
2827 } else {
2828 BorderType::Rounded
2829 })
2830 .border_style(border_style)
2831 .padding(Padding::horizontal(1));
2832
2833 match shape {
2834 None => {
2835 let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
2836 .style(Style::default().fg(p.warn))
2837 .block(block);
2838 f.render_widget(par, area);
2839 }
2840 Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
2841 let empty = if !items.is_empty() {
2842 format!("∅ nothing matches /{filter}")
2843 } else {
2844 let msg = match panel {
2845 Panel::Clusters => "no compute running",
2846 Panel::Jobs => "no jobs defined",
2847 Panel::Pipelines => "no pipelines",
2848 Panel::Warehouses => "no warehouses",
2849 Panel::Dashboards => "no dashboards yet",
2850 Panel::Catalog => "nothing at this level",
2851 Panel::Secrets => "no secret scopes",
2852 };
2853 format!("∅ {msg}")
2854 };
2855 let par = Paragraph::new(empty)
2856 .style(Style::default().fg(p.dim))
2857 .block(block);
2858 f.render_widget(par, area);
2859 }
2860 Some(Shape::List(_)) => {
2861 let list_items: Vec<ListItem> = visible
2862 .unwrap_or_default()
2863 .iter()
2864 .map(|item| {
2865 let color = status_color(&item.status, p);
2866 let chip = match item.status {
2867 Status::Stopped | Status::Unknown(_) => Span::styled(
2868 format!(" {} ", item.status.label()),
2869 Style::default().fg(color).add_modifier(Modifier::DIM),
2870 ),
2871 _ => Span::styled(
2872 format!(" {} ", item.status.label()),
2873 Style::default()
2874 .bg(color)
2875 .fg(Color::Black)
2876 .add_modifier(Modifier::BOLD),
2877 ),
2878 };
2879 let mut spans = vec![
2880 Span::styled("● ", Style::default().fg(color)),
2881 Span::styled(item.name.as_str(), dimmed(Style::default().fg(p.text))),
2882 Span::raw(" "),
2883 chip,
2884 ];
2885 if !item.history.is_empty() {
2886 spans.push(Span::raw(" "));
2887 for run in &item.history {
2888 spans.push(Span::styled(
2889 history_glyph(run),
2890 Style::default().fg(status_color(run, p)),
2891 ));
2892 }
2893 }
2894 if let Some(detail) = &item.detail {
2895 spans.push(Span::styled(
2896 format!(" {}", detail),
2897 dimmed(Style::default().fg(p.dim)),
2898 ));
2899 }
2900 ListItem::new(Line::from(spans))
2901 })
2902 .collect();
2903 let list = List::new(list_items)
2904 .block(block)
2905 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
2906 match selected {
2907 Some(sel) => {
2908 let mut state = ListState::default().with_selected(Some(sel));
2909 f.render_stateful_widget(list, area, &mut state);
2910 }
2911 None => f.render_widget(list, area),
2912 }
2913 }
2914 Some(Shape::Table(data)) => {
2915 let header_cells: Vec<Cell> = data
2916 .headers
2917 .iter()
2918 .map(|h| {
2919 Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
2920 })
2921 .collect();
2922 let header = Row::new(header_cells).style(Style::default().fg(accent));
2923 let rows: Vec<Row> = data
2924 .rows
2925 .iter()
2926 .map(|r| {
2927 Row::new(
2928 r.iter()
2929 .map(|c| Cell::from(c.as_str()).style(dimmed(Style::default())))
2930 .collect::<Vec<_>>(),
2931 )
2932 })
2933 .collect();
2934 let widths: Vec<Constraint> = data
2935 .headers
2936 .iter()
2937 .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
2938 .collect();
2939 let table = Table::new(rows, widths).header(header).block(block);
2940 f.render_widget(table, area);
2941 }
2942 Some(Shape::Badge(b)) => {
2943 let text = format!("{}: {}", b.label, b.value);
2944 let par = Paragraph::new(text)
2945 .style(dimmed(Style::default().fg(p.text)))
2946 .block(block);
2947 f.render_widget(par, area);
2948 }
2949 Some(Shape::Text(t)) => {
2950 let color = if t.starts_with('✗') { p.err } else { p.text };
2951 let par = Paragraph::new(t.as_str())
2952 .style(dimmed(Style::default().fg(color)))
2953 .wrap(Wrap { trim: false })
2954 .block(block);
2955 f.render_widget(par, area);
2956 }
2957 }
2958}
2959
2960fn scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, pos: usize, p: &Palette) {
2964 if viewport == 0 || total <= viewport {
2965 return;
2966 }
2967 let max = total - viewport;
2968 let mut state = ScrollbarState::new(max).position(pos.min(max));
2969 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
2970 .begin_symbol(None)
2971 .end_symbol(None)
2972 .track_symbol(Some("│"))
2973 .track_style(Style::default().fg(p.border))
2974 .thumb_symbol("┃")
2975 .thumb_style(Style::default().fg(p.text));
2976 f.render_stateful_widget(
2977 sb,
2978 area.inner(Margin {
2979 horizontal: 0,
2980 vertical: 1,
2981 }),
2982 &mut state,
2983 );
2984}
2985
2986fn wrapped_height(text: &str, width: usize) -> usize {
2988 let w = width.max(1);
2989 text.lines()
2990 .map(|l| l.chars().count().div_ceil(w).max(1))
2991 .sum()
2992}
2993
2994const SQL_EXTRA_KEYWORDS: &[&str] = &[
2997 "TABLE", "VIEW", "IF", "EXISTS", "USE", "TRUE", "FALSE", "ASC", "DESC", "INTERVAL",
2998];
2999
3000fn is_sql_keyword(word: &str) -> bool {
3001 let up = word.to_ascii_uppercase();
3002 crate::app::SQL_KEYWORDS
3003 .iter()
3004 .any(|k| k.split_whitespace().any(|w| w == up))
3005 || SQL_EXTRA_KEYWORDS.contains(&up.as_str())
3006}
3007
3008fn sql_spans(sql: &str, p: &Palette) -> Vec<Span<'static>> {
3012 let chars: Vec<char> = sql.chars().collect();
3013 let mut spans = Vec::new();
3014 let mut i = 0;
3015 let push = |spans: &mut Vec<Span<'static>>, text: String, style: Style| {
3016 if !text.is_empty() {
3017 spans.push(Span::styled(text, style));
3018 }
3019 };
3020 while i < chars.len() {
3021 let c = chars[i];
3022 if c == '\'' || c == '`' || c == '"' {
3023 let start = i;
3026 i += 1;
3027 while i < chars.len() && chars[i] != c {
3028 i += 1;
3029 }
3030 if i < chars.len() {
3031 i += 1;
3032 }
3033 let color = if c == '\'' { p.ok } else { p.catalog };
3034 push(
3035 &mut spans,
3036 chars[start..i].iter().collect(),
3037 Style::default().fg(color),
3038 );
3039 } else if c == '-' && chars.get(i + 1) == Some(&'-') {
3040 push(
3041 &mut spans,
3042 chars[i..].iter().collect(),
3043 Style::default().fg(p.dim),
3044 );
3045 break;
3046 } else if c.is_ascii_digit() {
3047 let start = i;
3048 while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
3049 i += 1;
3050 }
3051 push(
3052 &mut spans,
3053 chars[start..i].iter().collect(),
3054 Style::default().fg(p.warn),
3055 );
3056 } else if c.is_alphanumeric() || c == '_' {
3057 let start = i;
3058 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
3059 i += 1;
3060 }
3061 let word: String = chars[start..i].iter().collect();
3062 let style = if is_sql_keyword(&word) {
3063 Style::default().fg(p.key).add_modifier(Modifier::BOLD)
3064 } else {
3065 Style::default().fg(p.text)
3066 };
3067 push(&mut spans, word, style);
3068 } else {
3069 let start = i;
3070 i += 1;
3071 while i < chars.len() {
3072 let c = chars[i];
3073 if c.is_alphanumeric() || matches!(c, '_' | '\'' | '`' | '"' | '-') {
3074 break;
3075 }
3076 i += 1;
3077 }
3078 push(
3079 &mut spans,
3080 chars[start..i].iter().collect(),
3081 Style::default().fg(p.text),
3082 );
3083 }
3084 }
3085 spans
3086}
3087
3088fn output_line<'a>(line: &'a str, p: &Palette) -> Line<'a> {
3092 if let Some(rest) = line.strip_prefix("── ") {
3093 let color = if rest.contains("FAILED") {
3094 p.err
3095 } else if rest.contains("RUNNING") || rest.contains("PENDING") {
3096 p.warn
3097 } else if rest.contains("SUCCESS") {
3098 p.ok
3099 } else {
3100 p.text
3101 };
3102 return Line::from(Span::styled(
3103 line,
3104 Style::default().fg(color).add_modifier(Modifier::BOLD),
3105 ));
3106 }
3107 let trimmed = line.trim_start();
3108 let indented = line.starts_with(char::is_whitespace);
3109 let body = if line.contains("ERROR")
3110 || line.contains("FATAL")
3111 || line.contains("Exception")
3112 || trimmed.starts_with("Traceback")
3113 || trimmed.starts_with("Caused by")
3114 || (indented && (trimmed.starts_with("at ") || trimmed.starts_with("File \"")))
3115 {
3116 Style::default().fg(p.err)
3117 } else if line.contains("WARN") {
3118 Style::default().fg(p.warn)
3119 } else if line == "logs (tail):" || trimmed.starts_with("(no output") {
3120 Style::default().fg(p.dim)
3121 } else {
3122 Style::default().fg(p.text)
3123 };
3124 let ts = timestamp_prefix(line);
3125 if ts > 0 {
3126 Line::from(vec![
3127 Span::styled(&line[..ts], Style::default().fg(p.dim)),
3128 Span::styled(&line[ts..], body),
3129 ])
3130 } else {
3131 Line::from(Span::styled(line, body))
3132 }
3133}
3134
3135fn timestamp_prefix(line: &str) -> usize {
3139 let mut len = 0;
3140 let mut digits = 0;
3141 for (i, c) in line.char_indices() {
3142 if c.is_ascii_digit() {
3143 digits += 1;
3144 } else if !matches!(c, '/' | '-' | ':' | '.' | ',' | 'T' | 'Z' | ' ') {
3145 break;
3146 }
3147 len = i + c.len_utf8();
3148 if len >= 30 {
3149 break;
3150 }
3151 }
3152 if digits >= 6 && len >= 8 {
3153 len
3154 } else {
3155 0
3156 }
3157}
3158
3159fn status_color(status: &Status, p: &Palette) -> Color {
3160 match status {
3161 Status::Running | Status::Success => p.ok,
3162 Status::Stopped => p.dim,
3163 Status::Pending => p.warn,
3164 Status::Failed => p.err,
3165 Status::Unknown(_) => p.text,
3166 }
3167}
3168
3169fn history_glyph(status: &Status) -> &'static str {
3170 match status {
3171 Status::Success => "✓",
3172 Status::Failed => "✗",
3173 Status::Running => "●",
3174 Status::Pending => "◐",
3175 Status::Stopped => "○",
3176 Status::Unknown(_) => "·",
3177 }
3178}
3179
3180#[cfg(test)]
3181mod tests {
3182 use super::*;
3183
3184 fn find<'a>(spans: &'a [Span], text: &str) -> &'a Span<'a> {
3185 spans
3186 .iter()
3187 .find(|s| s.content == text)
3188 .unwrap_or_else(|| panic!("no span {text:?} in {spans:?}"))
3189 }
3190
3191 #[test]
3192 fn sql_spans_color_keywords_strings_numbers_comments() {
3193 let p = palette(ThemeMode::Dark);
3194 let spans = sql_spans("SELECT count(x) FROM t WHERE s = 'bob' LIMIT 10 -- hi", &p);
3195 assert_eq!(find(&spans, "SELECT").style.fg, Some(p.key));
3196 assert!(find(&spans, "SELECT")
3197 .style
3198 .add_modifier
3199 .contains(Modifier::BOLD));
3200 assert_eq!(find(&spans, "count").style.fg, Some(p.key));
3201 assert_eq!(find(&spans, "t").style.fg, Some(p.text));
3202 assert_eq!(find(&spans, "'bob'").style.fg, Some(p.ok));
3203 assert_eq!(find(&spans, "10").style.fg, Some(p.warn));
3204 assert_eq!(find(&spans, "-- hi").style.fg, Some(p.dim));
3205 }
3206
3207 #[test]
3208 fn sql_spans_preserve_every_char() {
3209 let p = palette(ThemeMode::Dark);
3210 for sql in [
3211 "SELECT `col a`, b - 1 FROM main.sales.orders WHERE s = 'unclosed",
3212 "-- only a comment",
3213 " \"quoted id\" <> 3.5e2 ",
3214 ] {
3215 let joined: String = sql_spans(sql, &p)
3216 .iter()
3217 .map(|s| s.content.as_ref())
3218 .collect();
3219 assert_eq!(joined, sql);
3220 }
3221 }
3222}