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: failures in every workspace, 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 prefix_of = |problem: &crate::app::Problem| -> String {
742 problem
743 .profile
744 .as_ref()
745 .map(|pr| format!("{pr} ▸ "))
746 .unwrap_or_default()
747 };
748 let name_w = pr
749 .items
750 .iter()
751 .map(|i| i.name.chars().count() + prefix_of(i).chars().count())
752 .max()
753 .unwrap_or(10)
754 .clamp(10, 40);
755 let note_w = pr
756 .items
757 .iter()
758 .map(|i| i.note.chars().count())
759 .max()
760 .unwrap_or(0);
761 let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
763 let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
764 let note_space = (width as usize).saturating_sub(name_w + 30);
766 let popup = Rect {
767 x: area.x + (area.width.saturating_sub(width)) / 2,
768 y: area.y + (area.height.saturating_sub(height)) / 2,
769 width,
770 height,
771 };
772 f.render_widget(Clear, popup);
773 let mut title_spans = vec![
774 Span::styled(" ✗ ", Style::default().fg(p.err)),
775 Span::styled(
776 format!("Problems · {} ", pr.items.len()),
777 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
778 ),
779 ];
780 if pr.scanning {
781 title_spans.push(Span::styled(
782 format!("· {} scanning workspaces ", app.spinner()),
783 Style::default().fg(p.warn),
784 ));
785 }
786 let block = Block::default()
787 .title(Line::from(title_spans))
788 .borders(Borders::ALL)
789 .border_type(BorderType::Rounded)
790 .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
791 .padding(Padding::new(1, 1, 1, 1));
792
793 if pr.items.is_empty() {
794 let (text, color) = if pr.scanning {
795 (
796 format!(
797 "✓ nothing failing here — {} scanning other workspaces…",
798 app.spinner()
799 ),
800 p.warn,
801 )
802 } else {
803 ("✓ all clear — nothing failing right now".to_string(), p.ok)
804 };
805 let par = Paragraph::new(text)
806 .style(Style::default().fg(color))
807 .block(block);
808 f.render_widget(par, popup);
809 return;
810 }
811
812 let items: Vec<ListItem> = pr
813 .items
814 .iter()
815 .map(|problem| {
816 let prefix = prefix_of(problem);
817 let plen = prefix.chars().count().min(name_w.saturating_sub(4));
818 let mut spans = vec![
819 Span::styled("✗ ", Style::default().fg(p.err)),
820 Span::styled(truncate(&prefix, plen), Style::default().fg(p.dim)),
821 Span::styled(
822 format!(
823 "{:<width$}",
824 truncate(&problem.name, name_w - plen),
825 width = name_w + 2 - plen
826 ),
827 Style::default().fg(p.text),
828 ),
829 ];
830 spans.push(match problem.panel {
831 Some(i) => {
832 let panel = Panel::ALL[i];
833 Span::styled(
834 format!("{} {:<18}", panel.icon(), panel.title()),
835 Style::default().fg(accent(panel, p)),
836 )
837 }
838 None => Span::styled(format!("⌂ {:<18}", "workspace"), Style::default().fg(p.dim)),
839 });
840 spans.push(Span::styled(
841 truncate(&problem.note, note_space),
842 Style::default().fg(p.dim),
843 ));
844 ListItem::new(Line::from(spans))
845 })
846 .collect();
847 let list = List::new(items)
848 .block(block)
849 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
850 let mut state = ListState::default().with_selected(Some(pr.index));
851 f.render_stateful_widget(list, popup, &mut state);
852}
853
854fn draw_upcoming(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
857 let Some(u) = &app.upcoming else {
858 return;
859 };
860 let label_w = u
861 .items
862 .iter()
863 .map(|i| i.next.label.chars().count())
864 .max()
865 .unwrap_or(8)
866 .clamp(8, 18);
867 let name_w = u
868 .items
869 .iter()
870 .map(|i| i.name.chars().count())
871 .max()
872 .unwrap_or(10)
873 .clamp(10, 40);
874 let desc_w = u
875 .items
876 .iter()
877 .map(|i| i.next.desc.chars().count())
878 .max()
879 .unwrap_or(0);
880 let width = ((label_w + name_w + desc_w + 12) as u16).min(area.width.saturating_sub(4));
881 let height = (u.items.len().max(1) as u16 + 4).min(area.height);
882 let desc_space = (width as usize).saturating_sub(label_w + name_w + 10);
883 let popup = Rect {
884 x: area.x + (area.width.saturating_sub(width)) / 2,
885 y: area.y + (area.height.saturating_sub(height)) / 2,
886 width,
887 height,
888 };
889 f.render_widget(Clear, popup);
890 let block = Block::default()
891 .title(Line::from(vec![
892 Span::styled(" ⏱ ", Style::default().fg(p.key)),
893 Span::styled(
894 format!("Upcoming runs · {} ", u.items.len()),
895 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
896 ),
897 ]))
898 .borders(Borders::ALL)
899 .border_type(BorderType::Rounded)
900 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
901 .padding(Padding::new(1, 1, 1, 1));
902
903 if u.loading {
904 let par = Paragraph::new(format!("{} reading job schedules…", app.spinner()))
905 .style(Style::default().fg(p.warn))
906 .block(block);
907 f.render_widget(par, popup);
908 return;
909 }
910 if u.items.is_empty() {
911 let par = Paragraph::new("no scheduled or triggered jobs — everything runs on demand")
912 .style(Style::default().fg(p.dim))
913 .block(block);
914 f.render_widget(par, popup);
915 return;
916 }
917
918 let truncate = |s: &str, max: usize| -> String {
919 if s.chars().count() <= max {
920 s.to_string()
921 } else {
922 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
923 format!("{cut}…")
924 }
925 };
926 let items: Vec<ListItem> = u
927 .items
928 .iter()
929 .map(|it| {
930 let when_color = if it.next.paused {
931 p.dim
932 } else if it.next.at_ms.is_some() {
933 p.ok
934 } else {
935 p.warn
936 };
937 ListItem::new(Line::from(vec![
938 Span::styled(
939 format!(
940 "{:<width$}",
941 truncate(&it.next.label, label_w),
942 width = label_w + 2
943 ),
944 Style::default().fg(when_color),
945 ),
946 Span::styled(
947 format!("{:<width$}", truncate(&it.name, name_w), width = name_w + 2),
948 Style::default().fg(p.text),
949 ),
950 Span::styled(
951 truncate(&it.next.desc, desc_space),
952 Style::default().fg(p.dim),
953 ),
954 ]))
955 })
956 .collect();
957 let list = List::new(items)
958 .block(block)
959 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
960 let mut state = ListState::default().with_selected(Some(u.index));
961 f.render_stateful_widget(list, popup, &mut state);
962}
963
964fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
965 let Some(picker) = &app.wh_picker else {
966 return;
967 };
968 let warehouses = app.warehouses();
969 let name_w = warehouses
970 .iter()
971 .map(|(n, _, _)| n.chars().count())
972 .max()
973 .unwrap_or(12)
974 .max(12);
975 let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
977 let height = (warehouses.len() as u16 + 4).min(area.height);
978 let popup = Rect {
979 x: area.x + (area.width.saturating_sub(width)) / 2,
980 y: area.y + (area.height.saturating_sub(height)) / 2,
981 width,
982 height,
983 };
984 f.render_widget(Clear, popup);
985 let block = Block::default()
986 .title(Line::from(vec![
987 Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
988 Span::styled(
989 "Run query on ",
990 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
991 ),
992 ]))
993 .borders(Borders::ALL)
994 .border_type(BorderType::Rounded)
995 .border_style(
996 Style::default()
997 .fg(p.warehouses)
998 .add_modifier(Modifier::BOLD),
999 )
1000 .padding(Padding::new(1, 1, 1, 1));
1001 let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
1002 let items: Vec<ListItem> = warehouses
1003 .iter()
1004 .map(|(name, id, running)| {
1005 let dot = if *running { p.ok } else { p.dim };
1006 let marker = if current_id == Some(id.as_str()) {
1007 "» "
1008 } else {
1009 " "
1010 };
1011 let state = if *running { "running" } else { "idle " };
1012 ListItem::new(Line::from(vec![
1013 Span::styled(marker, Style::default().fg(p.key)),
1014 Span::styled("● ", Style::default().fg(dot)),
1015 Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
1016 Span::styled(
1017 format!(" {state}"),
1018 Style::default().fg(if *running { p.ok } else { p.dim }),
1019 ),
1020 Span::styled(format!(" {id}"), Style::default().fg(p.dim)),
1021 ]))
1022 })
1023 .collect();
1024 let list = List::new(items)
1025 .block(block)
1026 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1027 let mut state = ListState::default().with_selected(Some(picker.index));
1028 f.render_stateful_widget(list, popup, &mut state);
1029}
1030
1031const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
1032const WORDMARK_BOT: &str = "█▄▀ █▀█ █ █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
1033
1034fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1035 let lines_total = 9u16;
1036 let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
1037 let frame = app.spinner_frame();
1038
1039 let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
1041 for (i, row) in bricks.iter().enumerate() {
1042 let line = Line::from(Span::styled(
1043 *row,
1044 Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
1045 ));
1046 let rect = Rect {
1047 x: area.x,
1048 y: top + i as u16,
1049 width: area.width,
1050 height: 1,
1051 };
1052 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1053 }
1054
1055 let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
1057 for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
1058 let spans: Vec<Span> = text
1059 .chars()
1060 .enumerate()
1061 .map(|(i, c)| {
1062 let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
1063 let style = if dist < 3 {
1064 Style::default()
1065 .fg(Color::White)
1066 .add_modifier(Modifier::BOLD)
1067 } else {
1068 Style::default().fg(p.brand)
1069 };
1070 Span::styled(c.to_string(), style)
1071 })
1072 .collect();
1073 let rect = Rect {
1074 x: area.x,
1075 y: top + row,
1076 width: area.width,
1077 height: 1,
1078 };
1079 f.render_widget(
1080 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
1081 rect,
1082 );
1083 }
1084
1085 let tagline = Line::from(vec![
1086 Span::styled("the ", Style::default().fg(p.dim)),
1087 Span::styled(
1088 "Lakehouse",
1089 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1090 ),
1091 Span::styled(" in your terminal", Style::default().fg(p.dim)),
1092 ]);
1093 let version = Line::from(Span::styled(
1094 format!("v{}", env!("CARGO_PKG_VERSION")),
1095 Style::default().fg(p.dim),
1096 ));
1097 for (offset, line) in [(7u16, tagline), (8u16, version)] {
1098 let rect = Rect {
1099 x: area.x,
1100 y: top + offset,
1101 width: area.width,
1102 height: 1,
1103 };
1104 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1105 }
1106}
1107
1108fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1109 let selected = app.picker.unwrap_or(0);
1110 let width = (app
1111 .profiles
1112 .iter()
1113 .map(|s| s.len())
1114 .max()
1115 .unwrap_or(10)
1116 .max(20) as u16
1117 + 6)
1118 .min(area.width);
1119 let height = (app.profiles.len() as u16 + 2).min(area.height);
1120 let popup = Rect {
1121 x: area.x + (area.width.saturating_sub(width)) / 2,
1122 y: area.y + (area.height.saturating_sub(height)) / 2,
1123 width,
1124 height,
1125 };
1126 f.render_widget(Clear, popup);
1127 let block = Block::default()
1128 .title(Line::from(Span::styled(
1129 " ⌂ Workspace ",
1130 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1131 )))
1132 .borders(Borders::ALL)
1133 .border_type(BorderType::Rounded)
1134 .border_style(Style::default().fg(p.key))
1135 .padding(Padding::horizontal(1));
1136 let items: Vec<ListItem> = app
1137 .profiles
1138 .iter()
1139 .map(|name| {
1140 let current = app.profile.as_deref() == Some(name.as_str());
1141 let marker = if current { "● " } else { " " };
1142 ListItem::new(Line::from(vec![
1143 Span::styled(marker, Style::default().fg(p.ok)),
1144 Span::styled(name.as_str(), Style::default().fg(p.text)),
1145 ]))
1146 })
1147 .collect();
1148 let list = List::new(items)
1149 .block(block)
1150 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1151 let mut state = ListState::default().with_selected(Some(selected));
1152 f.render_stateful_widget(list, popup, &mut state);
1153}
1154
1155fn bucket_color(bucket: &str, p: &Palette) -> Color {
1156 match bucket {
1157 "Jobs" => p.jobs,
1158 "SQL" => p.warehouses,
1159 "All-Purpose" => p.clusters,
1160 "DLT" => p.pipelines,
1161 _ => p.dim,
1162 }
1163}
1164
1165fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1166 let cv = app.cost.as_ref().unwrap();
1167 let scope = match &cv.data {
1168 Some(Ok(d)) if d.scoped => " · this workspace",
1169 Some(Ok(_)) => " · all workspaces",
1170 _ => "",
1171 };
1172 let title = Line::from(vec![
1173 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1174 Span::styled(
1175 format!("Usage · last 14 days{scope} "),
1176 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1177 ),
1178 Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1179 ]);
1180 let block = Block::default()
1181 .title(title)
1182 .borders(Borders::ALL)
1183 .border_type(BorderType::Rounded)
1184 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1185 .padding(Padding::new(1, 1, 1, 1));
1186
1187 match &cv.data {
1188 None => {
1189 let par = Paragraph::new(format!(
1190 "{} querying system.billing.usage — the warehouse may need to start…",
1191 app.spinner()
1192 ))
1193 .style(Style::default().fg(p.warn))
1194 .block(block);
1195 f.render_widget(par, area);
1196 }
1197 Some(Err(e)) => {
1198 let par = Paragraph::new(format!(
1199 "✗ {e}\n\nsystem tables need to be enabled and readable \
1200 (grants on the `system` catalog)"
1201 ))
1202 .style(Style::default().fg(p.err))
1203 .wrap(Wrap { trim: false })
1204 .block(block);
1205 f.render_widget(par, area);
1206 }
1207 Some(Ok(data)) if data.days.is_empty() => {
1208 let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1209 .style(Style::default().fg(p.dim))
1210 .block(block);
1211 f.render_widget(par, area);
1212 }
1213 Some(Ok(data)) => {
1214 let mut lines: Vec<Line> = Vec::new();
1215
1216 let mut legend = vec![Span::raw("")];
1218 for (bucket, total, usd) in &data.buckets {
1219 legend.push(Span::styled(
1220 "■ ",
1221 Style::default().fg(bucket_color(bucket, p)),
1222 ));
1223 let amount = if data.priced {
1224 format!("{bucket} {total:.1} (${usd:.0}) ")
1225 } else {
1226 format!("{bucket} {total:.1} ")
1227 };
1228 legend.push(Span::styled(amount, Style::default().fg(p.text)));
1229 }
1230 let sigma = if data.priced {
1231 format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1232 } else {
1233 format!("Σ {:.1} DBU", data.total)
1234 };
1235 legend.push(Span::styled(
1236 sigma,
1237 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1238 ));
1239 lines.push(Line::from(legend));
1240 if data.priced {
1241 lines.push(Line::from(Span::styled(
1242 " list prices before discounts",
1243 Style::default().fg(p.dim),
1244 )));
1245 }
1246 if !data.scoped {
1247 lines.push(Line::from(Span::styled(
1248 " couldn't resolve this workspace's id — showing the whole account",
1249 Style::default().fg(p.warn),
1250 )));
1251 }
1252 lines.push(Line::default());
1253
1254 let max_day = data
1255 .days
1256 .iter()
1257 .map(|d| d.total)
1258 .fold(0.0_f64, f64::max)
1259 .max(f64::EPSILON);
1260 let inner_w = area.width.saturating_sub(4) as usize;
1261 let bar_w = inner_w.saturating_sub(22).max(10);
1262
1263 for day in &data.days {
1264 let mut spans = vec![Span::styled(
1265 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1267 Style::default().fg(p.dim),
1268 )];
1269 for (bucket, value) in &day.by_bucket {
1270 let chars = ((value / max_day) * bar_w as f64)
1271 .round()
1272 .max(if *value > 0.0 { 1.0 } else { 0.0 })
1273 as usize;
1274 spans.push(Span::styled(
1275 "█".repeat(chars),
1276 Style::default().fg(bucket_color(bucket, p)),
1277 ));
1278 }
1279 let day_label = if data.priced {
1280 format!(" {:.1} · ${:.2}", day.total, day.total_usd)
1281 } else {
1282 format!(" {:.1}", day.total)
1283 };
1284 spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1285 lines.push(Line::from(spans));
1286 }
1287
1288 if !data.spenders.is_empty() {
1289 lines.push(Line::default());
1290 lines.push(Line::from(Span::styled(
1291 "TOP SPENDERS · 14 days",
1292 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1293 )));
1294 let name_w = data
1295 .spenders
1296 .iter()
1297 .map(|s| {
1298 app.resource_name(&s.kind, &s.id)
1299 .unwrap_or_else(|| s.id.clone())
1300 .chars()
1301 .count()
1302 })
1303 .max()
1304 .unwrap_or(0)
1305 .max(8);
1306 for (i, s) in data.spenders.iter().enumerate() {
1307 let name = app
1308 .resource_name(&s.kind, &s.id)
1309 .unwrap_or_else(|| s.id.clone());
1310 let kind_color = match s.kind.as_str() {
1311 "job" => p.jobs,
1312 "cluster" => p.clusters,
1313 "warehouse" => p.warehouses,
1314 _ => p.dim,
1315 };
1316 let amount = if data.priced {
1317 format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1318 } else {
1319 format!("{:>8.1} DBU", s.dbus)
1320 };
1321 lines.push(Line::from(vec![
1322 Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1323 Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1324 Span::styled(
1325 format!("{:<width$}", name, width = name_w + 2),
1326 Style::default().fg(p.text),
1327 ),
1328 Span::styled(amount, Style::default().fg(p.text)),
1329 ]));
1330 }
1331 }
1332
1333 let par = Paragraph::new(lines).block(block);
1334 f.render_widget(par, area);
1335 }
1336 }
1337}
1338
1339fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1340 let console = app.sql.as_ref().unwrap();
1341 let parts = Layout::default()
1342 .direction(Direction::Vertical)
1343 .constraints([Constraint::Length(3), Constraint::Min(0)])
1344 .split(area);
1345
1346 let via = if console.warehouse.is_empty() {
1347 String::new()
1348 } else {
1349 format!("via {} ", console.warehouse)
1350 };
1351 let prompt_block = Block::default()
1352 .title(Line::from(vec![
1353 Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1354 Span::styled(
1355 "SQL Console ",
1356 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1357 ),
1358 Span::styled(via, Style::default().fg(p.dim)),
1359 ]))
1360 .borders(Borders::ALL)
1361 .border_type(BorderType::Thick)
1362 .border_style(
1363 Style::default()
1364 .fg(p.warehouses)
1365 .add_modifier(Modifier::BOLD),
1366 )
1367 .padding(Padding::horizontal(1));
1368 if let Some((query, _)) = &app.hist_search {
1369 let matched = app
1371 .hist_search_current()
1372 .map(|s| s.replace('\n', " "))
1373 .unwrap_or_default();
1374 let mut spans = vec![
1375 Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1376 Span::styled(
1377 query.as_str(),
1378 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1379 ),
1380 Span::styled("`: ", Style::default().fg(p.dim)),
1381 ];
1382 spans.extend(sql_spans(&matched, p));
1383 let prompt = Paragraph::new(Line::from(spans)).block(prompt_block);
1384 f.render_widget(prompt, parts[0]);
1385 } else {
1386 let caret_byte = console
1390 .input
1391 .char_indices()
1392 .nth(console.cursor)
1393 .map(|(i, _)| i)
1394 .unwrap_or(console.input.len());
1395 let (before, after) = console.input.split_at(caret_byte);
1396 let inner_w = parts[0].width.saturating_sub(4) as usize; let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1398 let mut spans = vec![Span::styled("❯ ", Style::default().fg(p.key))];
1401 spans.extend(sql_spans(&before.replace('\n', " "), p));
1402 spans.push(Span::styled("▏", Style::default().fg(p.warn)));
1403 spans.extend(sql_spans(&after.replace('\n', " "), p));
1404 let prompt = Paragraph::new(Line::from(spans))
1405 .scroll((0, hscroll))
1406 .block(prompt_block);
1407 f.render_widget(prompt, parts[0]);
1408 }
1409
1410 let row_info = match &console.data {
1411 Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1412 _ => String::new(),
1413 };
1414 let mut results_title = vec![
1415 Span::styled(" Results ", Style::default().fg(p.text)),
1416 Span::styled(row_info, Style::default().fg(p.dim)),
1417 ];
1418 let avail = parts[1].width.saturating_sub(4) as usize;
1420 let sliced = match &console.data {
1421 Some(Ok(t)) if !t.headers.is_empty() => {
1422 let cols: Vec<usize> = (0..t.headers.len()).collect();
1423 let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
1424 if shown.len() < cols.len() {
1425 let first = console.col.min(cols.len() - 1) + 1;
1426 results_title.push(Span::styled(
1427 format!(
1428 "· cols {}–{} of {} (⇧←/→) ",
1429 first,
1430 first + shown.len() - 1,
1431 cols.len()
1432 ),
1433 Style::default().fg(p.dim),
1434 ));
1435 }
1436 Some((shown, constraints))
1437 }
1438 _ => None,
1439 };
1440 let results_block = Block::default()
1441 .title(Line::from(results_title))
1442 .borders(Borders::ALL)
1443 .border_type(BorderType::Rounded)
1444 .border_style(Style::default().fg(p.border))
1445 .padding(Padding::horizontal(1));
1446
1447 if console.running {
1448 let par = Paragraph::new(format!(
1449 "{} running on {} — the warehouse may need a moment to start…",
1450 app.spinner(),
1451 console.warehouse
1452 ))
1453 .style(Style::default().fg(p.warn))
1454 .block(results_block);
1455 f.render_widget(par, parts[1]);
1456 return;
1457 }
1458 match &console.data {
1459 None => {
1460 let par = Paragraph::new(
1461 "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1462 )
1463 .style(Style::default().fg(p.dim))
1464 .block(results_block);
1465 f.render_widget(par, parts[1]);
1466 }
1467 Some(Err(e)) => {
1468 let par = Paragraph::new(format!("✗ {e}"))
1469 .style(Style::default().fg(p.err))
1470 .wrap(Wrap { trim: false })
1471 .block(results_block);
1472 f.render_widget(par, parts[1]);
1473 }
1474 Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1475 let par = Paragraph::new("✓ statement succeeded — no result set")
1476 .style(Style::default().fg(p.ok))
1477 .block(results_block);
1478 f.render_widget(par, parts[1]);
1479 }
1480 Some(Ok(data)) => {
1481 let (shown, constraints) = sliced.unwrap_or_default();
1482 let header_cells: Vec<Cell> = shown
1483 .iter()
1484 .map(|&i| {
1485 Cell::from(data.headers[i].as_str()).style(
1486 Style::default()
1487 .fg(p.warehouses)
1488 .add_modifier(Modifier::BOLD),
1489 )
1490 })
1491 .collect();
1492 let header = Row::new(header_cells);
1493 let rows: Vec<Row> = data
1494 .rows
1495 .iter()
1496 .skip(console.scroll)
1497 .map(|r| {
1498 Row::new(
1499 shown
1500 .iter()
1501 .map(|&i| {
1502 Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
1503 .style(Style::default().fg(p.text))
1504 })
1505 .collect::<Vec<_>>(),
1506 )
1507 })
1508 .collect();
1509 let table = Table::new(rows, constraints)
1510 .header(header)
1511 .column_spacing(1)
1512 .block(results_block);
1513 f.render_widget(table, parts[1]);
1514 scrollbar(
1516 f,
1517 parts[1],
1518 data.rows.len(),
1519 parts[1].height.saturating_sub(3) as usize,
1520 console.scroll,
1521 p,
1522 );
1523 }
1524 }
1525}
1526
1527fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1529 let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
1530 return;
1531 };
1532 let inner_w = area.width.saturating_sub(4) as usize;
1534 let hscroll = (console.cursor + 3).saturating_sub(inner_w);
1535 let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
1536
1537 let lines: Vec<Line> = if comp.loading {
1538 vec![Line::from(Span::styled(
1539 format!("{} fetching names…", app.spinner()),
1540 Style::default().fg(p.warn),
1541 ))]
1542 } else {
1543 let visible = 8.min(comp.items.len());
1545 let skip = comp.index.saturating_sub(visible.saturating_sub(1));
1546 comp.items
1547 .iter()
1548 .enumerate()
1549 .skip(skip)
1550 .take(visible)
1551 .map(|(i, item)| {
1552 let style = if i == comp.index {
1553 Style::default()
1554 .fg(p.key)
1555 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1556 } else {
1557 Style::default().fg(p.text)
1558 };
1559 Line::from(Span::styled(format!(" {item} "), style))
1560 })
1561 .collect()
1562 };
1563 let widest = comp
1564 .items
1565 .iter()
1566 .map(|i| i.chars().count())
1567 .max()
1568 .unwrap_or(16);
1569 let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
1570 let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
1571 let popup = Rect {
1572 x: anchor.min(area.x + area.width.saturating_sub(width)),
1573 y: area.y + 3,
1574 width,
1575 height,
1576 };
1577 let title = if comp.loading {
1578 String::new()
1579 } else {
1580 format!(" {}/{} ", comp.index + 1, comp.items.len())
1581 };
1582 let block = Block::default()
1583 .title(Span::styled(title, Style::default().fg(p.dim)))
1584 .borders(Borders::ALL)
1585 .border_type(BorderType::Rounded)
1586 .border_style(Style::default().fg(p.key));
1587 f.render_widget(Clear, popup);
1588 f.render_widget(Paragraph::new(lines).block(block), popup);
1589}
1590
1591fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
1594 cols.iter()
1595 .map(|&i| {
1596 let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
1597 for row in data.rows.iter().take(50) {
1598 if let Some(c) = row.get(i) {
1599 w = w.max(c.chars().count());
1600 }
1601 }
1602 w.clamp(4, 36) as u16
1603 })
1604 .collect()
1605}
1606
1607fn grid_slice(
1612 data: &TableData,
1613 cols: &[usize],
1614 col: usize,
1615 avail: usize,
1616) -> (Vec<usize>, Vec<Constraint>) {
1617 let widths = grid_widths(data, cols);
1618 let col = col.min(cols.len().saturating_sub(1));
1619 let mut take = 0usize;
1620 let mut used = 0usize;
1621 for w in widths.iter().skip(col) {
1622 let next = used + *w as usize + if take > 0 { 1 } else { 0 };
1623 if take > 0 && next > avail {
1624 break;
1625 }
1626 used = next;
1627 take += 1;
1628 }
1629 let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
1630 let constraints: Vec<Constraint> = widths
1631 .iter()
1632 .skip(col)
1633 .take(take)
1634 .map(|&w| Constraint::Length(w))
1635 .collect();
1636 (shown, constraints)
1637}
1638
1639fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1640 let pv = app.preview.as_ref().unwrap();
1641 let acc = p.catalog;
1642 let mut title_spans: Vec<Span<'static>> = vec![
1643 Span::styled(" ◫ ", Style::default().fg(acc)),
1644 Span::styled(
1645 format!("{} · preview ", pv.name),
1646 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1647 ),
1648 Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1649 ];
1650 let make_block = |spans: Vec<Span<'static>>| {
1651 Block::default()
1652 .title(Line::from(spans))
1653 .borders(Borders::ALL)
1654 .border_type(BorderType::Rounded)
1655 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1656 .padding(Padding::horizontal(1))
1657 };
1658
1659 match &pv.data {
1660 None => {
1661 let par = Paragraph::new(format!(
1662 "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1663 app.spinner()
1664 ))
1665 .style(Style::default().fg(p.warn))
1666 .block(make_block(title_spans));
1667 f.render_widget(par, area);
1668 }
1669 Some(Err(e)) => {
1670 let text = format!(
1671 "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1672 pv.warehouse,
1673 pv.warehouse_id,
1674 app.profile.as_deref().unwrap_or("default"),
1675 app.host.as_deref().unwrap_or("unknown"),
1676 );
1677 let par = Paragraph::new(text)
1678 .style(Style::default().fg(p.err))
1679 .wrap(Wrap { trim: false })
1680 .block(make_block(title_spans));
1681 f.render_widget(par, area);
1682 }
1683 Some(Ok(data)) => {
1684 title_spans.push(Span::styled(
1685 format!("· {} rows ", data.rows.len()),
1686 Style::default().fg(p.dim),
1687 ));
1688 let cols = pv.visible_cols();
1689 if !pv.filter.is_empty() {
1690 title_spans.push(Span::styled(
1691 format!("· cols /{} ", pv.filter),
1692 Style::default().fg(p.warn),
1693 ));
1694 }
1695 if cols.is_empty() {
1696 let par = Paragraph::new(format!(
1697 "no columns match /{} — backspace to widen, esc to clear",
1698 pv.filter
1699 ))
1700 .style(Style::default().fg(p.warn))
1701 .block(make_block(title_spans));
1702 f.render_widget(par, area);
1703 return;
1704 }
1705 if pv.record {
1706 let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
1709 title_spans.push(Span::styled(
1710 format!("· row {}/{} ", row_n + 1, data.rows.len()),
1711 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1712 ));
1713 let name_w = cols
1714 .iter()
1715 .map(|&i| data.headers[i].chars().count())
1716 .max()
1717 .unwrap_or(0)
1718 .min(32);
1719 let empty: Vec<String> = Vec::new();
1720 let row = data.rows.get(row_n).unwrap_or(&empty);
1721 let lines: Vec<Line> = cols
1722 .iter()
1723 .map(|&i| {
1724 let value = row.get(i).map(String::as_str).unwrap_or("");
1725 let vstyle = if value == "␀" {
1726 Style::default().fg(p.dim)
1727 } else {
1728 Style::default().fg(p.text)
1729 };
1730 Line::from(vec![
1731 Span::styled(
1732 format!("{:<name_w$} ", data.headers[i]),
1733 Style::default().fg(acc),
1734 ),
1735 Span::styled(value.to_string(), vstyle),
1736 ])
1737 })
1738 .collect();
1739 let total = lines.len();
1740 let par = Paragraph::new(lines)
1741 .scroll((pv.rscroll, 0))
1742 .block(make_block(title_spans));
1743 f.render_widget(par, area);
1744 scrollbar(
1745 f,
1746 area,
1747 total,
1748 area.height.saturating_sub(2) as usize,
1749 pv.rscroll as usize,
1750 p,
1751 );
1752 return;
1753 }
1754 let avail = area.width.saturating_sub(4) as usize; let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
1756 if shown.len() < cols.len() {
1757 let first = pv.col.min(cols.len() - 1) + 1;
1758 title_spans.push(Span::styled(
1759 format!(
1760 "· cols {}–{} of {} ",
1761 first,
1762 first + shown.len() - 1,
1763 cols.len()
1764 ),
1765 Style::default().fg(p.dim),
1766 ));
1767 }
1768 let header = Row::new(
1769 shown
1770 .iter()
1771 .map(|&i| {
1772 Cell::from(data.headers[i].clone())
1773 .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1774 })
1775 .collect::<Vec<_>>(),
1776 );
1777 let rows: Vec<Row> = data
1778 .rows
1779 .iter()
1780 .skip(pv.scroll)
1781 .map(|r| {
1782 Row::new(
1783 shown
1784 .iter()
1785 .map(|&i| {
1786 Cell::from(r.get(i).cloned().unwrap_or_default())
1787 .style(Style::default().fg(p.text))
1788 })
1789 .collect::<Vec<_>>(),
1790 )
1791 })
1792 .collect();
1793 let table = Table::new(rows, constraints)
1794 .header(header)
1795 .column_spacing(1)
1796 .block(make_block(title_spans));
1797 f.render_widget(table, area);
1798 scrollbar(
1800 f,
1801 area,
1802 data.rows.len(),
1803 area.height.saturating_sub(3) as usize,
1804 pv.scroll,
1805 p,
1806 );
1807 }
1808 }
1809}
1810
1811fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1812 let d = app.detail.as_ref().unwrap();
1813 let acc = accent(d.panel, p);
1814 let title = Line::from(vec![
1815 Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1816 Span::styled(
1817 format!("{} · {} ", d.panel.title(), d.name),
1818 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1819 ),
1820 ]);
1821 let block = Block::default()
1822 .title(title)
1823 .borders(Borders::ALL)
1824 .border_type(BorderType::Rounded)
1825 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1826 .padding(Padding::horizontal(1));
1827
1828 let Some(data) = &d.data else {
1829 let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1830 .style(Style::default().fg(p.warn))
1831 .block(block);
1832 f.render_widget(par, area);
1833 return;
1834 };
1835
1836 if d.show_raw || data.summary.is_empty() {
1838 let par = Paragraph::new(data.raw.as_str())
1839 .style(Style::default().fg(p.text))
1840 .wrap(Wrap { trim: false })
1841 .scroll((d.scroll, 0))
1842 .block(block);
1843 f.render_widget(par, area);
1844 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
1845 scrollbar(
1846 f,
1847 area,
1848 total,
1849 area.height.saturating_sub(2) as usize,
1850 d.scroll as usize,
1851 p,
1852 );
1853 return;
1854 }
1855
1856 let mut lines: Vec<Line> = data
1857 .summary
1858 .iter()
1859 .map(|(k, v)| {
1860 Line::from(vec![
1861 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1862 Span::styled(v.as_str(), Style::default().fg(p.text)),
1863 ])
1864 })
1865 .collect();
1866 if !data.activity.is_empty() {
1867 lines.push(Line::default());
1868 lines.push(Line::from(Span::styled(
1869 d.section,
1870 Style::default().fg(acc).add_modifier(Modifier::BOLD),
1871 )));
1872 for (status, text) in &data.activity {
1873 let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
1875 if treeish {
1876 lines.push(Line::from(Span::styled(
1877 text.as_str(),
1878 Style::default().fg(status_color(status, p)),
1879 )));
1880 } else {
1881 lines.push(Line::from(vec![
1882 Span::styled("● ", Style::default().fg(status_color(status, p))),
1883 Span::styled(text.as_str(), Style::default().fg(p.text)),
1884 ]));
1885 }
1886 }
1887 }
1888 let total = lines.len();
1889 let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1890 f.render_widget(par, area);
1891 scrollbar(
1892 f,
1893 area,
1894 total,
1895 area.height.saturating_sub(2) as usize,
1896 d.scroll as usize,
1897 p,
1898 );
1899}
1900
1901fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1902 let rv = app.run_view.as_ref().unwrap();
1903 let acc = accent(rv.panel, p);
1904 let noun = if rv.panel == Panel::Jobs {
1905 "run"
1906 } else {
1907 "update"
1908 };
1909 let mut title_spans = vec![
1910 Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1911 Span::styled(
1912 format!("{} · {noun} ", rv.owner_name),
1913 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1914 ),
1915 ];
1916 if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1917 title_spans.push(Span::styled(
1918 format!("{run_id} "),
1919 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1920 ));
1921 title_spans.push(Span::styled(
1922 format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1923 Style::default().fg(p.dim),
1924 ));
1925 }
1926 if rv.live {
1927 title_spans.push(Span::styled(
1928 format!("{} live ", app.spinner()),
1929 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1930 ));
1931 }
1932 if rv.show_output {
1933 title_spans.push(Span::styled(
1934 if rv.live {
1935 "· output (tailing) "
1936 } else {
1937 "· output "
1938 },
1939 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1940 ));
1941 }
1942 if rv.show_timeline {
1943 title_spans.push(Span::styled(
1944 "· timeline ",
1945 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1946 ));
1947 }
1948 if rv.show_dag {
1949 title_spans.push(Span::styled(
1950 "· dag ",
1951 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1952 ));
1953 }
1954 let block = Block::default()
1955 .title(Line::from(title_spans))
1956 .borders(Borders::ALL)
1957 .border_type(BorderType::Rounded)
1958 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1959 .padding(Padding::horizontal(1));
1960
1961 if rv.show_output {
1963 let par = match &rv.output {
1964 Some(text) => {
1965 let lines: Vec<Line> = text.lines().map(|l| output_line(l, p)).collect();
1966 Paragraph::new(lines)
1967 .wrap(Wrap { trim: false })
1968 .scroll((rv.scroll, 0))
1969 .block(block)
1970 }
1971 None => Paragraph::new(format!(
1972 "{} fetching task output — one call per task…",
1973 app.spinner()
1974 ))
1975 .style(Style::default().fg(p.warn))
1976 .block(block),
1977 };
1978 f.render_widget(par, area);
1979 if let Some(text) = &rv.output {
1980 let total = wrapped_height(text, area.width.saturating_sub(4) as usize);
1981 scrollbar(
1982 f,
1983 area,
1984 total,
1985 area.height.saturating_sub(2) as usize,
1986 rv.scroll as usize,
1987 p,
1988 );
1989 }
1990 return;
1991 }
1992
1993 let Some(data) = &rv.data else {
1994 let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
1995 .style(Style::default().fg(p.warn))
1996 .block(block);
1997 f.render_widget(par, area);
1998 return;
1999 };
2000
2001 if rv.show_timeline {
2002 draw_run_timeline(f, area, rv.scroll, data, block, p);
2003 return;
2004 }
2005
2006 if rv.show_dag {
2007 draw_run_dag(f, area, rv.scroll, data, block, p);
2008 return;
2009 }
2010
2011 if rv.show_raw || data.summary.is_empty() {
2012 let par = Paragraph::new(data.raw.as_str())
2013 .style(Style::default().fg(p.text))
2014 .wrap(Wrap { trim: false })
2015 .scroll((rv.scroll, 0))
2016 .block(block);
2017 f.render_widget(par, area);
2018 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2019 scrollbar(
2020 f,
2021 area,
2022 total,
2023 area.height.saturating_sub(2) as usize,
2024 rv.scroll as usize,
2025 p,
2026 );
2027 return;
2028 }
2029
2030 let mut lines: Vec<Line> = data
2031 .summary
2032 .iter()
2033 .map(|(k, v)| {
2034 Line::from(vec![
2035 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2036 Span::styled(v.as_str(), Style::default().fg(p.text)),
2037 ])
2038 })
2039 .collect();
2040 if !data.activity.is_empty() {
2041 lines.push(Line::default());
2042 lines.push(Line::from(Span::styled(
2043 if rv.panel == Panel::Jobs {
2044 "Tasks"
2045 } else {
2046 "Event log"
2047 },
2048 Style::default().fg(acc).add_modifier(Modifier::BOLD),
2049 )));
2050 for (status, text) in &data.activity {
2051 if text.starts_with(" ↳") {
2053 lines.push(Line::from(Span::styled(
2054 text.as_str(),
2055 Style::default().fg(p.err),
2056 )));
2057 } else {
2058 lines.push(Line::from(vec![
2059 Span::styled("● ", Style::default().fg(status_color(status, p))),
2060 Span::styled(text.as_str(), Style::default().fg(p.text)),
2061 ]));
2062 }
2063 }
2064 }
2065 let total = lines.len();
2066 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2067 f.render_widget(par, area);
2068 scrollbar(
2069 f,
2070 area,
2071 total,
2072 area.height.saturating_sub(2) as usize,
2073 rv.scroll as usize,
2074 p,
2075 );
2076}
2077
2078fn draw_run_timeline(
2081 f: &mut Frame,
2082 area: Rect,
2083 scroll: u16,
2084 data: &DetailData,
2085 block: Block,
2086 p: &Palette,
2087) {
2088 let tasks = crate::fetchers::runs::timeline(&data.raw);
2089 let now = std::time::SystemTime::now()
2090 .duration_since(std::time::UNIX_EPOCH)
2091 .map(|d| d.as_millis() as u64)
2092 .unwrap_or(0);
2093 let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
2094 let Some(t0) = t0 else {
2095 let par = Paragraph::new(
2096 "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
2097 )
2098 .style(Style::default().fg(p.dim))
2099 .wrap(Wrap { trim: false })
2100 .block(block);
2101 f.render_widget(par, area);
2102 return;
2103 };
2104 let t1 = tasks
2105 .iter()
2106 .filter(|t| t.start > 0)
2107 .map(|t| t.end.unwrap_or(now).max(t.start))
2108 .max()
2109 .unwrap_or(t0);
2110 let span = (t1 - t0).max(1);
2111
2112 let name_w = tasks
2113 .iter()
2114 .map(|t| t.key.chars().count())
2115 .max()
2116 .unwrap_or(4)
2117 .clamp(4, 24);
2118 let inner_w = area.width.saturating_sub(4) as usize;
2119 let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
2121
2122 let mut lines: Vec<Line> = vec![
2123 Line::from(Span::styled(
2124 format!(
2125 "◷ {} task{} · span {}",
2126 tasks.len(),
2127 if tasks.len() == 1 { "" } else { "s" },
2128 crate::shape::fmt_duration_ms(span)
2129 ),
2130 Style::default().fg(p.dim),
2131 )),
2132 Line::default(),
2133 ];
2134 for t in &tasks {
2135 let mut key: String = t.key.chars().take(name_w).collect();
2136 if key.len() < t.key.len() {
2137 key.pop();
2138 key.push('…');
2139 }
2140 let name = Span::styled(format!("{key:<name_w$} "), Style::default().fg(p.text));
2141 if t.start == 0 {
2142 lines.push(Line::from(vec![
2143 name,
2144 Span::styled(
2145 format!("– {}", t.status.label().to_lowercase()),
2146 Style::default().fg(p.dim),
2147 ),
2148 ]));
2149 continue;
2150 }
2151 let end = t.end.unwrap_or(now).max(t.start);
2152 let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
2153 let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
2154 let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
2155 let dur = format!(
2156 " {}{}",
2157 crate::shape::fmt_duration_ms(end - t.start),
2158 if t.end.is_none() { "…" } else { "" }
2159 );
2160 lines.push(Line::from(vec![
2161 name,
2162 Span::styled(" ".repeat(off), Style::default()),
2163 Span::styled(
2164 "█".repeat(len),
2165 Style::default().fg(status_color(&t.status, p)),
2166 ),
2167 Span::styled(dur, Style::default().fg(p.dim)),
2168 ]));
2169 }
2170 let total = lines.len();
2171 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2172 f.render_widget(par, area);
2173 scrollbar(
2174 f,
2175 area,
2176 total,
2177 area.height.saturating_sub(2) as usize,
2178 scroll as usize,
2179 p,
2180 );
2181}
2182
2183fn draw_run_dag(
2186 f: &mut Frame,
2187 area: Rect,
2188 scroll: u16,
2189 data: &DetailData,
2190 block: Block,
2191 p: &Palette,
2192) {
2193 let rows = crate::fetchers::runs::dag(&data.raw);
2194 if rows.is_empty() {
2195 let par = Paragraph::new(
2196 "no task graph recorded — legacy single-task runs carry none (J shows the raw JSON)",
2197 )
2198 .style(Style::default().fg(p.dim))
2199 .wrap(Wrap { trim: false })
2200 .block(block);
2201 f.render_widget(par, area);
2202 return;
2203 }
2204 let mut lines: Vec<Line> = vec![
2205 Line::from(Span::styled(
2206 format!(
2207 "⧉ {} task{} · order of execution, top to bottom",
2208 rows.len(),
2209 if rows.len() == 1 { "" } else { "s" },
2210 ),
2211 Style::default().fg(p.dim),
2212 )),
2213 Line::default(),
2214 ];
2215 for r in &rows {
2216 let mut spans = vec![
2217 Span::styled(r.prefix.clone(), Style::default().fg(p.dim)),
2218 Span::styled("● ", Style::default().fg(status_color(&r.status, p))),
2219 Span::styled(r.key.clone(), Style::default().fg(p.text)),
2220 Span::styled(
2221 format!(" {}", r.status.label().to_lowercase()),
2222 Style::default().fg(status_color(&r.status, p)),
2223 ),
2224 ];
2225 if let Some(d) = r.duration {
2226 spans.push(Span::styled(
2227 format!(" · {}", crate::shape::fmt_duration_ms(d)),
2228 Style::default().fg(p.dim),
2229 ));
2230 }
2231 if !r.also_after.is_empty() {
2232 spans.push(Span::styled(
2233 format!(" (also after {})", r.also_after.join(", ")),
2234 Style::default().fg(p.dim),
2235 ));
2236 }
2237 lines.push(Line::from(spans));
2238 }
2239 let total = lines.len();
2240 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2241 f.render_widget(par, area);
2242 scrollbar(
2243 f,
2244 area,
2245 total,
2246 area.height.saturating_sub(2) as usize,
2247 scroll as usize,
2248 p,
2249 );
2250}
2251
2252fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2253 let block = Block::default()
2254 .borders(Borders::ALL)
2255 .border_type(BorderType::Rounded)
2256 .border_style(Style::default().fg(p.border));
2257 let inner = block.inner(area);
2258 f.render_widget(block, area);
2259
2260 let (from, to) = (p.grad_from, p.grad_to);
2264 let grad = |t: f32| {
2265 Color::Rgb(
2266 (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
2267 (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
2268 (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
2269 )
2270 };
2271 let mut left = vec![
2272 Span::styled(
2273 " ◢",
2274 Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
2275 ),
2276 Span::styled(
2277 "◤ ",
2278 Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
2279 ),
2280 ];
2281 let word: Vec<char> = "DATABRICKS".chars().collect();
2282 let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
2283 for (i, ch) in word.iter().enumerate() {
2284 let t = i as f32 / (word.len() - 1) as f32;
2285 let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
2286 Style::default().fg(p.text).add_modifier(Modifier::BOLD)
2287 } else {
2288 Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
2289 };
2290 left.push(Span::styled(ch.to_string(), style));
2291 }
2292 left.push(Span::raw(" "));
2293 for ch in "LAKEHOUSE".chars() {
2294 left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
2295 }
2296 left.push(Span::styled(
2297 format!("· v{}", env!("CARGO_PKG_VERSION")),
2298 Style::default().fg(p.dim),
2299 ));
2300 if let Some(profile) = &app.profile {
2301 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2302 left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
2303 left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
2304 }
2305 if let Some(Shape::Badge(b)) = &app.user_badge {
2306 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2307 left.push(Span::styled(
2308 format!("{} {}", b.label, b.value),
2309 Style::default().fg(p.key),
2310 ));
2311 }
2312 if app.zoomed {
2313 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2314 left.push(Span::styled(
2315 format!("⛶ {}", app.focus.title()),
2316 Style::default()
2317 .fg(accent(app.focus, p))
2318 .add_modifier(Modifier::BOLD),
2319 ));
2320 }
2321
2322 let (ok, pending, failed, idle) = app.status_counts();
2324 for (count, glyph, color) in [
2325 (ok, "●", p.ok),
2326 (pending, "◐", p.warn),
2327 (failed, "✗", p.err),
2328 (idle, "○", p.dim),
2329 ] {
2330 if count > 0 {
2331 left.push(Span::styled(" ", Style::default()));
2332 left.push(Span::styled(
2333 format!("{glyph} {count}"),
2334 Style::default().fg(color),
2335 ));
2336 }
2337 }
2338 f.render_widget(Paragraph::new(Line::from(left)), inner);
2339
2340 let right = if app.loading {
2341 Line::from(Span::styled(
2342 format!("{} refreshing ", app.spinner()),
2343 Style::default().fg(p.warn),
2344 ))
2345 } else {
2346 Line::from(Span::styled(
2347 format!("updated {}s ago ", app.last_refresh_age().as_secs()),
2348 Style::default().fg(p.dim),
2349 ))
2350 };
2351 f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
2352}
2353
2354fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2355 let key =
2356 |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
2357 let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
2358
2359 if let Some(confirm) = &app.confirm {
2360 let line = Line::from(vec![
2361 Span::styled(
2362 format!(" ⚠ {} ", confirm.message),
2363 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2364 ),
2365 key("y"),
2366 dim(" confirm · any other key cancels"),
2367 ]);
2368 f.render_widget(Paragraph::new(line), area);
2369 return;
2370 }
2371
2372 if app.filter_entry {
2373 let line = Line::from(vec![
2374 Span::styled(
2375 format!(" /{}", app.active_filter()),
2376 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2377 ),
2378 Span::styled("▏", Style::default().fg(p.warn)),
2379 dim(" type to filter "),
2380 key("enter"),
2381 dim(" apply "),
2382 key("esc"),
2383 dim(" clear"),
2384 ]);
2385 f.render_widget(Paragraph::new(line), area);
2386 return;
2387 }
2388
2389 if let Some(pv) = &app.preview {
2390 if pv.filter_entry {
2391 let line = Line::from(vec![
2392 Span::styled(
2393 format!(" cols /{}", pv.filter),
2394 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2395 ),
2396 Span::styled("▏", Style::default().fg(p.warn)),
2397 dim(" type to filter columns "),
2398 key("enter"),
2399 dim(" apply "),
2400 key("esc"),
2401 dim(" clear"),
2402 ]);
2403 f.render_widget(Paragraph::new(line), area);
2404 return;
2405 }
2406 }
2407
2408 let mut spans = if app.secret_form.is_some() {
2409 vec![
2410 dim(" type "),
2411 key("enter"),
2412 dim(" next / save "),
2413 key("esc"),
2414 dim(" cancel"),
2415 ]
2416 } else if app.help {
2417 vec![
2418 dim(" "),
2419 key("j"),
2420 dim("/"),
2421 key("k"),
2422 dim(" scroll any other key closes"),
2423 ]
2424 } else if app.pane_cfg.is_some() {
2425 vec![
2426 dim(" "),
2427 key("j"),
2428 dim("/"),
2429 key("k"),
2430 dim(" select "),
2431 key("space"),
2432 dim(" show/hide "),
2433 key("J"),
2434 dim("/"),
2435 key("K"),
2436 dim(" move "),
2437 key("esc"),
2438 dim(" done"),
2439 ]
2440 } else if app.jump.is_some() {
2441 vec![
2442 dim(" type to search everything "),
2443 key("\u{2191}"),
2444 dim("/"),
2445 key("\u{2193}"),
2446 dim(" select "),
2447 key("enter"),
2448 dim(" jump "),
2449 key("esc"),
2450 dim(" close"),
2451 ]
2452 } else if app.problems.is_some() {
2453 vec![
2454 dim(" "),
2455 key("j"),
2456 dim("/"),
2457 key("k"),
2458 dim(" select "),
2459 key("enter"),
2460 dim(" jump to item / switch workspace "),
2461 key("esc"),
2462 dim(" close"),
2463 ]
2464 } else if app.upcoming.is_some() {
2465 vec![
2466 dim(" "),
2467 key("j"),
2468 dim("/"),
2469 key("k"),
2470 dim(" select "),
2471 key("enter"),
2472 dim(" jump to job "),
2473 key("esc"),
2474 dim(" close"),
2475 ]
2476 } else if app.cost.is_some() {
2477 vec![
2478 dim(" "),
2479 key("esc"),
2480 dim(" back "),
2481 key("q"),
2482 dim(" quit"),
2483 ]
2484 } else if app.wh_picker.is_some() {
2485 vec![
2486 dim(" "),
2487 key("j"),
2488 dim("/"),
2489 key("k"),
2490 dim(" select "),
2491 key("enter"),
2492 dim(" run preview "),
2493 key("esc"),
2494 dim(" cancel"),
2495 ]
2496 } else if app.sql.is_some() {
2497 if app.hist_search.is_some() {
2498 vec![
2499 dim(" type to search history "),
2500 key("^r"),
2501 dim(" older match "),
2502 key("enter"),
2503 dim(" accept "),
2504 key("esc"),
2505 dim(" cancel"),
2506 ]
2507 } else {
2508 vec![
2509 dim(" "),
2510 key("enter"),
2511 dim(" run "),
2512 key("tab"),
2513 dim(" complete "),
2514 key("↑"),
2515 dim("/"),
2516 key("↓"),
2517 dim(" history "),
2518 key("^r"),
2519 dim(" search "),
2520 key("^x"),
2521 dim(" editor "),
2522 key("pgup"),
2523 dim("/"),
2524 key("pgdn"),
2525 dim(" scroll "),
2526 key("⇧←→"),
2527 dim(" cols "),
2528 key("^s"),
2529 dim(" export "),
2530 key("esc"),
2531 dim(if app.sql.as_ref().is_some_and(|c| c.running) {
2532 " cancel query"
2533 } else {
2534 " close"
2535 }),
2536 ]
2537 }
2538 } else if let Some(pv) = &app.preview {
2539 if pv.record {
2540 vec![
2541 dim(" "),
2542 key("j"),
2543 dim("/"),
2544 key("k"),
2545 dim(" fields "),
2546 key("h"),
2547 dim("/"),
2548 key("l"),
2549 dim(" prev/next row "),
2550 key("v"),
2551 dim(" grid "),
2552 key("/"),
2553 dim(" filter cols "),
2554 key("esc"),
2555 dim(" back"),
2556 ]
2557 } else {
2558 vec![
2559 dim(" "),
2560 key("esc"),
2561 dim(" back "),
2562 key("j"),
2563 dim("/"),
2564 key("k"),
2565 dim(" rows "),
2566 key("←"),
2567 dim("/"),
2568 key("→"),
2569 dim(" columns "),
2570 key("v"),
2571 dim(" row view "),
2572 key("/"),
2573 dim(" filter cols "),
2574 key("e"),
2575 dim(" export csv "),
2576 key("q"),
2577 dim(" quit"),
2578 ]
2579 }
2580 } else if app.picker.is_some() {
2581 vec![
2582 dim(" "),
2583 key("j"),
2584 dim("/"),
2585 key("k"),
2586 dim(" select "),
2587 key("enter"),
2588 dim(" switch workspace "),
2589 key("esc"),
2590 dim(" cancel"),
2591 ]
2592 } else if let Some(rv) = &app.run_view {
2593 if rv.show_output {
2594 vec![
2595 dim(" "),
2596 key("esc"),
2597 dim(" back to run "),
2598 key("j"),
2599 dim("/"),
2600 key("k"),
2601 dim(" scroll "),
2602 key("q"),
2603 dim(" quit"),
2604 ]
2605 } else {
2606 let mut spans = vec![
2607 dim(" "),
2608 key("esc"),
2609 dim(" back "),
2610 key("h"),
2611 dim("/"),
2612 key("l"),
2613 dim(" older/newer "),
2614 ];
2615 if rv.panel == Panel::Jobs {
2616 spans.push(key("o"));
2617 spans.push(dim(" output "));
2618 spans.push(key("t"));
2619 spans.push(dim(" timeline "));
2620 spans.push(key("d"));
2621 spans.push(dim(" dag "));
2622 spans.push(key("r"));
2623 spans.push(dim(" repair "));
2624 }
2625 spans.extend([
2626 key("s"),
2627 dim(" cancel "),
2628 key("j"),
2629 dim("/"),
2630 key("k"),
2631 dim(" scroll "),
2632 key("J"),
2633 dim(" raw "),
2634 key("q"),
2635 dim(" quit"),
2636 ]);
2637 spans
2638 }
2639 } else if app.detail.is_some() {
2640 let mut spans = vec![dim(" "), key("esc"), dim(" back ")];
2641 match app.detail.as_ref() {
2642 Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
2643 spans.push(key("enter"));
2644 spans.push(dim(" latest run "));
2645 }
2646 Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
2647 spans.push(key("enter"));
2648 spans.push(dim(" latest update "));
2649 }
2650 _ => {}
2651 }
2652 spans.extend([
2653 key("j"),
2654 dim("/"),
2655 key("k"),
2656 dim(" scroll "),
2657 key("J"),
2658 dim(" raw "),
2659 key("o"),
2660 dim(" open "),
2661 key("q"),
2662 dim(" quit"),
2663 ]);
2664 spans
2665 } else {
2666 let mut spans = vec![
2667 dim(" "),
2668 key("tab"),
2669 dim("/"),
2670 key("h"),
2671 dim("/"),
2672 key("l"),
2673 dim(" switch "),
2674 key("j"),
2675 dim("/"),
2676 key("k"),
2677 dim(" select "),
2678 key("/"),
2679 dim(if app.active_filter().is_empty() {
2680 " filter "
2681 } else {
2682 " filter · esc clears "
2683 }),
2684 ];
2685 let enter_label = match app.focus {
2687 Panel::Secrets if app.secret_scope.is_some() => None,
2688 Panel::Secrets => Some(" open scope "),
2689 Panel::Catalog if app.uc_path.len() < 2 => Some(" open "),
2690 _ => Some(" details "),
2691 };
2692 if let Some(label) = enter_label {
2693 spans.push(key("enter"));
2694 spans.push(dim(label));
2695 }
2696 match app.focus {
2698 Panel::Catalog => {
2699 if !app.uc_path.is_empty() {
2700 spans.push(key("bksp"));
2701 spans.push(dim(" up "));
2702 }
2703 if app.uc_path.len() == 2 {
2704 spans.push(key("p"));
2705 spans.push(dim(" preview "));
2706 spans.push(key("L"));
2707 spans.push(dim(" lineage "));
2708 spans.push(key(":"));
2709 spans.push(dim(" query table "));
2710 }
2711 }
2712 Panel::Dashboards => {}
2713 Panel::Secrets => {
2714 if app.secret_scope.is_some() {
2715 spans.push(key("bksp"));
2716 spans.push(dim(" up "));
2717 }
2718 spans.push(key("a"));
2719 spans.push(dim(if app.secret_scope.is_some() {
2720 " add secret "
2721 } else {
2722 " new scope "
2723 }));
2724 spans.push(key("x"));
2725 spans.push(dim(" delete "));
2726 }
2727 _ => {
2728 spans.push(key("s"));
2729 spans.push(dim(" action "));
2730 }
2731 }
2732 spans.extend([
2733 key("g"),
2734 dim(" access "),
2735 key("$"),
2736 dim(" cost "),
2737 key("!"),
2738 dim(" problems "),
2739 key("u"),
2740 dim(" upcoming "),
2741 ]);
2742 if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
2744 spans.push(key(":"));
2745 spans.push(dim(" sql "));
2746 }
2747 spans.extend([
2748 key("o"),
2749 dim(" open "),
2750 key("z"),
2751 dim(if app.zoomed { " unzoom " } else { " zoom " }),
2752 key("w"),
2753 dim(" workspace "),
2754 key("t"),
2755 dim(" theme "),
2756 key("r"),
2757 dim(" refresh "),
2758 key("?"),
2759 dim(" help "),
2760 key("q"),
2761 dim(" quit"),
2762 ]);
2763 spans
2764 };
2765 if let Some((msg, _)) = &app.flash {
2767 let color = if msg.starts_with('✗') { p.err } else { p.ok };
2768 let mut prefixed = vec![
2769 Span::styled(
2770 format!(" {msg}"),
2771 Style::default().fg(color).add_modifier(Modifier::BOLD),
2772 ),
2773 dim(" · "),
2774 ];
2775 prefixed.append(&mut spans);
2776 spans = prefixed;
2777 }
2778 f.render_widget(Paragraph::new(Line::from(spans)), area);
2779}
2780
2781#[allow(clippy::too_many_arguments)]
2782fn draw_panel(
2783 f: &mut Frame,
2784 area: Rect,
2785 panel: Panel,
2786 shape: Option<&Shape>,
2787 focused: bool,
2788 selected: Option<usize>,
2789 fresh: bool,
2790 breadcrumb: &str,
2791 spinner: &str,
2792 filter: &str,
2793 entering: bool,
2794 p: &Palette,
2795) {
2796 let accent = accent(panel, p);
2797 let dimmed = |s: Style| {
2800 if focused {
2801 s
2802 } else {
2803 s.add_modifier(Modifier::DIM)
2804 }
2805 };
2806 let (border_style, title_style) = if focused {
2807 (
2808 Style::default().fg(accent).add_modifier(Modifier::BOLD),
2809 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2810 )
2811 } else {
2812 (Style::default().fg(p.border), Style::default().fg(p.dim))
2813 };
2814 let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
2815 Some(Shape::List(items)) => Some(
2816 items
2817 .iter()
2818 .filter(|it| crate::shape::item_matches(it, filter))
2819 .collect(),
2820 ),
2821 _ => None,
2822 };
2823 let count = match (&visible, shape) {
2824 (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
2826 format!(" · {}/{}", v.len(), items.len())
2827 }
2828 (Some(v), _) => format!(" · {}", v.len()),
2829 (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
2830 _ => String::new(),
2831 };
2832 let crumb = if !breadcrumb.is_empty() {
2833 format!(" ▸ {breadcrumb}")
2834 } else {
2835 String::new()
2836 };
2837 let title_style = if fresh {
2839 Style::default()
2840 .bg(accent)
2841 .fg(Color::Black)
2842 .add_modifier(Modifier::BOLD)
2843 } else {
2844 title_style
2845 };
2846 let mut title_spans = vec![
2847 Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
2848 Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
2849 ];
2850 if entering || !filter.is_empty() {
2851 title_spans.push(Span::styled(
2852 format!("/{}{} ", filter, if entering { "▏" } else { "" }),
2853 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2854 ));
2855 }
2856 let title = Line::from(title_spans);
2857 let block = Block::default()
2858 .title(title)
2859 .borders(Borders::ALL)
2860 .border_type(if focused {
2861 BorderType::Thick
2862 } else {
2863 BorderType::Rounded
2864 })
2865 .border_style(border_style)
2866 .padding(Padding::horizontal(1));
2867
2868 match shape {
2869 None => {
2870 let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
2871 .style(Style::default().fg(p.warn))
2872 .block(block);
2873 f.render_widget(par, area);
2874 }
2875 Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
2876 let empty = if !items.is_empty() {
2877 format!("∅ nothing matches /{filter}")
2878 } else {
2879 let msg = match panel {
2880 Panel::Clusters => "no compute running",
2881 Panel::Jobs => "no jobs defined",
2882 Panel::Pipelines => "no pipelines",
2883 Panel::Warehouses => "no warehouses",
2884 Panel::Dashboards => "no dashboards yet",
2885 Panel::Catalog => "nothing at this level",
2886 Panel::Secrets => "no secret scopes",
2887 };
2888 format!("∅ {msg}")
2889 };
2890 let par = Paragraph::new(empty)
2891 .style(Style::default().fg(p.dim))
2892 .block(block);
2893 f.render_widget(par, area);
2894 }
2895 Some(Shape::List(_)) => {
2896 let list_items: Vec<ListItem> = visible
2897 .unwrap_or_default()
2898 .iter()
2899 .map(|item| {
2900 let color = status_color(&item.status, p);
2901 let chip = match item.status {
2902 Status::Stopped | Status::Unknown(_) => Span::styled(
2903 format!(" {} ", item.status.label()),
2904 Style::default().fg(color).add_modifier(Modifier::DIM),
2905 ),
2906 _ => Span::styled(
2907 format!(" {} ", item.status.label()),
2908 Style::default()
2909 .bg(color)
2910 .fg(Color::Black)
2911 .add_modifier(Modifier::BOLD),
2912 ),
2913 };
2914 let mut spans = vec![
2915 Span::styled("● ", Style::default().fg(color)),
2916 Span::styled(item.name.as_str(), dimmed(Style::default().fg(p.text))),
2917 Span::raw(" "),
2918 chip,
2919 ];
2920 if !item.history.is_empty() {
2921 spans.push(Span::raw(" "));
2922 for run in &item.history {
2923 spans.push(Span::styled(
2924 history_glyph(run),
2925 Style::default().fg(status_color(run, p)),
2926 ));
2927 }
2928 }
2929 if let Some(detail) = &item.detail {
2930 spans.push(Span::styled(
2931 format!(" {}", detail),
2932 dimmed(Style::default().fg(p.dim)),
2933 ));
2934 }
2935 ListItem::new(Line::from(spans))
2936 })
2937 .collect();
2938 let list = List::new(list_items)
2939 .block(block)
2940 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
2941 match selected {
2942 Some(sel) => {
2943 let mut state = ListState::default().with_selected(Some(sel));
2944 f.render_stateful_widget(list, area, &mut state);
2945 }
2946 None => f.render_widget(list, area),
2947 }
2948 }
2949 Some(Shape::Table(data)) => {
2950 let header_cells: Vec<Cell> = data
2951 .headers
2952 .iter()
2953 .map(|h| {
2954 Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
2955 })
2956 .collect();
2957 let header = Row::new(header_cells).style(Style::default().fg(accent));
2958 let rows: Vec<Row> = data
2959 .rows
2960 .iter()
2961 .map(|r| {
2962 Row::new(
2963 r.iter()
2964 .map(|c| Cell::from(c.as_str()).style(dimmed(Style::default())))
2965 .collect::<Vec<_>>(),
2966 )
2967 })
2968 .collect();
2969 let widths: Vec<Constraint> = data
2970 .headers
2971 .iter()
2972 .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
2973 .collect();
2974 let table = Table::new(rows, widths).header(header).block(block);
2975 f.render_widget(table, area);
2976 }
2977 Some(Shape::Badge(b)) => {
2978 let text = format!("{}: {}", b.label, b.value);
2979 let par = Paragraph::new(text)
2980 .style(dimmed(Style::default().fg(p.text)))
2981 .block(block);
2982 f.render_widget(par, area);
2983 }
2984 Some(Shape::Text(t)) => {
2985 let color = if t.starts_with('✗') { p.err } else { p.text };
2986 let par = Paragraph::new(t.as_str())
2987 .style(dimmed(Style::default().fg(color)))
2988 .wrap(Wrap { trim: false })
2989 .block(block);
2990 f.render_widget(par, area);
2991 }
2992 }
2993}
2994
2995fn scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, pos: usize, p: &Palette) {
2999 if viewport == 0 || total <= viewport {
3000 return;
3001 }
3002 let max = total - viewport;
3003 let mut state = ScrollbarState::new(max).position(pos.min(max));
3004 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
3005 .begin_symbol(None)
3006 .end_symbol(None)
3007 .track_symbol(Some("│"))
3008 .track_style(Style::default().fg(p.border))
3009 .thumb_symbol("┃")
3010 .thumb_style(Style::default().fg(p.text));
3011 f.render_stateful_widget(
3012 sb,
3013 area.inner(Margin {
3014 horizontal: 0,
3015 vertical: 1,
3016 }),
3017 &mut state,
3018 );
3019}
3020
3021fn wrapped_height(text: &str, width: usize) -> usize {
3023 let w = width.max(1);
3024 text.lines()
3025 .map(|l| l.chars().count().div_ceil(w).max(1))
3026 .sum()
3027}
3028
3029const SQL_EXTRA_KEYWORDS: &[&str] = &[
3032 "TABLE", "VIEW", "IF", "EXISTS", "USE", "TRUE", "FALSE", "ASC", "DESC", "INTERVAL",
3033];
3034
3035fn is_sql_keyword(word: &str) -> bool {
3036 let up = word.to_ascii_uppercase();
3037 crate::app::SQL_KEYWORDS
3038 .iter()
3039 .any(|k| k.split_whitespace().any(|w| w == up))
3040 || SQL_EXTRA_KEYWORDS.contains(&up.as_str())
3041}
3042
3043fn sql_spans(sql: &str, p: &Palette) -> Vec<Span<'static>> {
3047 let chars: Vec<char> = sql.chars().collect();
3048 let mut spans = Vec::new();
3049 let mut i = 0;
3050 let push = |spans: &mut Vec<Span<'static>>, text: String, style: Style| {
3051 if !text.is_empty() {
3052 spans.push(Span::styled(text, style));
3053 }
3054 };
3055 while i < chars.len() {
3056 let c = chars[i];
3057 if c == '\'' || c == '`' || c == '"' {
3058 let start = i;
3061 i += 1;
3062 while i < chars.len() && chars[i] != c {
3063 i += 1;
3064 }
3065 if i < chars.len() {
3066 i += 1;
3067 }
3068 let color = if c == '\'' { p.ok } else { p.catalog };
3069 push(
3070 &mut spans,
3071 chars[start..i].iter().collect(),
3072 Style::default().fg(color),
3073 );
3074 } else if c == '-' && chars.get(i + 1) == Some(&'-') {
3075 push(
3076 &mut spans,
3077 chars[i..].iter().collect(),
3078 Style::default().fg(p.dim),
3079 );
3080 break;
3081 } else if c.is_ascii_digit() {
3082 let start = i;
3083 while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
3084 i += 1;
3085 }
3086 push(
3087 &mut spans,
3088 chars[start..i].iter().collect(),
3089 Style::default().fg(p.warn),
3090 );
3091 } else if c.is_alphanumeric() || c == '_' {
3092 let start = i;
3093 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
3094 i += 1;
3095 }
3096 let word: String = chars[start..i].iter().collect();
3097 let style = if is_sql_keyword(&word) {
3098 Style::default().fg(p.key).add_modifier(Modifier::BOLD)
3099 } else {
3100 Style::default().fg(p.text)
3101 };
3102 push(&mut spans, word, style);
3103 } else {
3104 let start = i;
3105 i += 1;
3106 while i < chars.len() {
3107 let c = chars[i];
3108 if c.is_alphanumeric() || matches!(c, '_' | '\'' | '`' | '"' | '-') {
3109 break;
3110 }
3111 i += 1;
3112 }
3113 push(
3114 &mut spans,
3115 chars[start..i].iter().collect(),
3116 Style::default().fg(p.text),
3117 );
3118 }
3119 }
3120 spans
3121}
3122
3123fn output_line<'a>(line: &'a str, p: &Palette) -> Line<'a> {
3127 if let Some(rest) = line.strip_prefix("── ") {
3128 let color = if rest.contains("FAILED") {
3129 p.err
3130 } else if rest.contains("RUNNING") || rest.contains("PENDING") {
3131 p.warn
3132 } else if rest.contains("SUCCESS") {
3133 p.ok
3134 } else {
3135 p.text
3136 };
3137 return Line::from(Span::styled(
3138 line,
3139 Style::default().fg(color).add_modifier(Modifier::BOLD),
3140 ));
3141 }
3142 let trimmed = line.trim_start();
3143 let indented = line.starts_with(char::is_whitespace);
3144 let body = if line.contains("ERROR")
3145 || line.contains("FATAL")
3146 || line.contains("Exception")
3147 || trimmed.starts_with("Traceback")
3148 || trimmed.starts_with("Caused by")
3149 || (indented && (trimmed.starts_with("at ") || trimmed.starts_with("File \"")))
3150 {
3151 Style::default().fg(p.err)
3152 } else if line.contains("WARN") {
3153 Style::default().fg(p.warn)
3154 } else if line == "logs (tail):" || trimmed.starts_with("(no output") {
3155 Style::default().fg(p.dim)
3156 } else {
3157 Style::default().fg(p.text)
3158 };
3159 let ts = timestamp_prefix(line);
3160 if ts > 0 {
3161 Line::from(vec![
3162 Span::styled(&line[..ts], Style::default().fg(p.dim)),
3163 Span::styled(&line[ts..], body),
3164 ])
3165 } else {
3166 Line::from(Span::styled(line, body))
3167 }
3168}
3169
3170fn timestamp_prefix(line: &str) -> usize {
3174 let mut len = 0;
3175 let mut digits = 0;
3176 for (i, c) in line.char_indices() {
3177 if c.is_ascii_digit() {
3178 digits += 1;
3179 } else if !matches!(c, '/' | '-' | ':' | '.' | ',' | 'T' | 'Z' | ' ') {
3180 break;
3181 }
3182 len = i + c.len_utf8();
3183 if len >= 30 {
3184 break;
3185 }
3186 }
3187 if digits >= 6 && len >= 8 {
3188 len
3189 } else {
3190 0
3191 }
3192}
3193
3194fn status_color(status: &Status, p: &Palette) -> Color {
3195 match status {
3196 Status::Running | Status::Success => p.ok,
3197 Status::Stopped => p.dim,
3198 Status::Pending => p.warn,
3199 Status::Failed => p.err,
3200 Status::Unknown(_) => p.text,
3201 }
3202}
3203
3204fn history_glyph(status: &Status) -> &'static str {
3205 match status {
3206 Status::Success => "✓",
3207 Status::Failed => "✗",
3208 Status::Running => "●",
3209 Status::Pending => "◐",
3210 Status::Stopped => "○",
3211 Status::Unknown(_) => "·",
3212 }
3213}
3214
3215#[cfg(test)]
3216mod tests {
3217 use super::*;
3218
3219 fn find<'a>(spans: &'a [Span], text: &str) -> &'a Span<'a> {
3220 spans
3221 .iter()
3222 .find(|s| s.content == text)
3223 .unwrap_or_else(|| panic!("no span {text:?} in {spans:?}"))
3224 }
3225
3226 #[test]
3227 fn sql_spans_color_keywords_strings_numbers_comments() {
3228 let p = palette(ThemeMode::Dark);
3229 let spans = sql_spans("SELECT count(x) FROM t WHERE s = 'bob' LIMIT 10 -- hi", &p);
3230 assert_eq!(find(&spans, "SELECT").style.fg, Some(p.key));
3231 assert!(find(&spans, "SELECT")
3232 .style
3233 .add_modifier
3234 .contains(Modifier::BOLD));
3235 assert_eq!(find(&spans, "count").style.fg, Some(p.key));
3236 assert_eq!(find(&spans, "t").style.fg, Some(p.text));
3237 assert_eq!(find(&spans, "'bob'").style.fg, Some(p.ok));
3238 assert_eq!(find(&spans, "10").style.fg, Some(p.warn));
3239 assert_eq!(find(&spans, "-- hi").style.fg, Some(p.dim));
3240 }
3241
3242 #[test]
3243 fn sql_spans_preserve_every_char() {
3244 let p = palette(ThemeMode::Dark);
3245 for sql in [
3246 "SELECT `col a`, b - 1 FROM main.sales.orders WHERE s = 'unclosed",
3247 "-- only a comment",
3248 " \"quoted id\" <> 3.5e2 ",
3249 ] {
3250 let joined: String = sql_spans(sql, &p)
3251 .iter()
3252 .map(|s| s.content.as_ref())
3253 .collect();
3254 assert_eq!(joined, sql);
3255 }
3256 }
3257}