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