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