1use crate::app::{App, Panel, ThemeMode};
2use crate::shape::{Shape, Status};
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 }
194}
195
196pub fn draw(f: &mut Frame, app: &App) {
197 let p = palette(app.theme);
198
199 if app.splash_active() {
200 draw_splash(f, f.area(), app, &p);
201 return;
202 }
203
204 let root = Layout::default()
205 .direction(Direction::Vertical)
206 .constraints([
207 Constraint::Length(3),
208 Constraint::Min(0),
209 Constraint::Length(1),
210 ])
211 .split(f.area());
212
213 draw_header(f, root[0], app, &p);
214 draw_footer(f, root[2], app, &p);
215
216 if app.sql.is_some() {
217 draw_sql(f, root[1], app, &p);
218 if app.wh_picker.is_some() {
220 draw_wh_picker(f, root[1], app, &p);
221 }
222 return;
223 }
224
225 if app.cost.is_some() {
226 draw_cost(f, root[1], app, &p);
227 return;
228 }
229
230 if app.preview.is_some() {
231 draw_preview(f, root[1], app, &p);
232 return;
233 }
234
235 if app.run_view.is_some() {
236 draw_run(f, root[1], app, &p);
237 return;
238 }
239
240 if app.detail.is_some() {
241 draw_detail(f, root[1], app, &p);
242 return;
243 }
244
245 if app.zoomed {
246 let idx = Panel::ALL
247 .iter()
248 .position(|pn| pn == &app.focus)
249 .unwrap_or(0);
250 draw_panel(
251 f,
252 root[1],
253 app.focus,
254 app.shapes[idx].as_ref(),
255 true,
256 Some(app.selection(idx)),
257 false,
258 &app.uc_path.join("."),
259 app.spinner(),
260 &app.filters[idx],
261 app.filter_entry,
262 &p,
263 );
264 if app.picker.is_some() {
266 draw_picker(f, root[1], app, &p);
267 }
268 if app.wh_picker.is_some() {
269 draw_wh_picker(f, root[1], app, &p);
270 }
271 if app.problems.is_some() {
272 draw_problems(f, root[1], app, &p);
273 }
274 if app.jump.is_some() {
275 draw_jump(f, root[1], app, &p);
276 }
277 if app.pane_cfg.is_some() {
278 draw_pane_cfg(f, root[1], app, &p);
279 }
280 if app.help {
281 draw_help(f, root[1], app, &p);
282 }
283 return;
284 }
285
286 let visible = app.visible_panes();
289 if visible.is_empty() {
290 let par = Paragraph::new("all panes hidden — press H to bring them back")
291 .style(Style::default().fg(p.dim))
292 .alignment(Alignment::Center);
293 f.render_widget(par, root[1]);
294 } else {
295 let rows_of = |n: usize| -> Vec<Constraint> {
296 (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
297 };
298 let left_count = visible.len().div_ceil(2);
299 let right_count = visible.len() - left_count;
300 let mut areas: Vec<Rect> = Vec::new();
301 if right_count == 0 {
302 areas.extend(
303 Layout::default()
304 .direction(Direction::Vertical)
305 .constraints(rows_of(left_count))
306 .split(root[1])
307 .iter(),
308 );
309 } else {
310 let body = Layout::default()
311 .direction(Direction::Horizontal)
312 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
313 .split(root[1]);
314 areas.extend(
315 Layout::default()
316 .direction(Direction::Vertical)
317 .constraints(rows_of(left_count))
318 .split(body[0])
319 .iter(),
320 );
321 areas.extend(
322 Layout::default()
323 .direction(Direction::Vertical)
324 .constraints(rows_of(right_count))
325 .split(body[1])
326 .iter(),
327 );
328 }
329
330 for (slot, &i) in visible.iter().enumerate() {
331 let panel = Panel::ALL[i];
332 let focused = app.focus == panel;
333 let shape = app.shapes[i].as_ref();
334 let selected = focused.then(|| app.selection(i));
335 let fresh = app.updated_at[i]
336 .map(|t| t.elapsed() < Duration::from_millis(1200))
337 .unwrap_or(false);
338 draw_panel(
339 f,
340 areas[slot],
341 panel,
342 shape,
343 focused,
344 selected,
345 fresh,
346 &app.uc_path.join("."),
347 app.spinner(),
348 &app.filters[i],
349 app.filter_entry && focused,
350 &p,
351 );
352 }
353 }
354
355 if app.picker.is_some() {
356 draw_picker(f, root[1], app, &p);
357 }
358 if app.wh_picker.is_some() {
359 draw_wh_picker(f, root[1], app, &p);
360 }
361 if app.problems.is_some() {
362 draw_problems(f, root[1], app, &p);
363 }
364 if app.jump.is_some() {
365 draw_jump(f, root[1], app, &p);
366 }
367 if app.pane_cfg.is_some() {
368 draw_pane_cfg(f, root[1], app, &p);
369 }
370 if app.help {
371 draw_help(f, root[1], app, &p);
372 }
373}
374
375fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
376 let Some(selected) = app.pane_cfg else {
377 return;
378 };
379 let width = 52.min(area.width.saturating_sub(4));
380 let height = 11.min(area.height);
381 let popup = Rect {
382 x: area.x + (area.width.saturating_sub(width)) / 2,
383 y: area.y + (area.height.saturating_sub(height)) / 2,
384 width,
385 height,
386 };
387 f.render_widget(Clear, popup);
388 let block = Block::default()
389 .title(Line::from(vec![
390 Span::styled(" ▦ ", Style::default().fg(p.key)),
391 Span::styled(
392 "Panes ",
393 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
394 ),
395 ]))
396 .borders(Borders::ALL)
397 .border_type(BorderType::Thick)
398 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
399 .padding(Padding::new(1, 1, 1, 0));
400 let items: Vec<ListItem> = app
401 .pane_order
402 .iter()
403 .map(|&i| {
404 let panel = Panel::ALL[i];
405 let (mark, style) = if app.hidden[i] {
406 ("○ ", Style::default().fg(p.dim))
407 } else {
408 ("● ", Style::default().fg(p.ok))
409 };
410 ListItem::new(Line::from(vec![
411 Span::styled(mark, style),
412 Span::styled(
413 format!("{} ", panel.icon()),
414 Style::default().fg(accent(panel, p)),
415 ),
416 Span::styled(
417 panel.title(),
418 if app.hidden[i] {
419 Style::default().fg(p.dim)
420 } else {
421 Style::default().fg(p.text)
422 },
423 ),
424 ]))
425 })
426 .collect();
427 let list = List::new(items)
428 .block(block)
429 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
430 let mut state = ListState::default().with_selected(Some(selected));
431 f.render_stateful_widget(list, popup, &mut state);
432}
433
434fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
435 let width = 74.min(area.width.saturating_sub(4));
436 let height = area.height.saturating_sub(2);
437 let popup = Rect {
438 x: area.x + (area.width.saturating_sub(width)) / 2,
439 y: area.y + 1,
440 width,
441 height,
442 };
443 f.render_widget(Clear, popup);
444 let block = Block::default()
445 .title(Line::from(vec![
446 Span::styled(" ? ", Style::default().fg(p.key)),
447 Span::styled(
448 "Keys ",
449 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
450 ),
451 ]))
452 .borders(Borders::ALL)
453 .border_type(BorderType::Thick)
454 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
455 .padding(Padding::new(1, 1, 1, 0));
456
457 let sections: &[(&str, &[(&str, &str)])] = &[
458 (
459 "Global",
460 &[
461 ("tab / l, shift+tab / h", "focus next / previous pane"),
462 ("j / k", "select item"),
463 ("enter", "open details / drill down"),
464 ("/", "filter the focused pane (esc clears)"),
465 ("ctrl+p", "command palette: fuzzy-jump anywhere"),
466 ("!", "problems: everything failing, enter jumps"),
467 (":", "SQL console (prefilled on a catalog table)"),
468 ("$", "cost view: DBUs, dollars, top spenders"),
469 ("H", "arrange panes: hide and reorder"),
470 ("z", "zoom the focused pane"),
471 ("w", "switch workspace profile"),
472 ("t", "cycle color theme"),
473 ("r", "refresh now"),
474 ("?", "this help"),
475 ("q / ctrl+c", "quit"),
476 ],
477 ),
478 (
479 "Focused pane",
480 &[
481 ("s", "start / stop / run the selected item"),
482 ("g", "access: grants and permissions"),
483 ("o", "open in the workspace web UI"),
484 ],
485 ),
486 (
487 "Unity Catalog pane",
488 &[
489 ("enter / backspace", "drill down / up (into volumes too)"),
490 ("p / P", "preview table rows (P picks the warehouse)"),
491 ("L", "lineage: upstream and downstream tables"),
492 (":", "query the selected table"),
493 ("enter on a file", "peek at its first 200 lines"),
494 ],
495 ),
496 (
497 "Details & runs",
498 &[
499 ("enter", "job/pipeline detail → latest run/update"),
500 ("h / l", "older / newer run"),
501 ("s", "cancel the shown run"),
502 ("j / k, J", "scroll · toggle raw JSON"),
503 ("e", "in previews: export rows to CSV"),
504 ],
505 ),
506 (
507 "SQL console",
508 &[
509 ("enter", "run the statement"),
510 ("↑ / ↓, ctrl+r", "history · incremental search"),
511 ("ctrl+x", "compose in $EDITOR"),
512 ("ctrl+s", "export results to CSV"),
513 ("pgup / pgdn", "scroll results"),
514 ("esc", "cancel a running query, else close"),
515 ],
516 ),
517 ];
518 let mut lines: Vec<Line> = Vec::new();
519 for (title, keys) in sections {
520 lines.push(Line::from(Span::styled(
521 *title,
522 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
523 )));
524 for (k, desc) in *keys {
525 lines.push(Line::from(vec![
526 Span::styled(format!(" {k:<24}"), Style::default().fg(p.warn)),
527 Span::styled(*desc, Style::default().fg(p.text)),
528 ]));
529 }
530 lines.push(Line::default());
531 }
532 let par = Paragraph::new(lines)
533 .scroll((app.help_scroll, 0))
534 .block(block);
535 f.render_widget(par, popup);
536}
537
538fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
539 let Some(jump) = &app.jump else {
540 return;
541 };
542 let matches = app.jump_matches();
543 let width = 70.min(area.width.saturating_sub(4));
544 let height = (matches.len() as u16 + 5).min(area.height);
545 let popup = Rect {
546 x: area.x + (area.width.saturating_sub(width)) / 2,
547 y: area.y + 2.min(area.height.saturating_sub(height)),
548 width,
549 height,
550 };
551 f.render_widget(Clear, popup);
552 let block = Block::default()
553 .title(Line::from(vec![
554 Span::styled(" ⌕ ", Style::default().fg(p.key)),
555 Span::styled(
556 "Jump to ",
557 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
558 ),
559 ]))
560 .borders(Borders::ALL)
561 .border_type(BorderType::Thick)
562 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
563 .padding(Padding::horizontal(1));
564 let inner = block.inner(popup);
565 f.render_widget(block, popup);
566
567 let parts = Layout::default()
568 .direction(Direction::Vertical)
569 .constraints([Constraint::Length(2), Constraint::Min(0)])
570 .split(inner);
571 f.render_widget(
572 Paragraph::new(Line::from(vec![
573 Span::styled("❯ ", Style::default().fg(p.key)),
574 Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
575 Span::styled("▏", Style::default().fg(p.warn)),
576 ])),
577 parts[0],
578 );
579
580 if matches.is_empty() {
581 f.render_widget(
582 Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
583 parts[1],
584 );
585 return;
586 }
587 let items: Vec<ListItem> = matches
588 .iter()
589 .map(|(panel_idx, name, label)| {
590 let panel = Panel::ALL[*panel_idx];
591 ListItem::new(Line::from(vec![
592 Span::styled(
593 format!("{} ", panel.icon()),
594 Style::default().fg(accent(panel, p)),
595 ),
596 Span::styled(format!("{name} "), Style::default().fg(p.text)),
597 Span::styled(label.clone(), Style::default().fg(p.dim)),
598 ]))
599 })
600 .collect();
601 let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
602 let mut state = ListState::default().with_selected(Some(jump.index));
603 f.render_stateful_widget(list, parts[1], &mut state);
604}
605
606fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
607 let Some(pr) = &app.problems else {
608 return;
609 };
610 let truncate = |s: &str, max: usize| -> String {
611 if s.chars().count() <= max {
612 s.to_string()
613 } else {
614 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
615 format!("{cut}…")
616 }
617 };
618 let name_w = pr
621 .items
622 .iter()
623 .map(|i| i.name.chars().count())
624 .max()
625 .unwrap_or(10)
626 .clamp(10, 40);
627 let note_w = pr
628 .items
629 .iter()
630 .map(|i| i.note.chars().count())
631 .max()
632 .unwrap_or(0);
633 let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
635 let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
636 let note_space = (width as usize).saturating_sub(name_w + 30);
638 let popup = Rect {
639 x: area.x + (area.width.saturating_sub(width)) / 2,
640 y: area.y + (area.height.saturating_sub(height)) / 2,
641 width,
642 height,
643 };
644 f.render_widget(Clear, popup);
645 let block = Block::default()
646 .title(Line::from(vec![
647 Span::styled(" ✗ ", Style::default().fg(p.err)),
648 Span::styled(
649 format!("Problems · {} ", pr.items.len()),
650 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
651 ),
652 ]))
653 .borders(Borders::ALL)
654 .border_type(BorderType::Rounded)
655 .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
656 .padding(Padding::new(1, 1, 1, 1));
657
658 if pr.items.is_empty() {
659 let par = Paragraph::new("✓ all clear — nothing failing right now")
660 .style(Style::default().fg(p.ok))
661 .block(block);
662 f.render_widget(par, popup);
663 return;
664 }
665
666 let items: Vec<ListItem> = pr
667 .items
668 .iter()
669 .map(|problem| {
670 let panel = Panel::ALL[problem.panel];
671 ListItem::new(Line::from(vec![
672 Span::styled("✗ ", Style::default().fg(p.err)),
673 Span::styled(
674 format!(
675 "{:<width$}",
676 truncate(&problem.name, name_w),
677 width = name_w + 2
678 ),
679 Style::default().fg(p.text),
680 ),
681 Span::styled(
682 format!("{} {:<18}", panel.icon(), panel.title()),
683 Style::default().fg(accent(panel, p)),
684 ),
685 Span::styled(
686 truncate(&problem.note, note_space),
687 Style::default().fg(p.dim),
688 ),
689 ]))
690 })
691 .collect();
692 let list = List::new(items)
693 .block(block)
694 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
695 let mut state = ListState::default().with_selected(Some(pr.index));
696 f.render_stateful_widget(list, popup, &mut state);
697}
698
699fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
700 let Some(picker) = &app.wh_picker else {
701 return;
702 };
703 let warehouses = app.warehouses();
704 let name_w = warehouses
705 .iter()
706 .map(|(n, _, _)| n.chars().count())
707 .max()
708 .unwrap_or(12)
709 .max(12);
710 let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
712 let height = (warehouses.len() as u16 + 4).min(area.height);
713 let popup = Rect {
714 x: area.x + (area.width.saturating_sub(width)) / 2,
715 y: area.y + (area.height.saturating_sub(height)) / 2,
716 width,
717 height,
718 };
719 f.render_widget(Clear, popup);
720 let block = Block::default()
721 .title(Line::from(vec![
722 Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
723 Span::styled(
724 "Run query on ",
725 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
726 ),
727 ]))
728 .borders(Borders::ALL)
729 .border_type(BorderType::Rounded)
730 .border_style(
731 Style::default()
732 .fg(p.warehouses)
733 .add_modifier(Modifier::BOLD),
734 )
735 .padding(Padding::new(1, 1, 1, 1));
736 let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
737 let items: Vec<ListItem> = warehouses
738 .iter()
739 .map(|(name, id, running)| {
740 let dot = if *running { p.ok } else { p.dim };
741 let marker = if current_id == Some(id.as_str()) {
742 "» "
743 } else {
744 " "
745 };
746 let state = if *running { "running" } else { "idle " };
747 ListItem::new(Line::from(vec![
748 Span::styled(marker, Style::default().fg(p.key)),
749 Span::styled("● ", Style::default().fg(dot)),
750 Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
751 Span::styled(
752 format!(" {state}"),
753 Style::default().fg(if *running { p.ok } else { p.dim }),
754 ),
755 Span::styled(format!(" {id}"), Style::default().fg(p.dim)),
756 ]))
757 })
758 .collect();
759 let list = List::new(items)
760 .block(block)
761 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
762 let mut state = ListState::default().with_selected(Some(picker.index));
763 f.render_stateful_widget(list, popup, &mut state);
764}
765
766const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
767const WORDMARK_BOT: &str = "█▄▀ █▀█ █ █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
768
769fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
770 let lines_total = 9u16;
771 let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
772 let frame = app.spinner_frame();
773
774 let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
776 for (i, row) in bricks.iter().enumerate() {
777 let line = Line::from(Span::styled(
778 *row,
779 Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
780 ));
781 let rect = Rect {
782 x: area.x,
783 y: top + i as u16,
784 width: area.width,
785 height: 1,
786 };
787 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
788 }
789
790 let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
792 for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
793 let spans: Vec<Span> = text
794 .chars()
795 .enumerate()
796 .map(|(i, c)| {
797 let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
798 let style = if dist < 3 {
799 Style::default()
800 .fg(Color::White)
801 .add_modifier(Modifier::BOLD)
802 } else {
803 Style::default().fg(p.brand)
804 };
805 Span::styled(c.to_string(), style)
806 })
807 .collect();
808 let rect = Rect {
809 x: area.x,
810 y: top + row,
811 width: area.width,
812 height: 1,
813 };
814 f.render_widget(
815 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
816 rect,
817 );
818 }
819
820 let tagline = Line::from(vec![
821 Span::styled("the ", Style::default().fg(p.dim)),
822 Span::styled(
823 "Lakehouse",
824 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
825 ),
826 Span::styled(" in your terminal", Style::default().fg(p.dim)),
827 ]);
828 let version = Line::from(Span::styled(
829 format!("v{}", env!("CARGO_PKG_VERSION")),
830 Style::default().fg(p.dim),
831 ));
832 for (offset, line) in [(7u16, tagline), (8u16, version)] {
833 let rect = Rect {
834 x: area.x,
835 y: top + offset,
836 width: area.width,
837 height: 1,
838 };
839 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
840 }
841}
842
843fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
844 let selected = app.picker.unwrap_or(0);
845 let width = (app
846 .profiles
847 .iter()
848 .map(|s| s.len())
849 .max()
850 .unwrap_or(10)
851 .max(20) as u16
852 + 6)
853 .min(area.width);
854 let height = (app.profiles.len() as u16 + 2).min(area.height);
855 let popup = Rect {
856 x: area.x + (area.width.saturating_sub(width)) / 2,
857 y: area.y + (area.height.saturating_sub(height)) / 2,
858 width,
859 height,
860 };
861 f.render_widget(Clear, popup);
862 let block = Block::default()
863 .title(Line::from(Span::styled(
864 " ⌂ Workspace ",
865 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
866 )))
867 .borders(Borders::ALL)
868 .border_type(BorderType::Rounded)
869 .border_style(Style::default().fg(p.key))
870 .padding(Padding::horizontal(1));
871 let items: Vec<ListItem> = app
872 .profiles
873 .iter()
874 .map(|name| {
875 let current = app.profile.as_deref() == Some(name.as_str());
876 let marker = if current { "● " } else { " " };
877 ListItem::new(Line::from(vec![
878 Span::styled(marker, Style::default().fg(p.ok)),
879 Span::styled(name.as_str(), Style::default().fg(p.text)),
880 ]))
881 })
882 .collect();
883 let list = List::new(items)
884 .block(block)
885 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
886 let mut state = ListState::default().with_selected(Some(selected));
887 f.render_stateful_widget(list, popup, &mut state);
888}
889
890fn bucket_color(bucket: &str, p: &Palette) -> Color {
891 match bucket {
892 "Jobs" => p.jobs,
893 "SQL" => p.warehouses,
894 "All-Purpose" => p.clusters,
895 "DLT" => p.pipelines,
896 _ => p.dim,
897 }
898}
899
900fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
901 let cv = app.cost.as_ref().unwrap();
902 let scope = match &cv.data {
903 Some(Ok(d)) if d.scoped => " · this workspace",
904 Some(Ok(_)) => " · all workspaces",
905 _ => "",
906 };
907 let title = Line::from(vec![
908 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
909 Span::styled(
910 format!("Usage · last 14 days{scope} "),
911 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
912 ),
913 Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
914 ]);
915 let block = Block::default()
916 .title(title)
917 .borders(Borders::ALL)
918 .border_type(BorderType::Rounded)
919 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
920 .padding(Padding::new(1, 1, 1, 1));
921
922 match &cv.data {
923 None => {
924 let par = Paragraph::new(format!(
925 "{} querying system.billing.usage — the warehouse may need to start…",
926 app.spinner()
927 ))
928 .style(Style::default().fg(p.warn))
929 .block(block);
930 f.render_widget(par, area);
931 }
932 Some(Err(e)) => {
933 let par = Paragraph::new(format!(
934 "✗ {e}\n\nsystem tables need to be enabled and readable \
935 (grants on the `system` catalog)"
936 ))
937 .style(Style::default().fg(p.err))
938 .wrap(Wrap { trim: false })
939 .block(block);
940 f.render_widget(par, area);
941 }
942 Some(Ok(data)) if data.days.is_empty() => {
943 let par = Paragraph::new("∅ no usage recorded in the last 14 days")
944 .style(Style::default().fg(p.dim))
945 .block(block);
946 f.render_widget(par, area);
947 }
948 Some(Ok(data)) => {
949 let mut lines: Vec<Line> = Vec::new();
950
951 let mut legend = vec![Span::raw("")];
953 for (bucket, total, usd) in &data.buckets {
954 legend.push(Span::styled(
955 "■ ",
956 Style::default().fg(bucket_color(bucket, p)),
957 ));
958 let amount = if data.priced {
959 format!("{bucket} {total:.1} (${usd:.0}) ")
960 } else {
961 format!("{bucket} {total:.1} ")
962 };
963 legend.push(Span::styled(amount, Style::default().fg(p.text)));
964 }
965 let sigma = if data.priced {
966 format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
967 } else {
968 format!("Σ {:.1} DBU", data.total)
969 };
970 legend.push(Span::styled(
971 sigma,
972 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
973 ));
974 lines.push(Line::from(legend));
975 if data.priced {
976 lines.push(Line::from(Span::styled(
977 " list prices before discounts",
978 Style::default().fg(p.dim),
979 )));
980 }
981 if !data.scoped {
982 lines.push(Line::from(Span::styled(
983 " couldn't resolve this workspace's id — showing the whole account",
984 Style::default().fg(p.warn),
985 )));
986 }
987 lines.push(Line::default());
988
989 let max_day = data
990 .days
991 .iter()
992 .map(|d| d.total)
993 .fold(0.0_f64, f64::max)
994 .max(f64::EPSILON);
995 let inner_w = area.width.saturating_sub(4) as usize;
996 let bar_w = inner_w.saturating_sub(22).max(10);
997
998 for day in &data.days {
999 let mut spans = vec![Span::styled(
1000 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1002 Style::default().fg(p.dim),
1003 )];
1004 for (bucket, value) in &day.by_bucket {
1005 let chars = ((value / max_day) * bar_w as f64)
1006 .round()
1007 .max(if *value > 0.0 { 1.0 } else { 0.0 })
1008 as usize;
1009 spans.push(Span::styled(
1010 "█".repeat(chars),
1011 Style::default().fg(bucket_color(bucket, p)),
1012 ));
1013 }
1014 let day_label = if data.priced {
1015 format!(" {:.1} · ${:.2}", day.total, day.total_usd)
1016 } else {
1017 format!(" {:.1}", day.total)
1018 };
1019 spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1020 lines.push(Line::from(spans));
1021 }
1022
1023 if !data.spenders.is_empty() {
1024 lines.push(Line::default());
1025 lines.push(Line::from(Span::styled(
1026 "TOP SPENDERS · 14 days",
1027 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1028 )));
1029 let name_w = data
1030 .spenders
1031 .iter()
1032 .map(|s| {
1033 app.resource_name(&s.kind, &s.id)
1034 .unwrap_or_else(|| s.id.clone())
1035 .chars()
1036 .count()
1037 })
1038 .max()
1039 .unwrap_or(0)
1040 .max(8);
1041 for (i, s) in data.spenders.iter().enumerate() {
1042 let name = app
1043 .resource_name(&s.kind, &s.id)
1044 .unwrap_or_else(|| s.id.clone());
1045 let kind_color = match s.kind.as_str() {
1046 "job" => p.jobs,
1047 "cluster" => p.clusters,
1048 "warehouse" => p.warehouses,
1049 _ => p.dim,
1050 };
1051 let amount = if data.priced {
1052 format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1053 } else {
1054 format!("{:>8.1} DBU", s.dbus)
1055 };
1056 lines.push(Line::from(vec![
1057 Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1058 Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1059 Span::styled(
1060 format!("{:<width$}", name, width = name_w + 2),
1061 Style::default().fg(p.text),
1062 ),
1063 Span::styled(amount, Style::default().fg(p.text)),
1064 ]));
1065 }
1066 }
1067
1068 let par = Paragraph::new(lines).block(block);
1069 f.render_widget(par, area);
1070 }
1071 }
1072}
1073
1074fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1075 let console = app.sql.as_ref().unwrap();
1076 let parts = Layout::default()
1077 .direction(Direction::Vertical)
1078 .constraints([Constraint::Length(3), Constraint::Min(0)])
1079 .split(area);
1080
1081 let via = if console.warehouse.is_empty() {
1082 String::new()
1083 } else {
1084 format!("via {} ", console.warehouse)
1085 };
1086 let prompt_block = Block::default()
1087 .title(Line::from(vec![
1088 Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1089 Span::styled(
1090 "SQL Console ",
1091 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1092 ),
1093 Span::styled(via, Style::default().fg(p.dim)),
1094 ]))
1095 .borders(Borders::ALL)
1096 .border_type(BorderType::Thick)
1097 .border_style(
1098 Style::default()
1099 .fg(p.warehouses)
1100 .add_modifier(Modifier::BOLD),
1101 )
1102 .padding(Padding::horizontal(1));
1103 if let Some((query, _)) = &app.hist_search {
1104 let matched = app
1106 .hist_search_current()
1107 .map(|s| s.replace('\n', " "))
1108 .unwrap_or_default();
1109 let prompt = Paragraph::new(Line::from(vec![
1110 Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1111 Span::styled(
1112 query.as_str(),
1113 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1114 ),
1115 Span::styled("`: ", Style::default().fg(p.dim)),
1116 Span::styled(matched, Style::default().fg(p.text)),
1117 ]))
1118 .block(prompt_block);
1119 f.render_widget(prompt, parts[0]);
1120 } else {
1121 let caret_byte = console
1125 .input
1126 .char_indices()
1127 .nth(console.cursor)
1128 .map(|(i, _)| i)
1129 .unwrap_or(console.input.len());
1130 let (before, after) = console.input.split_at(caret_byte);
1131 let inner_w = parts[0].width.saturating_sub(4) as usize; let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1133 let prompt = Paragraph::new(Line::from(vec![
1134 Span::styled("❯ ", Style::default().fg(p.key)),
1135 Span::styled(before.replace('\n', " "), Style::default().fg(p.text)),
1136 Span::styled("▏", Style::default().fg(p.warn)),
1137 Span::styled(after.replace('\n', " "), Style::default().fg(p.text)),
1138 ]))
1139 .scroll((0, hscroll))
1140 .block(prompt_block);
1141 f.render_widget(prompt, parts[0]);
1142 }
1143
1144 let row_info = match &console.data {
1145 Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1146 _ => String::new(),
1147 };
1148 let results_block = Block::default()
1149 .title(Line::from(vec![
1150 Span::styled(" Results ", Style::default().fg(p.text)),
1151 Span::styled(row_info, Style::default().fg(p.dim)),
1152 ]))
1153 .borders(Borders::ALL)
1154 .border_type(BorderType::Rounded)
1155 .border_style(Style::default().fg(p.border))
1156 .padding(Padding::horizontal(1));
1157
1158 if console.running {
1159 let par = Paragraph::new(format!(
1160 "{} running on {} — the warehouse may need a moment to start…",
1161 app.spinner(),
1162 console.warehouse
1163 ))
1164 .style(Style::default().fg(p.warn))
1165 .block(results_block);
1166 f.render_widget(par, parts[1]);
1167 return;
1168 }
1169 match &console.data {
1170 None => {
1171 let par = Paragraph::new(
1172 "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1173 )
1174 .style(Style::default().fg(p.dim))
1175 .block(results_block);
1176 f.render_widget(par, parts[1]);
1177 }
1178 Some(Err(e)) => {
1179 let par = Paragraph::new(format!("✗ {e}"))
1180 .style(Style::default().fg(p.err))
1181 .wrap(Wrap { trim: false })
1182 .block(results_block);
1183 f.render_widget(par, parts[1]);
1184 }
1185 Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1186 let par = Paragraph::new("✓ statement succeeded — no result set")
1187 .style(Style::default().fg(p.ok))
1188 .block(results_block);
1189 f.render_widget(par, parts[1]);
1190 }
1191 Some(Ok(data)) => {
1192 let header_cells: Vec<Cell> = data
1193 .headers
1194 .iter()
1195 .map(|h| {
1196 Cell::from(h.as_str()).style(
1197 Style::default()
1198 .fg(p.warehouses)
1199 .add_modifier(Modifier::BOLD),
1200 )
1201 })
1202 .collect();
1203 let header = Row::new(header_cells);
1204 let rows: Vec<Row> = data
1205 .rows
1206 .iter()
1207 .skip(console.scroll)
1208 .map(|r| {
1209 Row::new(
1210 r.iter()
1211 .map(|c| Cell::from(c.as_str()).style(Style::default().fg(p.text)))
1212 .collect::<Vec<_>>(),
1213 )
1214 })
1215 .collect();
1216 let n = data.headers.len().max(1) as u16;
1217 let widths: Vec<Constraint> = data
1218 .headers
1219 .iter()
1220 .map(|_| Constraint::Ratio(1, n as u32))
1221 .collect();
1222 let table = Table::new(rows, widths)
1223 .header(header)
1224 .column_spacing(1)
1225 .block(results_block);
1226 f.render_widget(table, parts[1]);
1227 }
1228 }
1229}
1230
1231fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1232 let pv = app.preview.as_ref().unwrap();
1233 let acc = p.catalog;
1234 let row_info = match &pv.data {
1235 Some(Ok(t)) => format!(" · {} rows", t.rows.len()),
1236 _ => String::new(),
1237 };
1238 let title = Line::from(vec![
1239 Span::styled(" ◫ ", Style::default().fg(acc)),
1240 Span::styled(
1241 format!("{}{} · preview ", pv.name, row_info),
1242 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1243 ),
1244 Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1245 ]);
1246 let block = Block::default()
1247 .title(title)
1248 .borders(Borders::ALL)
1249 .border_type(BorderType::Rounded)
1250 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1251 .padding(Padding::horizontal(1));
1252
1253 match &pv.data {
1254 None => {
1255 let par = Paragraph::new(format!(
1256 "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1257 app.spinner()
1258 ))
1259 .style(Style::default().fg(p.warn))
1260 .block(block);
1261 f.render_widget(par, area);
1262 }
1263 Some(Err(e)) => {
1264 let text = format!(
1265 "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1266 pv.warehouse,
1267 pv.warehouse_id,
1268 app.profile.as_deref().unwrap_or("default"),
1269 app.host.as_deref().unwrap_or("unknown"),
1270 );
1271 let par = Paragraph::new(text)
1272 .style(Style::default().fg(p.err))
1273 .wrap(Wrap { trim: false })
1274 .block(block);
1275 f.render_widget(par, area);
1276 }
1277 Some(Ok(data)) => {
1278 let header_cells: Vec<Cell> = data
1279 .headers
1280 .iter()
1281 .map(|h| {
1282 Cell::from(h.as_str())
1283 .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1284 })
1285 .collect();
1286 let header = Row::new(header_cells);
1287 let rows: Vec<Row> = data
1288 .rows
1289 .iter()
1290 .skip(pv.scroll)
1291 .map(|r| {
1292 Row::new(
1293 r.iter()
1294 .map(|c| Cell::from(c.as_str()).style(Style::default().fg(p.text)))
1295 .collect::<Vec<_>>(),
1296 )
1297 })
1298 .collect();
1299 let n = data.headers.len().max(1) as u16;
1300 let widths: Vec<Constraint> = data
1301 .headers
1302 .iter()
1303 .map(|_| Constraint::Ratio(1, n as u32))
1304 .collect();
1305 let table = Table::new(rows, widths)
1306 .header(header)
1307 .column_spacing(1)
1308 .block(block);
1309 f.render_widget(table, area);
1310 }
1311 }
1312}
1313
1314fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1315 let d = app.detail.as_ref().unwrap();
1316 let acc = accent(d.panel, p);
1317 let title = Line::from(vec![
1318 Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1319 Span::styled(
1320 format!("{} · {} ", d.panel.title(), d.name),
1321 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1322 ),
1323 ]);
1324 let block = Block::default()
1325 .title(title)
1326 .borders(Borders::ALL)
1327 .border_type(BorderType::Rounded)
1328 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1329 .padding(Padding::horizontal(1));
1330
1331 let Some(data) = &d.data else {
1332 let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1333 .style(Style::default().fg(p.warn))
1334 .block(block);
1335 f.render_widget(par, area);
1336 return;
1337 };
1338
1339 if d.show_raw || data.summary.is_empty() {
1341 let par = Paragraph::new(data.raw.as_str())
1342 .style(Style::default().fg(p.text))
1343 .wrap(Wrap { trim: false })
1344 .scroll((d.scroll, 0))
1345 .block(block);
1346 f.render_widget(par, area);
1347 return;
1348 }
1349
1350 let mut lines: Vec<Line> = data
1351 .summary
1352 .iter()
1353 .map(|(k, v)| {
1354 Line::from(vec![
1355 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1356 Span::styled(v.as_str(), Style::default().fg(p.text)),
1357 ])
1358 })
1359 .collect();
1360 if !data.activity.is_empty() {
1361 lines.push(Line::default());
1362 lines.push(Line::from(Span::styled(
1363 d.section,
1364 Style::default().fg(acc).add_modifier(Modifier::BOLD),
1365 )));
1366 for (status, text) in &data.activity {
1367 lines.push(Line::from(vec![
1368 Span::styled("● ", Style::default().fg(status_color(status, p))),
1369 Span::styled(text.as_str(), Style::default().fg(p.text)),
1370 ]));
1371 }
1372 }
1373 let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1374 f.render_widget(par, area);
1375}
1376
1377fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1378 let rv = app.run_view.as_ref().unwrap();
1379 let acc = accent(rv.panel, p);
1380 let noun = if rv.panel == Panel::Jobs {
1381 "run"
1382 } else {
1383 "update"
1384 };
1385 let mut title_spans = vec![
1386 Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1387 Span::styled(
1388 format!("{} · {noun} ", rv.owner_name),
1389 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1390 ),
1391 ];
1392 if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1393 title_spans.push(Span::styled(
1394 format!("{run_id} "),
1395 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1396 ));
1397 title_spans.push(Span::styled(
1398 format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1399 Style::default().fg(p.dim),
1400 ));
1401 }
1402 if rv.live {
1403 title_spans.push(Span::styled(
1404 format!("{} live ", app.spinner()),
1405 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1406 ));
1407 }
1408 let block = Block::default()
1409 .title(Line::from(title_spans))
1410 .borders(Borders::ALL)
1411 .border_type(BorderType::Rounded)
1412 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1413 .padding(Padding::horizontal(1));
1414
1415 let Some(data) = &rv.data else {
1416 let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
1417 .style(Style::default().fg(p.warn))
1418 .block(block);
1419 f.render_widget(par, area);
1420 return;
1421 };
1422
1423 if rv.show_raw || data.summary.is_empty() {
1424 let par = Paragraph::new(data.raw.as_str())
1425 .style(Style::default().fg(p.text))
1426 .wrap(Wrap { trim: false })
1427 .scroll((rv.scroll, 0))
1428 .block(block);
1429 f.render_widget(par, area);
1430 return;
1431 }
1432
1433 let mut lines: Vec<Line> = data
1434 .summary
1435 .iter()
1436 .map(|(k, v)| {
1437 Line::from(vec![
1438 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1439 Span::styled(v.as_str(), Style::default().fg(p.text)),
1440 ])
1441 })
1442 .collect();
1443 if !data.activity.is_empty() {
1444 lines.push(Line::default());
1445 lines.push(Line::from(Span::styled(
1446 if rv.panel == Panel::Jobs {
1447 "Tasks"
1448 } else {
1449 "Event log"
1450 },
1451 Style::default().fg(acc).add_modifier(Modifier::BOLD),
1452 )));
1453 for (status, text) in &data.activity {
1454 if text.starts_with(" ↳") {
1456 lines.push(Line::from(Span::styled(
1457 text.as_str(),
1458 Style::default().fg(p.err),
1459 )));
1460 } else {
1461 lines.push(Line::from(vec![
1462 Span::styled("● ", Style::default().fg(status_color(status, p))),
1463 Span::styled(text.as_str(), Style::default().fg(p.text)),
1464 ]));
1465 }
1466 }
1467 }
1468 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
1469 f.render_widget(par, area);
1470}
1471
1472fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1473 let block = Block::default()
1474 .borders(Borders::ALL)
1475 .border_type(BorderType::Rounded)
1476 .border_style(Style::default().fg(p.border));
1477 let inner = block.inner(area);
1478 f.render_widget(block, area);
1479
1480 let (from, to) = (p.grad_from, p.grad_to);
1484 let grad = |t: f32| {
1485 Color::Rgb(
1486 (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
1487 (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
1488 (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
1489 )
1490 };
1491 let mut left = vec![
1492 Span::styled(
1493 " ◢",
1494 Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
1495 ),
1496 Span::styled(
1497 "◤ ",
1498 Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
1499 ),
1500 ];
1501 let word: Vec<char> = "DATABRICKS".chars().collect();
1502 let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
1503 for (i, ch) in word.iter().enumerate() {
1504 let t = i as f32 / (word.len() - 1) as f32;
1505 let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
1506 Style::default().fg(p.text).add_modifier(Modifier::BOLD)
1507 } else {
1508 Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
1509 };
1510 left.push(Span::styled(ch.to_string(), style));
1511 }
1512 left.push(Span::raw(" "));
1513 for ch in "LAKEHOUSE".chars() {
1514 left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
1515 }
1516 left.push(Span::styled(
1517 format!("· v{}", env!("CARGO_PKG_VERSION")),
1518 Style::default().fg(p.dim),
1519 ));
1520 if let Some(profile) = &app.profile {
1521 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
1522 left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
1523 left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
1524 }
1525 if let Some(Shape::Badge(b)) = &app.user_badge {
1526 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
1527 left.push(Span::styled(
1528 format!("{} {}", b.label, b.value),
1529 Style::default().fg(p.key),
1530 ));
1531 }
1532 if app.zoomed {
1533 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
1534 left.push(Span::styled(
1535 format!("⛶ {}", app.focus.title()),
1536 Style::default()
1537 .fg(accent(app.focus, p))
1538 .add_modifier(Modifier::BOLD),
1539 ));
1540 }
1541
1542 let (ok, pending, failed, idle) = app.status_counts();
1544 for (count, glyph, color) in [
1545 (ok, "●", p.ok),
1546 (pending, "◐", p.warn),
1547 (failed, "✗", p.err),
1548 (idle, "○", p.dim),
1549 ] {
1550 if count > 0 {
1551 left.push(Span::styled(" ", Style::default()));
1552 left.push(Span::styled(
1553 format!("{glyph} {count}"),
1554 Style::default().fg(color),
1555 ));
1556 }
1557 }
1558 f.render_widget(Paragraph::new(Line::from(left)), inner);
1559
1560 let right = if app.loading {
1561 Line::from(Span::styled(
1562 format!("{} refreshing ", app.spinner()),
1563 Style::default().fg(p.warn),
1564 ))
1565 } else {
1566 Line::from(Span::styled(
1567 format!("updated {}s ago ", app.last_refresh_age().as_secs()),
1568 Style::default().fg(p.dim),
1569 ))
1570 };
1571 f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
1572}
1573
1574fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1575 let key =
1576 |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
1577 let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
1578
1579 if let Some(confirm) = &app.confirm {
1580 let line = Line::from(vec![
1581 Span::styled(
1582 format!(" ⚠ {} ", confirm.message),
1583 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1584 ),
1585 key("y"),
1586 dim(" confirm · any other key cancels"),
1587 ]);
1588 f.render_widget(Paragraph::new(line), area);
1589 return;
1590 }
1591
1592 if app.filter_entry {
1593 let line = Line::from(vec![
1594 Span::styled(
1595 format!(" /{}", app.active_filter()),
1596 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1597 ),
1598 Span::styled("▏", Style::default().fg(p.warn)),
1599 dim(" type to filter "),
1600 key("enter"),
1601 dim(" apply "),
1602 key("esc"),
1603 dim(" clear"),
1604 ]);
1605 f.render_widget(Paragraph::new(line), area);
1606 return;
1607 }
1608
1609 let mut spans = if app.help {
1610 vec![
1611 dim(" "),
1612 key("j"),
1613 dim("/"),
1614 key("k"),
1615 dim(" scroll any other key closes"),
1616 ]
1617 } else if app.pane_cfg.is_some() {
1618 vec![
1619 dim(" "),
1620 key("j"),
1621 dim("/"),
1622 key("k"),
1623 dim(" select "),
1624 key("space"),
1625 dim(" show/hide "),
1626 key("J"),
1627 dim("/"),
1628 key("K"),
1629 dim(" move "),
1630 key("esc"),
1631 dim(" done"),
1632 ]
1633 } else if app.jump.is_some() {
1634 vec![
1635 dim(" type to search everything "),
1636 key("\u{2191}"),
1637 dim("/"),
1638 key("\u{2193}"),
1639 dim(" select "),
1640 key("enter"),
1641 dim(" jump "),
1642 key("esc"),
1643 dim(" close"),
1644 ]
1645 } else if app.problems.is_some() {
1646 vec![
1647 dim(" "),
1648 key("j"),
1649 dim("/"),
1650 key("k"),
1651 dim(" select "),
1652 key("enter"),
1653 dim(" jump to item "),
1654 key("esc"),
1655 dim(" close"),
1656 ]
1657 } else if app.cost.is_some() {
1658 vec![
1659 dim(" "),
1660 key("esc"),
1661 dim(" back "),
1662 key("q"),
1663 dim(" quit"),
1664 ]
1665 } else if app.wh_picker.is_some() {
1666 vec![
1667 dim(" "),
1668 key("j"),
1669 dim("/"),
1670 key("k"),
1671 dim(" select "),
1672 key("enter"),
1673 dim(" run preview "),
1674 key("esc"),
1675 dim(" cancel"),
1676 ]
1677 } else if app.sql.is_some() {
1678 if app.hist_search.is_some() {
1679 vec![
1680 dim(" type to search history "),
1681 key("^r"),
1682 dim(" older match "),
1683 key("enter"),
1684 dim(" accept "),
1685 key("esc"),
1686 dim(" cancel"),
1687 ]
1688 } else {
1689 vec![
1690 dim(" "),
1691 key("enter"),
1692 dim(" run "),
1693 key("↑"),
1694 dim("/"),
1695 key("↓"),
1696 dim(" history "),
1697 key("^r"),
1698 dim(" search "),
1699 key("^x"),
1700 dim(" editor "),
1701 key("pgup"),
1702 dim("/"),
1703 key("pgdn"),
1704 dim(" scroll "),
1705 key("^s"),
1706 dim(" export "),
1707 key("esc"),
1708 dim(if app.sql.as_ref().is_some_and(|c| c.running) {
1709 " cancel query"
1710 } else {
1711 " close"
1712 }),
1713 ]
1714 }
1715 } else if app.preview.is_some() {
1716 vec![
1717 dim(" "),
1718 key("esc"),
1719 dim(" back "),
1720 key("j"),
1721 dim("/"),
1722 key("k"),
1723 dim(" scroll rows "),
1724 key("e"),
1725 dim(" export csv "),
1726 key("q"),
1727 dim(" quit"),
1728 ]
1729 } else if app.picker.is_some() {
1730 vec![
1731 dim(" "),
1732 key("j"),
1733 dim("/"),
1734 key("k"),
1735 dim(" select "),
1736 key("enter"),
1737 dim(" switch workspace "),
1738 key("esc"),
1739 dim(" cancel"),
1740 ]
1741 } else if app.run_view.is_some() {
1742 vec![
1743 dim(" "),
1744 key("esc"),
1745 dim(" back "),
1746 key("h"),
1747 dim("/"),
1748 key("l"),
1749 dim(" older/newer "),
1750 key("s"),
1751 dim(" cancel "),
1752 key("j"),
1753 dim("/"),
1754 key("k"),
1755 dim(" scroll "),
1756 key("J"),
1757 dim(" raw "),
1758 key("q"),
1759 dim(" quit"),
1760 ]
1761 } else if app.detail.is_some() {
1762 let mut spans = vec![dim(" "), key("esc"), dim(" back ")];
1763 match app.detail.as_ref() {
1764 Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
1765 spans.push(key("enter"));
1766 spans.push(dim(" latest run "));
1767 }
1768 Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
1769 spans.push(key("enter"));
1770 spans.push(dim(" latest update "));
1771 }
1772 _ => {}
1773 }
1774 spans.extend([
1775 key("j"),
1776 dim("/"),
1777 key("k"),
1778 dim(" scroll "),
1779 key("J"),
1780 dim(" raw "),
1781 key("o"),
1782 dim(" open "),
1783 key("q"),
1784 dim(" quit"),
1785 ]);
1786 spans
1787 } else {
1788 let mut spans = vec![
1789 dim(" "),
1790 key("tab"),
1791 dim("/"),
1792 key("h"),
1793 dim("/"),
1794 key("l"),
1795 dim(" switch "),
1796 key("j"),
1797 dim("/"),
1798 key("k"),
1799 dim(" select "),
1800 key("/"),
1801 dim(if app.active_filter().is_empty() {
1802 " filter "
1803 } else {
1804 " filter · esc clears "
1805 }),
1806 key("enter"),
1807 dim(if app.focus == Panel::Catalog && app.uc_path.len() < 2 {
1808 " open "
1809 } else {
1810 " details "
1811 }),
1812 ];
1813 match app.focus {
1815 Panel::Catalog => {
1816 if !app.uc_path.is_empty() {
1817 spans.push(key("bksp"));
1818 spans.push(dim(" up "));
1819 }
1820 if app.uc_path.len() == 2 {
1821 spans.push(key("p"));
1822 spans.push(dim(" preview "));
1823 spans.push(key("L"));
1824 spans.push(dim(" lineage "));
1825 spans.push(key(":"));
1826 spans.push(dim(" query table "));
1827 }
1828 }
1829 Panel::Dashboards => {}
1830 _ => {
1831 spans.push(key("s"));
1832 spans.push(dim(" action "));
1833 }
1834 }
1835 spans.extend([
1836 key("g"),
1837 dim(" access "),
1838 key("$"),
1839 dim(" cost "),
1840 key("!"),
1841 dim(" problems "),
1842 ]);
1843 if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
1845 spans.push(key(":"));
1846 spans.push(dim(" sql "));
1847 }
1848 spans.extend([
1849 key("o"),
1850 dim(" open "),
1851 key("z"),
1852 dim(if app.zoomed { " unzoom " } else { " zoom " }),
1853 key("w"),
1854 dim(" workspace "),
1855 key("t"),
1856 dim(" theme "),
1857 key("r"),
1858 dim(" refresh "),
1859 key("?"),
1860 dim(" help "),
1861 key("q"),
1862 dim(" quit"),
1863 ]);
1864 spans
1865 };
1866 if let Some((msg, _)) = &app.flash {
1868 let color = if msg.starts_with('✗') { p.err } else { p.ok };
1869 let mut prefixed = vec![
1870 Span::styled(
1871 format!(" {msg}"),
1872 Style::default().fg(color).add_modifier(Modifier::BOLD),
1873 ),
1874 dim(" · "),
1875 ];
1876 prefixed.append(&mut spans);
1877 spans = prefixed;
1878 }
1879 f.render_widget(Paragraph::new(Line::from(spans)), area);
1880}
1881
1882#[allow(clippy::too_many_arguments)]
1883fn draw_panel(
1884 f: &mut Frame,
1885 area: Rect,
1886 panel: Panel,
1887 shape: Option<&Shape>,
1888 focused: bool,
1889 selected: Option<usize>,
1890 fresh: bool,
1891 breadcrumb: &str,
1892 spinner: &str,
1893 filter: &str,
1894 entering: bool,
1895 p: &Palette,
1896) {
1897 let accent = accent(panel, p);
1898 let (border_style, title_style) = if focused {
1899 (
1900 Style::default().fg(accent).add_modifier(Modifier::BOLD),
1901 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1902 )
1903 } else {
1904 (Style::default().fg(p.border), Style::default().fg(p.dim))
1905 };
1906 let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
1907 Some(Shape::List(items)) => Some(
1908 items
1909 .iter()
1910 .filter(|it| crate::shape::item_matches(it, filter))
1911 .collect(),
1912 ),
1913 _ => None,
1914 };
1915 let count = match (&visible, shape) {
1916 (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
1918 format!(" · {}/{}", v.len(), items.len())
1919 }
1920 (Some(v), _) => format!(" · {}", v.len()),
1921 (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
1922 _ => String::new(),
1923 };
1924 let crumb = if panel == Panel::Catalog && !breadcrumb.is_empty() {
1925 format!(" ▸ {breadcrumb}")
1926 } else {
1927 String::new()
1928 };
1929 let title_style = if fresh {
1931 Style::default()
1932 .bg(accent)
1933 .fg(Color::Black)
1934 .add_modifier(Modifier::BOLD)
1935 } else {
1936 title_style
1937 };
1938 let mut title_spans = vec![
1939 Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
1940 Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
1941 ];
1942 if entering || !filter.is_empty() {
1943 title_spans.push(Span::styled(
1944 format!("/{}{} ", filter, if entering { "▏" } else { "" }),
1945 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1946 ));
1947 }
1948 let title = Line::from(title_spans);
1949 let block = Block::default()
1950 .title(title)
1951 .borders(Borders::ALL)
1952 .border_type(if focused {
1953 BorderType::Thick
1954 } else {
1955 BorderType::Rounded
1956 })
1957 .border_style(border_style)
1958 .padding(Padding::horizontal(1));
1959
1960 match shape {
1961 None => {
1962 let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
1963 .style(Style::default().fg(p.warn))
1964 .block(block);
1965 f.render_widget(par, area);
1966 }
1967 Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
1968 let empty = if !items.is_empty() {
1969 format!("∅ nothing matches /{filter}")
1970 } else {
1971 let msg = match panel {
1972 Panel::Clusters => "no compute running",
1973 Panel::Jobs => "no jobs defined",
1974 Panel::Pipelines => "no pipelines",
1975 Panel::Warehouses => "no warehouses",
1976 Panel::Dashboards => "no dashboards yet",
1977 Panel::Catalog => "nothing at this level",
1978 };
1979 format!("∅ {msg}")
1980 };
1981 let par = Paragraph::new(empty)
1982 .style(Style::default().fg(p.dim))
1983 .block(block);
1984 f.render_widget(par, area);
1985 }
1986 Some(Shape::List(_)) => {
1987 let list_items: Vec<ListItem> = visible
1988 .unwrap_or_default()
1989 .iter()
1990 .map(|item| {
1991 let color = status_color(&item.status, p);
1992 let chip = match item.status {
1993 Status::Stopped | Status::Unknown(_) => Span::styled(
1994 format!(" {} ", item.status.label()),
1995 Style::default().fg(color).add_modifier(Modifier::DIM),
1996 ),
1997 _ => Span::styled(
1998 format!(" {} ", item.status.label()),
1999 Style::default()
2000 .bg(color)
2001 .fg(Color::Black)
2002 .add_modifier(Modifier::BOLD),
2003 ),
2004 };
2005 let mut spans = vec![
2006 Span::styled("● ", Style::default().fg(color)),
2007 Span::styled(item.name.as_str(), Style::default().fg(p.text)),
2008 Span::raw(" "),
2009 chip,
2010 ];
2011 if !item.history.is_empty() {
2012 spans.push(Span::raw(" "));
2013 for run in &item.history {
2014 spans.push(Span::styled(
2015 history_glyph(run),
2016 Style::default().fg(status_color(run, p)),
2017 ));
2018 }
2019 }
2020 if let Some(detail) = &item.detail {
2021 spans.push(Span::styled(
2022 format!(" {}", detail),
2023 Style::default().fg(p.dim),
2024 ));
2025 }
2026 ListItem::new(Line::from(spans))
2027 })
2028 .collect();
2029 let list = List::new(list_items)
2030 .block(block)
2031 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
2032 match selected {
2033 Some(sel) => {
2034 let mut state = ListState::default().with_selected(Some(sel));
2035 f.render_stateful_widget(list, area, &mut state);
2036 }
2037 None => f.render_widget(list, area),
2038 }
2039 }
2040 Some(Shape::Table(data)) => {
2041 let header_cells: Vec<Cell> = data
2042 .headers
2043 .iter()
2044 .map(|h| {
2045 Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
2046 })
2047 .collect();
2048 let header = Row::new(header_cells).style(Style::default().fg(accent));
2049 let rows: Vec<Row> = data
2050 .rows
2051 .iter()
2052 .map(|r| Row::new(r.iter().map(|c| Cell::from(c.as_str())).collect::<Vec<_>>()))
2053 .collect();
2054 let widths: Vec<Constraint> = data
2055 .headers
2056 .iter()
2057 .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
2058 .collect();
2059 let table = Table::new(rows, widths).header(header).block(block);
2060 f.render_widget(table, area);
2061 }
2062 Some(Shape::Badge(b)) => {
2063 let text = format!("{}: {}", b.label, b.value);
2064 let par = Paragraph::new(text)
2065 .style(Style::default().fg(p.text))
2066 .block(block);
2067 f.render_widget(par, area);
2068 }
2069 Some(Shape::Text(t)) => {
2070 let color = if t.starts_with('✗') { p.err } else { p.text };
2071 let par = Paragraph::new(t.as_str())
2072 .style(Style::default().fg(color))
2073 .wrap(Wrap { trim: false })
2074 .block(block);
2075 f.render_widget(par, area);
2076 }
2077 }
2078}
2079
2080fn status_color(status: &Status, p: &Palette) -> Color {
2081 match status {
2082 Status::Running | Status::Success => p.ok,
2083 Status::Stopped => p.dim,
2084 Status::Pending => p.warn,
2085 Status::Failed => p.err,
2086 Status::Unknown(_) => p.text,
2087 }
2088}
2089
2090fn history_glyph(status: &Status) -> &'static str {
2091 match status {
2092 Status::Success => "✓",
2093 Status::Failed => "✗",
2094 Status::Running => "●",
2095 Status::Pending => "◐",
2096 Status::Stopped => "○",
2097 Status::Unknown(_) => "·",
2098 }
2099}