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