1use crate::app::{App, Panel, ThemeMode};
2use crate::shape::{DetailData, Shape, Status, TableData};
3use ratatui::{
4 layout::{Alignment, Constraint, Direction, Layout, Rect},
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7 widgets::{
8 Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph,
9 Row, Table, Wrap,
10 },
11 Frame,
12};
13use std::time::Duration;
14
15struct Palette {
16 text: Color,
17 dim: Color,
18 border: Color,
19 warn: Color,
20 ok: Color,
21 err: Color,
22 key: Color,
23 brand: Color,
24 clusters: Color,
25 jobs: Color,
26 pipelines: Color,
27 warehouses: Color,
28 catalog: Color,
29 grad_from: (u8, u8, u8),
31 grad_to: (u8, u8, u8),
32}
33
34const fn rgb(hex: u32) -> Color {
35 Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
36}
37
38const fn rgb3(hex: u32) -> (u8, u8, u8) {
39 ((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
40}
41
42fn palette(mode: ThemeMode) -> Palette {
43 match mode {
44 ThemeMode::Dark => Palette {
46 text: Color::White,
47 dim: Color::DarkGray,
48 border: Color::DarkGray,
49 warn: Color::Yellow,
50 ok: Color::Green,
51 err: Color::Red,
52 key: Color::Cyan,
53 brand: Color::Red,
54 clusters: Color::Cyan,
55 jobs: Color::Magenta,
56 pipelines: Color::Green,
57 warehouses: Color::Blue,
58 catalog: rgb(0xFF8C42),
59 grad_from: rgb3(0xFF3621),
60 grad_to: rgb3(0xFFA046),
61 },
62 ThemeMode::Light => Palette {
64 text: Color::Black,
65 dim: rgb(0x6B7280),
66 border: rgb(0x9CA3AF),
67 warn: rgb(0xB45309),
68 ok: rgb(0x15803D),
69 err: rgb(0xB91C1C),
70 key: rgb(0x0891B2),
71 brand: rgb(0xDC2626),
72 clusters: rgb(0x0891B2),
73 jobs: rgb(0xA21CAF),
74 pipelines: rgb(0x15803D),
75 warehouses: rgb(0x1D4ED8),
76 catalog: rgb(0xC2410C),
77 grad_from: rgb3(0xB91C1C),
78 grad_to: rgb3(0xC2410C),
79 },
80 ThemeMode::CatppuccinMocha => Palette {
81 text: rgb(0xCDD6F4),
82 dim: rgb(0x6C7086),
83 border: rgb(0x585B70),
84 warn: rgb(0xF9E2AF),
85 ok: rgb(0xA6E3A1),
86 err: rgb(0xF38BA8),
87 key: rgb(0x89DCEB),
88 brand: rgb(0xF38BA8),
89 clusters: rgb(0x89DCEB),
90 jobs: rgb(0xCBA6F7),
91 pipelines: rgb(0xA6E3A1),
92 warehouses: rgb(0x89B4FA),
93 catalog: rgb(0xFAB387),
94 grad_from: rgb3(0xF38BA8),
95 grad_to: rgb3(0xFAB387),
96 },
97 ThemeMode::CatppuccinLatte => Palette {
98 text: rgb(0x4C4F69),
99 dim: rgb(0x8C8FA1),
100 border: rgb(0xACB0BE),
101 warn: rgb(0xDF8E1D),
102 ok: rgb(0x40A02B),
103 err: rgb(0xD20F39),
104 key: rgb(0x04A5E5),
105 brand: rgb(0xD20F39),
106 clusters: rgb(0x04A5E5),
107 jobs: rgb(0x8839EF),
108 pipelines: rgb(0x40A02B),
109 warehouses: rgb(0x1E66F5),
110 catalog: rgb(0xFE640B),
111 grad_from: rgb3(0xD20F39),
112 grad_to: rgb3(0xFE640B),
113 },
114 ThemeMode::GruvboxDark => Palette {
115 text: rgb(0xEBDBB2),
116 dim: rgb(0x928374),
117 border: rgb(0x665C54),
118 warn: rgb(0xFABD2F),
119 ok: rgb(0xB8BB26),
120 err: rgb(0xFB4934),
121 key: rgb(0x8EC07C),
122 brand: rgb(0xFB4934),
123 clusters: rgb(0x8EC07C),
124 jobs: rgb(0xD3869B),
125 pipelines: rgb(0xB8BB26),
126 warehouses: rgb(0x83A598),
127 catalog: rgb(0xFE8019),
128 grad_from: rgb3(0xFB4934),
129 grad_to: rgb3(0xFE8019),
130 },
131 ThemeMode::Dracula => Palette {
132 text: rgb(0xF8F8F2),
133 dim: rgb(0x6272A4),
134 border: rgb(0x44475A),
135 warn: rgb(0xF1FA8C),
136 ok: rgb(0x50FA7B),
137 err: rgb(0xFF5555),
138 key: rgb(0x8BE9FD),
139 brand: rgb(0xFF5555),
140 clusters: rgb(0x8BE9FD),
141 jobs: rgb(0xFF79C6),
142 pipelines: rgb(0x50FA7B),
143 warehouses: rgb(0xBD93F9),
144 catalog: rgb(0xFFB86C),
145 grad_from: rgb3(0xFF5555),
146 grad_to: rgb3(0xFFB86C),
147 },
148 ThemeMode::Nord => Palette {
149 text: rgb(0xD8DEE9),
150 dim: rgb(0x4C566A),
151 border: rgb(0x434C5E),
152 warn: rgb(0xEBCB8B),
153 ok: rgb(0xA3BE8C),
154 err: rgb(0xBF616A),
155 key: rgb(0x88C0D0),
156 brand: rgb(0xBF616A),
157 clusters: rgb(0x88C0D0),
158 jobs: rgb(0xB48EAD),
159 pipelines: rgb(0xA3BE8C),
160 warehouses: rgb(0x81A1C1),
161 catalog: rgb(0xD08770),
162 grad_from: rgb3(0xBF616A),
163 grad_to: rgb3(0xD08770),
164 },
165 ThemeMode::TokyoNight => Palette {
166 text: rgb(0xC0CAF5),
167 dim: rgb(0x565F89),
168 border: rgb(0x3B4261),
169 warn: rgb(0xE0AF68),
170 ok: rgb(0x9ECE6A),
171 err: rgb(0xF7768E),
172 key: rgb(0x7DCFFF),
173 brand: rgb(0xF7768E),
174 clusters: rgb(0x7DCFFF),
175 jobs: rgb(0xBB9AF7),
176 pipelines: rgb(0x9ECE6A),
177 warehouses: rgb(0x7AA2F7),
178 catalog: rgb(0xFF9E64),
179 grad_from: rgb3(0xF7768E),
180 grad_to: rgb3(0xFF9E64),
181 },
182 }
183}
184
185fn accent(panel: Panel, p: &Palette) -> Color {
186 match panel {
187 Panel::Clusters => p.clusters,
188 Panel::Jobs => p.jobs,
189 Panel::Pipelines => p.pipelines,
190 Panel::Warehouses => p.warehouses,
191 Panel::Dashboards => p.key,
192 Panel::Catalog => p.catalog,
193 Panel::Secrets => p.warn,
194 }
195}
196
197fn pane_breadcrumb(app: &App, panel: Panel) -> String {
198 match panel {
199 Panel::Catalog => app.uc_path.join("."),
200 Panel::Secrets => app.secret_scope.clone().unwrap_or_default(),
201 _ => String::new(),
202 }
203}
204
205pub fn draw(f: &mut Frame, app: &App) {
206 let p = palette(app.theme);
207
208 if app.splash_active() {
209 draw_splash(f, f.area(), app, &p);
210 return;
211 }
212
213 let root = Layout::default()
214 .direction(Direction::Vertical)
215 .constraints([
216 Constraint::Length(3),
217 Constraint::Min(0),
218 Constraint::Length(1),
219 ])
220 .split(f.area());
221
222 draw_header(f, root[0], app, &p);
223 draw_footer(f, root[2], app, &p);
224
225 if app.sql.is_some() {
226 draw_sql(f, root[1], app, &p);
227 if app.sql_complete.is_some() {
228 draw_sql_complete(f, root[1], app, &p);
229 }
230 if app.wh_picker.is_some() {
232 draw_wh_picker(f, root[1], app, &p);
233 }
234 return;
235 }
236
237 if app.cost.is_some() {
238 draw_cost(f, root[1], app, &p);
239 return;
240 }
241
242 if app.preview.is_some() {
243 draw_preview(f, root[1], app, &p);
244 return;
245 }
246
247 if app.run_view.is_some() {
248 draw_run(f, root[1], app, &p);
249 return;
250 }
251
252 if app.detail.is_some() {
253 draw_detail(f, root[1], app, &p);
254 return;
255 }
256
257 if app.zoomed {
258 let idx = Panel::ALL
259 .iter()
260 .position(|pn| pn == &app.focus)
261 .unwrap_or(0);
262 draw_panel(
263 f,
264 root[1],
265 app.focus,
266 app.shapes[idx].as_ref(),
267 true,
268 Some(app.selection(idx)),
269 false,
270 &pane_breadcrumb(app, app.focus),
271 app.spinner(),
272 &app.filters[idx],
273 app.filter_entry,
274 &p,
275 );
276 if app.picker.is_some() {
278 draw_picker(f, root[1], app, &p);
279 }
280 if app.wh_picker.is_some() {
281 draw_wh_picker(f, root[1], app, &p);
282 }
283 if app.problems.is_some() {
284 draw_problems(f, root[1], app, &p);
285 }
286 if app.jump.is_some() {
287 draw_jump(f, root[1], app, &p);
288 }
289 if app.pane_cfg.is_some() {
290 draw_pane_cfg(f, root[1], app, &p);
291 }
292 if app.secret_form.is_some() {
293 draw_secret_form(f, root[1], app, &p);
294 }
295 if app.help {
296 draw_help(f, root[1], app, &p);
297 }
298 return;
299 }
300
301 let visible = app.visible_panes();
304 if visible.is_empty() {
305 let par = Paragraph::new("all panes hidden — press H to bring them back")
306 .style(Style::default().fg(p.dim))
307 .alignment(Alignment::Center);
308 f.render_widget(par, root[1]);
309 } else {
310 let rows_of = |n: usize| -> Vec<Constraint> {
311 (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
312 };
313 let left_count = visible.len().div_ceil(2);
314 let right_count = visible.len() - left_count;
315 let mut areas: Vec<Rect> = Vec::new();
316 if right_count == 0 {
317 areas.extend(
318 Layout::default()
319 .direction(Direction::Vertical)
320 .constraints(rows_of(left_count))
321 .split(root[1])
322 .iter(),
323 );
324 } else {
325 let body = Layout::default()
326 .direction(Direction::Horizontal)
327 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
328 .split(root[1]);
329 areas.extend(
330 Layout::default()
331 .direction(Direction::Vertical)
332 .constraints(rows_of(left_count))
333 .split(body[0])
334 .iter(),
335 );
336 areas.extend(
337 Layout::default()
338 .direction(Direction::Vertical)
339 .constraints(rows_of(right_count))
340 .split(body[1])
341 .iter(),
342 );
343 }
344
345 for (slot, &i) in visible.iter().enumerate() {
346 let panel = Panel::ALL[i];
347 let focused = app.focus == panel;
348 let shape = app.shapes[i].as_ref();
349 let selected = focused.then(|| app.selection(i));
350 let fresh = app.updated_at[i]
351 .map(|t| t.elapsed() < Duration::from_millis(1200))
352 .unwrap_or(false);
353 draw_panel(
354 f,
355 areas[slot],
356 panel,
357 shape,
358 focused,
359 selected,
360 fresh,
361 &pane_breadcrumb(app, panel),
362 app.spinner(),
363 &app.filters[i],
364 app.filter_entry && focused,
365 &p,
366 );
367 }
368 }
369
370 if app.picker.is_some() {
371 draw_picker(f, root[1], app, &p);
372 }
373 if app.wh_picker.is_some() {
374 draw_wh_picker(f, root[1], app, &p);
375 }
376 if app.problems.is_some() {
377 draw_problems(f, root[1], app, &p);
378 }
379 if app.jump.is_some() {
380 draw_jump(f, root[1], app, &p);
381 }
382 if app.pane_cfg.is_some() {
383 draw_pane_cfg(f, root[1], app, &p);
384 }
385 if app.secret_form.is_some() {
386 draw_secret_form(f, root[1], app, &p);
387 }
388 if app.help {
389 draw_help(f, root[1], app, &p);
390 }
391}
392
393fn draw_secret_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
394 let Some(form) = &app.secret_form else {
395 return;
396 };
397 let width = 60.min(area.width.saturating_sub(4));
398 let height = 6.min(area.height);
399 let popup = Rect {
400 x: area.x + (area.width.saturating_sub(width)) / 2,
401 y: area.y + (area.height.saturating_sub(height)) / 2,
402 width,
403 height,
404 };
405 f.render_widget(Clear, popup);
406 let title = match &form.scope {
407 None => "New secret scope ".to_string(),
408 Some(s) => format!("New secret in {s} "),
409 };
410 let block = Block::default()
411 .title(Line::from(vec![
412 Span::styled(" ◈ ", Style::default().fg(p.warn)),
413 Span::styled(
414 title,
415 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
416 ),
417 ]))
418 .borders(Borders::ALL)
419 .border_type(BorderType::Thick)
420 .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
421 .padding(Padding::new(1, 1, 1, 0));
422 let label0 = if form.scope.is_none() {
423 "name "
424 } else {
425 "key "
426 };
427 let mut lines = vec![Line::from(vec![
428 Span::styled(label0, Style::default().fg(p.dim)),
429 Span::styled(form.key.as_str(), Style::default().fg(p.text)),
430 if form.stage == 0 {
431 Span::styled("▏", Style::default().fg(p.warn))
432 } else {
433 Span::raw("")
434 },
435 ])];
436 if form.scope.is_some() {
437 lines.push(Line::from(vec![
439 Span::styled("value ", Style::default().fg(p.dim)),
440 Span::styled(
441 "•".repeat(form.value.chars().count()),
442 Style::default().fg(p.text),
443 ),
444 if form.stage == 1 {
445 Span::styled("▏", Style::default().fg(p.warn))
446 } else {
447 Span::raw("")
448 },
449 ]));
450 }
451 f.render_widget(Paragraph::new(lines).block(block), popup);
452}
453
454fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
455 let Some(selected) = app.pane_cfg else {
456 return;
457 };
458 let width = 52.min(area.width.saturating_sub(4));
459 let height = 11.min(area.height);
460 let popup = Rect {
461 x: area.x + (area.width.saturating_sub(width)) / 2,
462 y: area.y + (area.height.saturating_sub(height)) / 2,
463 width,
464 height,
465 };
466 f.render_widget(Clear, popup);
467 let block = Block::default()
468 .title(Line::from(vec![
469 Span::styled(" ▦ ", Style::default().fg(p.key)),
470 Span::styled(
471 "Panes ",
472 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
473 ),
474 ]))
475 .borders(Borders::ALL)
476 .border_type(BorderType::Thick)
477 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
478 .padding(Padding::new(1, 1, 1, 0));
479 let items: Vec<ListItem> = app
480 .pane_order
481 .iter()
482 .map(|&i| {
483 let panel = Panel::ALL[i];
484 let (mark, style) = if app.hidden[i] {
485 ("○ ", Style::default().fg(p.dim))
486 } else {
487 ("● ", Style::default().fg(p.ok))
488 };
489 ListItem::new(Line::from(vec![
490 Span::styled(mark, style),
491 Span::styled(
492 format!("{} ", panel.icon()),
493 Style::default().fg(accent(panel, p)),
494 ),
495 Span::styled(
496 panel.title(),
497 if app.hidden[i] {
498 Style::default().fg(p.dim)
499 } else {
500 Style::default().fg(p.text)
501 },
502 ),
503 ]))
504 })
505 .collect();
506 let list = List::new(items)
507 .block(block)
508 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
509 let mut state = ListState::default().with_selected(Some(selected));
510 f.render_stateful_widget(list, popup, &mut state);
511}
512
513fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
514 let width = 74.min(area.width.saturating_sub(4));
515 let height = area.height.saturating_sub(2);
516 let popup = Rect {
517 x: area.x + (area.width.saturating_sub(width)) / 2,
518 y: area.y + 1,
519 width,
520 height,
521 };
522 f.render_widget(Clear, popup);
523 let block = Block::default()
524 .title(Line::from(vec![
525 Span::styled(" ? ", Style::default().fg(p.key)),
526 Span::styled(
527 "Keys ",
528 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
529 ),
530 ]))
531 .borders(Borders::ALL)
532 .border_type(BorderType::Thick)
533 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
534 .padding(Padding::new(1, 1, 1, 0));
535
536 let sections: &[(&str, &[(&str, &str)])] = &[
537 (
538 "Global",
539 &[
540 ("tab / l, shift+tab / h", "focus next / previous pane"),
541 ("j / k", "select item"),
542 ("enter", "open details / drill down"),
543 ("/", "filter the focused pane (esc clears)"),
544 ("ctrl+p", "command palette: fuzzy-jump anywhere"),
545 ("!", "problems: everything failing, enter jumps"),
546 (":", "SQL console (prefilled on a catalog table)"),
547 ("$", "cost view: DBUs, dollars, top spenders"),
548 ("H", "arrange panes: hide and reorder"),
549 ("z", "zoom the focused pane"),
550 ("w", "switch workspace profile"),
551 ("t", "cycle color theme"),
552 ("r", "refresh now"),
553 ("?", "this help"),
554 ("q / ctrl+c", "quit"),
555 ],
556 ),
557 (
558 "Focused pane",
559 &[
560 ("s", "start / stop / run the selected item"),
561 ("g", "access: grants and permissions"),
562 ("o", "open in the workspace web UI"),
563 ],
564 ),
565 (
566 "Unity Catalog pane",
567 &[
568 ("enter / backspace", "drill down / up (into volumes too)"),
569 ("p / P", "preview table rows (P picks the warehouse)"),
570 ("L", "lineage: upstream and downstream tables"),
571 (":", "query the selected table"),
572 ("enter on a file", "peek at its first 200 lines"),
573 ],
574 ),
575 (
576 "Details & runs",
577 &[
578 ("enter", "job/pipeline detail → latest run/update"),
579 ("h / l", "older / newer run"),
580 ("o", "full task output: error, trace, log tail"),
581 ("t", "timeline: per-task Gantt of the run"),
582 ("r", "repair a failed run — reruns only failed tasks"),
583 ("s", "cancel the shown run"),
584 ("j / k, J", "scroll · toggle raw JSON"),
585 ],
586 ),
587 (
588 "Table previews",
589 &[
590 ("j / k", "scroll rows"),
591 ("← / →", "page columns (wide tables)"),
592 ("/", "filter columns by name"),
593 ("v / enter", "record view: one row, fields stacked"),
594 ("e", "export rows to CSV"),
595 ],
596 ),
597 (
598 "Secret scopes pane",
599 &[
600 ("enter / backspace", "open a scope / back to scopes"),
601 ("a", "create a scope / add a secret (value masked)"),
602 ("x", "delete the selected scope or secret"),
603 ("g", "scope ACLs"),
604 ],
605 ),
606 (
607 "SQL console",
608 &[
609 ("enter", "run the statement"),
610 ("tab", "complete catalog/schema/table/column names"),
611 ("↑ / ↓, ctrl+r", "history · incremental search"),
612 ("ctrl+x", "compose in $EDITOR"),
613 ("ctrl+s", "export results to CSV"),
614 ("pgup / pgdn", "scroll results"),
615 ("shift+← / →", "page result columns"),
616 ("esc", "cancel a running query, else close"),
617 ],
618 ),
619 ];
620 let mut lines: Vec<Line> = Vec::new();
621 for (title, keys) in sections {
622 lines.push(Line::from(Span::styled(
623 *title,
624 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
625 )));
626 for (k, desc) in *keys {
627 lines.push(Line::from(vec![
628 Span::styled(format!(" {k:<24}"), Style::default().fg(p.warn)),
629 Span::styled(*desc, Style::default().fg(p.text)),
630 ]));
631 }
632 lines.push(Line::default());
633 }
634 let par = Paragraph::new(lines)
635 .scroll((app.help_scroll, 0))
636 .block(block);
637 f.render_widget(par, popup);
638}
639
640fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
641 let Some(jump) = &app.jump else {
642 return;
643 };
644 let matches = app.jump_matches();
645 let width = 70.min(area.width.saturating_sub(4));
646 let height = (matches.len() as u16 + 5).min(area.height);
647 let popup = Rect {
648 x: area.x + (area.width.saturating_sub(width)) / 2,
649 y: area.y + 2.min(area.height.saturating_sub(height)),
650 width,
651 height,
652 };
653 f.render_widget(Clear, popup);
654 let block = Block::default()
655 .title(Line::from(vec![
656 Span::styled(" ⌕ ", Style::default().fg(p.key)),
657 Span::styled(
658 "Jump to ",
659 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
660 ),
661 ]))
662 .borders(Borders::ALL)
663 .border_type(BorderType::Thick)
664 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
665 .padding(Padding::horizontal(1));
666 let inner = block.inner(popup);
667 f.render_widget(block, popup);
668
669 let parts = Layout::default()
670 .direction(Direction::Vertical)
671 .constraints([Constraint::Length(2), Constraint::Min(0)])
672 .split(inner);
673 f.render_widget(
674 Paragraph::new(Line::from(vec![
675 Span::styled("❯ ", Style::default().fg(p.key)),
676 Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
677 Span::styled("▏", Style::default().fg(p.warn)),
678 ])),
679 parts[0],
680 );
681
682 if matches.is_empty() {
683 f.render_widget(
684 Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
685 parts[1],
686 );
687 return;
688 }
689 let items: Vec<ListItem> = matches
690 .iter()
691 .map(|(panel_idx, name, label)| {
692 let panel = Panel::ALL[*panel_idx];
693 ListItem::new(Line::from(vec![
694 Span::styled(
695 format!("{} ", panel.icon()),
696 Style::default().fg(accent(panel, p)),
697 ),
698 Span::styled(format!("{name} "), Style::default().fg(p.text)),
699 Span::styled(label.clone(), Style::default().fg(p.dim)),
700 ]))
701 })
702 .collect();
703 let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
704 let mut state = ListState::default().with_selected(Some(jump.index));
705 f.render_stateful_widget(list, parts[1], &mut state);
706}
707
708fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
709 let Some(pr) = &app.problems else {
710 return;
711 };
712 let truncate = |s: &str, max: usize| -> String {
713 if s.chars().count() <= max {
714 s.to_string()
715 } else {
716 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
717 format!("{cut}…")
718 }
719 };
720 let name_w = pr
723 .items
724 .iter()
725 .map(|i| i.name.chars().count())
726 .max()
727 .unwrap_or(10)
728 .clamp(10, 40);
729 let note_w = pr
730 .items
731 .iter()
732 .map(|i| i.note.chars().count())
733 .max()
734 .unwrap_or(0);
735 let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
737 let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
738 let note_space = (width as usize).saturating_sub(name_w + 30);
740 let popup = Rect {
741 x: area.x + (area.width.saturating_sub(width)) / 2,
742 y: area.y + (area.height.saturating_sub(height)) / 2,
743 width,
744 height,
745 };
746 f.render_widget(Clear, popup);
747 let block = Block::default()
748 .title(Line::from(vec![
749 Span::styled(" ✗ ", Style::default().fg(p.err)),
750 Span::styled(
751 format!("Problems · {} ", pr.items.len()),
752 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
753 ),
754 ]))
755 .borders(Borders::ALL)
756 .border_type(BorderType::Rounded)
757 .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
758 .padding(Padding::new(1, 1, 1, 1));
759
760 if pr.items.is_empty() {
761 let par = Paragraph::new("✓ all clear — nothing failing right now")
762 .style(Style::default().fg(p.ok))
763 .block(block);
764 f.render_widget(par, popup);
765 return;
766 }
767
768 let items: Vec<ListItem> = pr
769 .items
770 .iter()
771 .map(|problem| {
772 let panel = Panel::ALL[problem.panel];
773 ListItem::new(Line::from(vec![
774 Span::styled("✗ ", Style::default().fg(p.err)),
775 Span::styled(
776 format!(
777 "{:<width$}",
778 truncate(&problem.name, name_w),
779 width = name_w + 2
780 ),
781 Style::default().fg(p.text),
782 ),
783 Span::styled(
784 format!("{} {:<18}", panel.icon(), panel.title()),
785 Style::default().fg(accent(panel, p)),
786 ),
787 Span::styled(
788 truncate(&problem.note, note_space),
789 Style::default().fg(p.dim),
790 ),
791 ]))
792 })
793 .collect();
794 let list = List::new(items)
795 .block(block)
796 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
797 let mut state = ListState::default().with_selected(Some(pr.index));
798 f.render_stateful_widget(list, popup, &mut state);
799}
800
801fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
802 let Some(picker) = &app.wh_picker else {
803 return;
804 };
805 let warehouses = app.warehouses();
806 let name_w = warehouses
807 .iter()
808 .map(|(n, _, _)| n.chars().count())
809 .max()
810 .unwrap_or(12)
811 .max(12);
812 let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
814 let height = (warehouses.len() as u16 + 4).min(area.height);
815 let popup = Rect {
816 x: area.x + (area.width.saturating_sub(width)) / 2,
817 y: area.y + (area.height.saturating_sub(height)) / 2,
818 width,
819 height,
820 };
821 f.render_widget(Clear, popup);
822 let block = Block::default()
823 .title(Line::from(vec![
824 Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
825 Span::styled(
826 "Run query on ",
827 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
828 ),
829 ]))
830 .borders(Borders::ALL)
831 .border_type(BorderType::Rounded)
832 .border_style(
833 Style::default()
834 .fg(p.warehouses)
835 .add_modifier(Modifier::BOLD),
836 )
837 .padding(Padding::new(1, 1, 1, 1));
838 let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
839 let items: Vec<ListItem> = warehouses
840 .iter()
841 .map(|(name, id, running)| {
842 let dot = if *running { p.ok } else { p.dim };
843 let marker = if current_id == Some(id.as_str()) {
844 "» "
845 } else {
846 " "
847 };
848 let state = if *running { "running" } else { "idle " };
849 ListItem::new(Line::from(vec![
850 Span::styled(marker, Style::default().fg(p.key)),
851 Span::styled("● ", Style::default().fg(dot)),
852 Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
853 Span::styled(
854 format!(" {state}"),
855 Style::default().fg(if *running { p.ok } else { p.dim }),
856 ),
857 Span::styled(format!(" {id}"), Style::default().fg(p.dim)),
858 ]))
859 })
860 .collect();
861 let list = List::new(items)
862 .block(block)
863 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
864 let mut state = ListState::default().with_selected(Some(picker.index));
865 f.render_stateful_widget(list, popup, &mut state);
866}
867
868const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
869const WORDMARK_BOT: &str = "█▄▀ █▀█ █ █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
870
871fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
872 let lines_total = 9u16;
873 let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
874 let frame = app.spinner_frame();
875
876 let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
878 for (i, row) in bricks.iter().enumerate() {
879 let line = Line::from(Span::styled(
880 *row,
881 Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
882 ));
883 let rect = Rect {
884 x: area.x,
885 y: top + i as u16,
886 width: area.width,
887 height: 1,
888 };
889 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
890 }
891
892 let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
894 for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
895 let spans: Vec<Span> = text
896 .chars()
897 .enumerate()
898 .map(|(i, c)| {
899 let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
900 let style = if dist < 3 {
901 Style::default()
902 .fg(Color::White)
903 .add_modifier(Modifier::BOLD)
904 } else {
905 Style::default().fg(p.brand)
906 };
907 Span::styled(c.to_string(), style)
908 })
909 .collect();
910 let rect = Rect {
911 x: area.x,
912 y: top + row,
913 width: area.width,
914 height: 1,
915 };
916 f.render_widget(
917 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
918 rect,
919 );
920 }
921
922 let tagline = Line::from(vec![
923 Span::styled("the ", Style::default().fg(p.dim)),
924 Span::styled(
925 "Lakehouse",
926 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
927 ),
928 Span::styled(" in your terminal", Style::default().fg(p.dim)),
929 ]);
930 let version = Line::from(Span::styled(
931 format!("v{}", env!("CARGO_PKG_VERSION")),
932 Style::default().fg(p.dim),
933 ));
934 for (offset, line) in [(7u16, tagline), (8u16, version)] {
935 let rect = Rect {
936 x: area.x,
937 y: top + offset,
938 width: area.width,
939 height: 1,
940 };
941 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
942 }
943}
944
945fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
946 let selected = app.picker.unwrap_or(0);
947 let width = (app
948 .profiles
949 .iter()
950 .map(|s| s.len())
951 .max()
952 .unwrap_or(10)
953 .max(20) as u16
954 + 6)
955 .min(area.width);
956 let height = (app.profiles.len() as u16 + 2).min(area.height);
957 let popup = Rect {
958 x: area.x + (area.width.saturating_sub(width)) / 2,
959 y: area.y + (area.height.saturating_sub(height)) / 2,
960 width,
961 height,
962 };
963 f.render_widget(Clear, popup);
964 let block = Block::default()
965 .title(Line::from(Span::styled(
966 " ⌂ Workspace ",
967 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
968 )))
969 .borders(Borders::ALL)
970 .border_type(BorderType::Rounded)
971 .border_style(Style::default().fg(p.key))
972 .padding(Padding::horizontal(1));
973 let items: Vec<ListItem> = app
974 .profiles
975 .iter()
976 .map(|name| {
977 let current = app.profile.as_deref() == Some(name.as_str());
978 let marker = if current { "● " } else { " " };
979 ListItem::new(Line::from(vec![
980 Span::styled(marker, Style::default().fg(p.ok)),
981 Span::styled(name.as_str(), Style::default().fg(p.text)),
982 ]))
983 })
984 .collect();
985 let list = List::new(items)
986 .block(block)
987 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
988 let mut state = ListState::default().with_selected(Some(selected));
989 f.render_stateful_widget(list, popup, &mut state);
990}
991
992fn bucket_color(bucket: &str, p: &Palette) -> Color {
993 match bucket {
994 "Jobs" => p.jobs,
995 "SQL" => p.warehouses,
996 "All-Purpose" => p.clusters,
997 "DLT" => p.pipelines,
998 _ => p.dim,
999 }
1000}
1001
1002fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1003 let cv = app.cost.as_ref().unwrap();
1004 let scope = match &cv.data {
1005 Some(Ok(d)) if d.scoped => " · this workspace",
1006 Some(Ok(_)) => " · all workspaces",
1007 _ => "",
1008 };
1009 let title = Line::from(vec![
1010 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1011 Span::styled(
1012 format!("Usage · last 14 days{scope} "),
1013 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1014 ),
1015 Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1016 ]);
1017 let block = Block::default()
1018 .title(title)
1019 .borders(Borders::ALL)
1020 .border_type(BorderType::Rounded)
1021 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1022 .padding(Padding::new(1, 1, 1, 1));
1023
1024 match &cv.data {
1025 None => {
1026 let par = Paragraph::new(format!(
1027 "{} querying system.billing.usage — the warehouse may need to start…",
1028 app.spinner()
1029 ))
1030 .style(Style::default().fg(p.warn))
1031 .block(block);
1032 f.render_widget(par, area);
1033 }
1034 Some(Err(e)) => {
1035 let par = Paragraph::new(format!(
1036 "✗ {e}\n\nsystem tables need to be enabled and readable \
1037 (grants on the `system` catalog)"
1038 ))
1039 .style(Style::default().fg(p.err))
1040 .wrap(Wrap { trim: false })
1041 .block(block);
1042 f.render_widget(par, area);
1043 }
1044 Some(Ok(data)) if data.days.is_empty() => {
1045 let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1046 .style(Style::default().fg(p.dim))
1047 .block(block);
1048 f.render_widget(par, area);
1049 }
1050 Some(Ok(data)) => {
1051 let mut lines: Vec<Line> = Vec::new();
1052
1053 let mut legend = vec![Span::raw("")];
1055 for (bucket, total, usd) in &data.buckets {
1056 legend.push(Span::styled(
1057 "■ ",
1058 Style::default().fg(bucket_color(bucket, p)),
1059 ));
1060 let amount = if data.priced {
1061 format!("{bucket} {total:.1} (${usd:.0}) ")
1062 } else {
1063 format!("{bucket} {total:.1} ")
1064 };
1065 legend.push(Span::styled(amount, Style::default().fg(p.text)));
1066 }
1067 let sigma = if data.priced {
1068 format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1069 } else {
1070 format!("Σ {:.1} DBU", data.total)
1071 };
1072 legend.push(Span::styled(
1073 sigma,
1074 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1075 ));
1076 lines.push(Line::from(legend));
1077 if data.priced {
1078 lines.push(Line::from(Span::styled(
1079 " list prices before discounts",
1080 Style::default().fg(p.dim),
1081 )));
1082 }
1083 if !data.scoped {
1084 lines.push(Line::from(Span::styled(
1085 " couldn't resolve this workspace's id — showing the whole account",
1086 Style::default().fg(p.warn),
1087 )));
1088 }
1089 lines.push(Line::default());
1090
1091 let max_day = data
1092 .days
1093 .iter()
1094 .map(|d| d.total)
1095 .fold(0.0_f64, f64::max)
1096 .max(f64::EPSILON);
1097 let inner_w = area.width.saturating_sub(4) as usize;
1098 let bar_w = inner_w.saturating_sub(22).max(10);
1099
1100 for day in &data.days {
1101 let mut spans = vec![Span::styled(
1102 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1104 Style::default().fg(p.dim),
1105 )];
1106 for (bucket, value) in &day.by_bucket {
1107 let chars = ((value / max_day) * bar_w as f64)
1108 .round()
1109 .max(if *value > 0.0 { 1.0 } else { 0.0 })
1110 as usize;
1111 spans.push(Span::styled(
1112 "█".repeat(chars),
1113 Style::default().fg(bucket_color(bucket, p)),
1114 ));
1115 }
1116 let day_label = if data.priced {
1117 format!(" {:.1} · ${:.2}", day.total, day.total_usd)
1118 } else {
1119 format!(" {:.1}", day.total)
1120 };
1121 spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1122 lines.push(Line::from(spans));
1123 }
1124
1125 if !data.spenders.is_empty() {
1126 lines.push(Line::default());
1127 lines.push(Line::from(Span::styled(
1128 "TOP SPENDERS · 14 days",
1129 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1130 )));
1131 let name_w = data
1132 .spenders
1133 .iter()
1134 .map(|s| {
1135 app.resource_name(&s.kind, &s.id)
1136 .unwrap_or_else(|| s.id.clone())
1137 .chars()
1138 .count()
1139 })
1140 .max()
1141 .unwrap_or(0)
1142 .max(8);
1143 for (i, s) in data.spenders.iter().enumerate() {
1144 let name = app
1145 .resource_name(&s.kind, &s.id)
1146 .unwrap_or_else(|| s.id.clone());
1147 let kind_color = match s.kind.as_str() {
1148 "job" => p.jobs,
1149 "cluster" => p.clusters,
1150 "warehouse" => p.warehouses,
1151 _ => p.dim,
1152 };
1153 let amount = if data.priced {
1154 format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1155 } else {
1156 format!("{:>8.1} DBU", s.dbus)
1157 };
1158 lines.push(Line::from(vec![
1159 Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1160 Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1161 Span::styled(
1162 format!("{:<width$}", name, width = name_w + 2),
1163 Style::default().fg(p.text),
1164 ),
1165 Span::styled(amount, Style::default().fg(p.text)),
1166 ]));
1167 }
1168 }
1169
1170 let par = Paragraph::new(lines).block(block);
1171 f.render_widget(par, area);
1172 }
1173 }
1174}
1175
1176fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1177 let console = app.sql.as_ref().unwrap();
1178 let parts = Layout::default()
1179 .direction(Direction::Vertical)
1180 .constraints([Constraint::Length(3), Constraint::Min(0)])
1181 .split(area);
1182
1183 let via = if console.warehouse.is_empty() {
1184 String::new()
1185 } else {
1186 format!("via {} ", console.warehouse)
1187 };
1188 let prompt_block = Block::default()
1189 .title(Line::from(vec![
1190 Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1191 Span::styled(
1192 "SQL Console ",
1193 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1194 ),
1195 Span::styled(via, Style::default().fg(p.dim)),
1196 ]))
1197 .borders(Borders::ALL)
1198 .border_type(BorderType::Thick)
1199 .border_style(
1200 Style::default()
1201 .fg(p.warehouses)
1202 .add_modifier(Modifier::BOLD),
1203 )
1204 .padding(Padding::horizontal(1));
1205 if let Some((query, _)) = &app.hist_search {
1206 let matched = app
1208 .hist_search_current()
1209 .map(|s| s.replace('\n', " "))
1210 .unwrap_or_default();
1211 let prompt = Paragraph::new(Line::from(vec![
1212 Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1213 Span::styled(
1214 query.as_str(),
1215 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1216 ),
1217 Span::styled("`: ", Style::default().fg(p.dim)),
1218 Span::styled(matched, Style::default().fg(p.text)),
1219 ]))
1220 .block(prompt_block);
1221 f.render_widget(prompt, parts[0]);
1222 } else {
1223 let caret_byte = console
1227 .input
1228 .char_indices()
1229 .nth(console.cursor)
1230 .map(|(i, _)| i)
1231 .unwrap_or(console.input.len());
1232 let (before, after) = console.input.split_at(caret_byte);
1233 let inner_w = parts[0].width.saturating_sub(4) as usize; let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1235 let prompt = Paragraph::new(Line::from(vec![
1236 Span::styled("❯ ", Style::default().fg(p.key)),
1237 Span::styled(before.replace('\n', " "), Style::default().fg(p.text)),
1238 Span::styled("▏", Style::default().fg(p.warn)),
1239 Span::styled(after.replace('\n', " "), Style::default().fg(p.text)),
1240 ]))
1241 .scroll((0, hscroll))
1242 .block(prompt_block);
1243 f.render_widget(prompt, parts[0]);
1244 }
1245
1246 let row_info = match &console.data {
1247 Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1248 _ => String::new(),
1249 };
1250 let mut results_title = vec![
1251 Span::styled(" Results ", Style::default().fg(p.text)),
1252 Span::styled(row_info, Style::default().fg(p.dim)),
1253 ];
1254 let avail = parts[1].width.saturating_sub(4) as usize;
1256 let sliced = match &console.data {
1257 Some(Ok(t)) if !t.headers.is_empty() => {
1258 let cols: Vec<usize> = (0..t.headers.len()).collect();
1259 let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
1260 if shown.len() < cols.len() {
1261 let first = console.col.min(cols.len() - 1) + 1;
1262 results_title.push(Span::styled(
1263 format!(
1264 "· cols {}–{} of {} (⇧←/→) ",
1265 first,
1266 first + shown.len() - 1,
1267 cols.len()
1268 ),
1269 Style::default().fg(p.dim),
1270 ));
1271 }
1272 Some((shown, constraints))
1273 }
1274 _ => None,
1275 };
1276 let results_block = Block::default()
1277 .title(Line::from(results_title))
1278 .borders(Borders::ALL)
1279 .border_type(BorderType::Rounded)
1280 .border_style(Style::default().fg(p.border))
1281 .padding(Padding::horizontal(1));
1282
1283 if console.running {
1284 let par = Paragraph::new(format!(
1285 "{} running on {} — the warehouse may need a moment to start…",
1286 app.spinner(),
1287 console.warehouse
1288 ))
1289 .style(Style::default().fg(p.warn))
1290 .block(results_block);
1291 f.render_widget(par, parts[1]);
1292 return;
1293 }
1294 match &console.data {
1295 None => {
1296 let par = Paragraph::new(
1297 "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1298 )
1299 .style(Style::default().fg(p.dim))
1300 .block(results_block);
1301 f.render_widget(par, parts[1]);
1302 }
1303 Some(Err(e)) => {
1304 let par = Paragraph::new(format!("✗ {e}"))
1305 .style(Style::default().fg(p.err))
1306 .wrap(Wrap { trim: false })
1307 .block(results_block);
1308 f.render_widget(par, parts[1]);
1309 }
1310 Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1311 let par = Paragraph::new("✓ statement succeeded — no result set")
1312 .style(Style::default().fg(p.ok))
1313 .block(results_block);
1314 f.render_widget(par, parts[1]);
1315 }
1316 Some(Ok(data)) => {
1317 let (shown, constraints) = sliced.unwrap_or_default();
1318 let header_cells: Vec<Cell> = shown
1319 .iter()
1320 .map(|&i| {
1321 Cell::from(data.headers[i].as_str()).style(
1322 Style::default()
1323 .fg(p.warehouses)
1324 .add_modifier(Modifier::BOLD),
1325 )
1326 })
1327 .collect();
1328 let header = Row::new(header_cells);
1329 let rows: Vec<Row> = data
1330 .rows
1331 .iter()
1332 .skip(console.scroll)
1333 .map(|r| {
1334 Row::new(
1335 shown
1336 .iter()
1337 .map(|&i| {
1338 Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
1339 .style(Style::default().fg(p.text))
1340 })
1341 .collect::<Vec<_>>(),
1342 )
1343 })
1344 .collect();
1345 let table = Table::new(rows, constraints)
1346 .header(header)
1347 .column_spacing(1)
1348 .block(results_block);
1349 f.render_widget(table, parts[1]);
1350 }
1351 }
1352}
1353
1354fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1356 let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
1357 return;
1358 };
1359 let inner_w = area.width.saturating_sub(4) as usize;
1361 let hscroll = (console.cursor + 3).saturating_sub(inner_w);
1362 let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
1363
1364 let lines: Vec<Line> = if comp.loading {
1365 vec![Line::from(Span::styled(
1366 format!("{} fetching names…", app.spinner()),
1367 Style::default().fg(p.warn),
1368 ))]
1369 } else {
1370 let visible = 8.min(comp.items.len());
1372 let skip = comp.index.saturating_sub(visible.saturating_sub(1));
1373 comp.items
1374 .iter()
1375 .enumerate()
1376 .skip(skip)
1377 .take(visible)
1378 .map(|(i, item)| {
1379 let style = if i == comp.index {
1380 Style::default()
1381 .fg(p.key)
1382 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1383 } else {
1384 Style::default().fg(p.text)
1385 };
1386 Line::from(Span::styled(format!(" {item} "), style))
1387 })
1388 .collect()
1389 };
1390 let widest = comp
1391 .items
1392 .iter()
1393 .map(|i| i.chars().count())
1394 .max()
1395 .unwrap_or(16);
1396 let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
1397 let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
1398 let popup = Rect {
1399 x: anchor.min(area.x + area.width.saturating_sub(width)),
1400 y: area.y + 3,
1401 width,
1402 height,
1403 };
1404 let title = if comp.loading {
1405 String::new()
1406 } else {
1407 format!(" {}/{} ", comp.index + 1, comp.items.len())
1408 };
1409 let block = Block::default()
1410 .title(Span::styled(title, Style::default().fg(p.dim)))
1411 .borders(Borders::ALL)
1412 .border_type(BorderType::Rounded)
1413 .border_style(Style::default().fg(p.key));
1414 f.render_widget(Clear, popup);
1415 f.render_widget(Paragraph::new(lines).block(block), popup);
1416}
1417
1418fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
1421 cols.iter()
1422 .map(|&i| {
1423 let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
1424 for row in data.rows.iter().take(50) {
1425 if let Some(c) = row.get(i) {
1426 w = w.max(c.chars().count());
1427 }
1428 }
1429 w.clamp(4, 36) as u16
1430 })
1431 .collect()
1432}
1433
1434fn grid_slice(
1439 data: &TableData,
1440 cols: &[usize],
1441 col: usize,
1442 avail: usize,
1443) -> (Vec<usize>, Vec<Constraint>) {
1444 let widths = grid_widths(data, cols);
1445 let col = col.min(cols.len().saturating_sub(1));
1446 let mut take = 0usize;
1447 let mut used = 0usize;
1448 for w in widths.iter().skip(col) {
1449 let next = used + *w as usize + if take > 0 { 1 } else { 0 };
1450 if take > 0 && next > avail {
1451 break;
1452 }
1453 used = next;
1454 take += 1;
1455 }
1456 let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
1457 let constraints: Vec<Constraint> = widths
1458 .iter()
1459 .skip(col)
1460 .take(take)
1461 .map(|&w| Constraint::Length(w))
1462 .collect();
1463 (shown, constraints)
1464}
1465
1466fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1467 let pv = app.preview.as_ref().unwrap();
1468 let acc = p.catalog;
1469 let mut title_spans: Vec<Span<'static>> = vec![
1470 Span::styled(" ◫ ", Style::default().fg(acc)),
1471 Span::styled(
1472 format!("{} · preview ", pv.name),
1473 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1474 ),
1475 Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1476 ];
1477 let make_block = |spans: Vec<Span<'static>>| {
1478 Block::default()
1479 .title(Line::from(spans))
1480 .borders(Borders::ALL)
1481 .border_type(BorderType::Rounded)
1482 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1483 .padding(Padding::horizontal(1))
1484 };
1485
1486 match &pv.data {
1487 None => {
1488 let par = Paragraph::new(format!(
1489 "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1490 app.spinner()
1491 ))
1492 .style(Style::default().fg(p.warn))
1493 .block(make_block(title_spans));
1494 f.render_widget(par, area);
1495 }
1496 Some(Err(e)) => {
1497 let text = format!(
1498 "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1499 pv.warehouse,
1500 pv.warehouse_id,
1501 app.profile.as_deref().unwrap_or("default"),
1502 app.host.as_deref().unwrap_or("unknown"),
1503 );
1504 let par = Paragraph::new(text)
1505 .style(Style::default().fg(p.err))
1506 .wrap(Wrap { trim: false })
1507 .block(make_block(title_spans));
1508 f.render_widget(par, area);
1509 }
1510 Some(Ok(data)) => {
1511 title_spans.push(Span::styled(
1512 format!("· {} rows ", data.rows.len()),
1513 Style::default().fg(p.dim),
1514 ));
1515 let cols = pv.visible_cols();
1516 if !pv.filter.is_empty() {
1517 title_spans.push(Span::styled(
1518 format!("· cols /{} ", pv.filter),
1519 Style::default().fg(p.warn),
1520 ));
1521 }
1522 if cols.is_empty() {
1523 let par = Paragraph::new(format!(
1524 "no columns match /{} — backspace to widen, esc to clear",
1525 pv.filter
1526 ))
1527 .style(Style::default().fg(p.warn))
1528 .block(make_block(title_spans));
1529 f.render_widget(par, area);
1530 return;
1531 }
1532 if pv.record {
1533 let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
1536 title_spans.push(Span::styled(
1537 format!("· row {}/{} ", row_n + 1, data.rows.len()),
1538 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1539 ));
1540 let name_w = cols
1541 .iter()
1542 .map(|&i| data.headers[i].chars().count())
1543 .max()
1544 .unwrap_or(0)
1545 .min(32);
1546 let empty: Vec<String> = Vec::new();
1547 let row = data.rows.get(row_n).unwrap_or(&empty);
1548 let lines: Vec<Line> = cols
1549 .iter()
1550 .map(|&i| {
1551 let value = row.get(i).map(String::as_str).unwrap_or("");
1552 let vstyle = if value == "␀" {
1553 Style::default().fg(p.dim)
1554 } else {
1555 Style::default().fg(p.text)
1556 };
1557 Line::from(vec![
1558 Span::styled(
1559 format!("{:<name_w$} ", data.headers[i]),
1560 Style::default().fg(acc),
1561 ),
1562 Span::styled(value.to_string(), vstyle),
1563 ])
1564 })
1565 .collect();
1566 let par = Paragraph::new(lines)
1567 .scroll((pv.rscroll, 0))
1568 .block(make_block(title_spans));
1569 f.render_widget(par, area);
1570 return;
1571 }
1572 let avail = area.width.saturating_sub(4) as usize; let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
1574 if shown.len() < cols.len() {
1575 let first = pv.col.min(cols.len() - 1) + 1;
1576 title_spans.push(Span::styled(
1577 format!(
1578 "· cols {}–{} of {} ",
1579 first,
1580 first + shown.len() - 1,
1581 cols.len()
1582 ),
1583 Style::default().fg(p.dim),
1584 ));
1585 }
1586 let header = Row::new(
1587 shown
1588 .iter()
1589 .map(|&i| {
1590 Cell::from(data.headers[i].clone())
1591 .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1592 })
1593 .collect::<Vec<_>>(),
1594 );
1595 let rows: Vec<Row> = data
1596 .rows
1597 .iter()
1598 .skip(pv.scroll)
1599 .map(|r| {
1600 Row::new(
1601 shown
1602 .iter()
1603 .map(|&i| {
1604 Cell::from(r.get(i).cloned().unwrap_or_default())
1605 .style(Style::default().fg(p.text))
1606 })
1607 .collect::<Vec<_>>(),
1608 )
1609 })
1610 .collect();
1611 let table = Table::new(rows, constraints)
1612 .header(header)
1613 .column_spacing(1)
1614 .block(make_block(title_spans));
1615 f.render_widget(table, area);
1616 }
1617 }
1618}
1619
1620fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1621 let d = app.detail.as_ref().unwrap();
1622 let acc = accent(d.panel, p);
1623 let title = Line::from(vec![
1624 Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1625 Span::styled(
1626 format!("{} · {} ", d.panel.title(), d.name),
1627 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1628 ),
1629 ]);
1630 let block = Block::default()
1631 .title(title)
1632 .borders(Borders::ALL)
1633 .border_type(BorderType::Rounded)
1634 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1635 .padding(Padding::horizontal(1));
1636
1637 let Some(data) = &d.data else {
1638 let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1639 .style(Style::default().fg(p.warn))
1640 .block(block);
1641 f.render_widget(par, area);
1642 return;
1643 };
1644
1645 if d.show_raw || data.summary.is_empty() {
1647 let par = Paragraph::new(data.raw.as_str())
1648 .style(Style::default().fg(p.text))
1649 .wrap(Wrap { trim: false })
1650 .scroll((d.scroll, 0))
1651 .block(block);
1652 f.render_widget(par, area);
1653 return;
1654 }
1655
1656 let mut lines: Vec<Line> = data
1657 .summary
1658 .iter()
1659 .map(|(k, v)| {
1660 Line::from(vec![
1661 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1662 Span::styled(v.as_str(), Style::default().fg(p.text)),
1663 ])
1664 })
1665 .collect();
1666 if !data.activity.is_empty() {
1667 lines.push(Line::default());
1668 lines.push(Line::from(Span::styled(
1669 d.section,
1670 Style::default().fg(acc).add_modifier(Modifier::BOLD),
1671 )));
1672 for (status, text) in &data.activity {
1673 let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
1675 if treeish {
1676 lines.push(Line::from(Span::styled(
1677 text.as_str(),
1678 Style::default().fg(status_color(status, p)),
1679 )));
1680 } else {
1681 lines.push(Line::from(vec![
1682 Span::styled("● ", Style::default().fg(status_color(status, p))),
1683 Span::styled(text.as_str(), Style::default().fg(p.text)),
1684 ]));
1685 }
1686 }
1687 }
1688 let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1689 f.render_widget(par, area);
1690}
1691
1692fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1693 let rv = app.run_view.as_ref().unwrap();
1694 let acc = accent(rv.panel, p);
1695 let noun = if rv.panel == Panel::Jobs {
1696 "run"
1697 } else {
1698 "update"
1699 };
1700 let mut title_spans = vec![
1701 Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1702 Span::styled(
1703 format!("{} · {noun} ", rv.owner_name),
1704 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1705 ),
1706 ];
1707 if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1708 title_spans.push(Span::styled(
1709 format!("{run_id} "),
1710 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1711 ));
1712 title_spans.push(Span::styled(
1713 format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1714 Style::default().fg(p.dim),
1715 ));
1716 }
1717 if rv.live {
1718 title_spans.push(Span::styled(
1719 format!("{} live ", app.spinner()),
1720 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1721 ));
1722 }
1723 if rv.show_output {
1724 title_spans.push(Span::styled(
1725 "· output ",
1726 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1727 ));
1728 }
1729 if rv.show_timeline {
1730 title_spans.push(Span::styled(
1731 "· timeline ",
1732 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1733 ));
1734 }
1735 let block = Block::default()
1736 .title(Line::from(title_spans))
1737 .borders(Borders::ALL)
1738 .border_type(BorderType::Rounded)
1739 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1740 .padding(Padding::horizontal(1));
1741
1742 if rv.show_output {
1744 let par = match &rv.output {
1745 Some(text) => Paragraph::new(text.as_str())
1746 .style(Style::default().fg(p.text))
1747 .wrap(Wrap { trim: false })
1748 .scroll((rv.scroll, 0))
1749 .block(block),
1750 None => Paragraph::new(format!(
1751 "{} fetching task output — one call per task…",
1752 app.spinner()
1753 ))
1754 .style(Style::default().fg(p.warn))
1755 .block(block),
1756 };
1757 f.render_widget(par, area);
1758 return;
1759 }
1760
1761 let Some(data) = &rv.data else {
1762 let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
1763 .style(Style::default().fg(p.warn))
1764 .block(block);
1765 f.render_widget(par, area);
1766 return;
1767 };
1768
1769 if rv.show_timeline {
1770 draw_run_timeline(f, area, rv.scroll, data, block, p);
1771 return;
1772 }
1773
1774 if rv.show_raw || data.summary.is_empty() {
1775 let par = Paragraph::new(data.raw.as_str())
1776 .style(Style::default().fg(p.text))
1777 .wrap(Wrap { trim: false })
1778 .scroll((rv.scroll, 0))
1779 .block(block);
1780 f.render_widget(par, area);
1781 return;
1782 }
1783
1784 let mut lines: Vec<Line> = data
1785 .summary
1786 .iter()
1787 .map(|(k, v)| {
1788 Line::from(vec![
1789 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1790 Span::styled(v.as_str(), Style::default().fg(p.text)),
1791 ])
1792 })
1793 .collect();
1794 if !data.activity.is_empty() {
1795 lines.push(Line::default());
1796 lines.push(Line::from(Span::styled(
1797 if rv.panel == Panel::Jobs {
1798 "Tasks"
1799 } else {
1800 "Event log"
1801 },
1802 Style::default().fg(acc).add_modifier(Modifier::BOLD),
1803 )));
1804 for (status, text) in &data.activity {
1805 if text.starts_with(" ↳") {
1807 lines.push(Line::from(Span::styled(
1808 text.as_str(),
1809 Style::default().fg(p.err),
1810 )));
1811 } else {
1812 lines.push(Line::from(vec![
1813 Span::styled("● ", Style::default().fg(status_color(status, p))),
1814 Span::styled(text.as_str(), Style::default().fg(p.text)),
1815 ]));
1816 }
1817 }
1818 }
1819 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
1820 f.render_widget(par, area);
1821}
1822
1823fn draw_run_timeline(
1826 f: &mut Frame,
1827 area: Rect,
1828 scroll: u16,
1829 data: &DetailData,
1830 block: Block,
1831 p: &Palette,
1832) {
1833 let tasks = crate::fetchers::runs::timeline(&data.raw);
1834 let now = std::time::SystemTime::now()
1835 .duration_since(std::time::UNIX_EPOCH)
1836 .map(|d| d.as_millis() as u64)
1837 .unwrap_or(0);
1838 let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
1839 let Some(t0) = t0 else {
1840 let par = Paragraph::new(
1841 "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
1842 )
1843 .style(Style::default().fg(p.dim))
1844 .wrap(Wrap { trim: false })
1845 .block(block);
1846 f.render_widget(par, area);
1847 return;
1848 };
1849 let t1 = tasks
1850 .iter()
1851 .filter(|t| t.start > 0)
1852 .map(|t| t.end.unwrap_or(now).max(t.start))
1853 .max()
1854 .unwrap_or(t0);
1855 let span = (t1 - t0).max(1);
1856
1857 let name_w = tasks
1858 .iter()
1859 .map(|t| t.key.chars().count())
1860 .max()
1861 .unwrap_or(4)
1862 .clamp(4, 24);
1863 let inner_w = area.width.saturating_sub(4) as usize;
1864 let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
1866
1867 let mut lines: Vec<Line> = vec![
1868 Line::from(Span::styled(
1869 format!(
1870 "◷ {} task{} · span {}",
1871 tasks.len(),
1872 if tasks.len() == 1 { "" } else { "s" },
1873 crate::shape::fmt_duration_ms(span)
1874 ),
1875 Style::default().fg(p.dim),
1876 )),
1877 Line::default(),
1878 ];
1879 for t in &tasks {
1880 let mut key: String = t.key.chars().take(name_w).collect();
1881 if key.len() < t.key.len() {
1882 key.pop();
1883 key.push('…');
1884 }
1885 let name = Span::styled(format!("{key:<name_w$} "), Style::default().fg(p.text));
1886 if t.start == 0 {
1887 lines.push(Line::from(vec![
1888 name,
1889 Span::styled(
1890 format!("– {}", t.status.label().to_lowercase()),
1891 Style::default().fg(p.dim),
1892 ),
1893 ]));
1894 continue;
1895 }
1896 let end = t.end.unwrap_or(now).max(t.start);
1897 let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
1898 let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
1899 let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
1900 let dur = format!(
1901 " {}{}",
1902 crate::shape::fmt_duration_ms(end - t.start),
1903 if t.end.is_none() { "…" } else { "" }
1904 );
1905 lines.push(Line::from(vec![
1906 name,
1907 Span::styled(" ".repeat(off), Style::default()),
1908 Span::styled(
1909 "█".repeat(len),
1910 Style::default().fg(status_color(&t.status, p)),
1911 ),
1912 Span::styled(dur, Style::default().fg(p.dim)),
1913 ]));
1914 }
1915 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
1916 f.render_widget(par, area);
1917}
1918
1919fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1920 let block = Block::default()
1921 .borders(Borders::ALL)
1922 .border_type(BorderType::Rounded)
1923 .border_style(Style::default().fg(p.border));
1924 let inner = block.inner(area);
1925 f.render_widget(block, area);
1926
1927 let (from, to) = (p.grad_from, p.grad_to);
1931 let grad = |t: f32| {
1932 Color::Rgb(
1933 (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
1934 (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
1935 (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
1936 )
1937 };
1938 let mut left = vec![
1939 Span::styled(
1940 " ◢",
1941 Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
1942 ),
1943 Span::styled(
1944 "◤ ",
1945 Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
1946 ),
1947 ];
1948 let word: Vec<char> = "DATABRICKS".chars().collect();
1949 let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
1950 for (i, ch) in word.iter().enumerate() {
1951 let t = i as f32 / (word.len() - 1) as f32;
1952 let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
1953 Style::default().fg(p.text).add_modifier(Modifier::BOLD)
1954 } else {
1955 Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
1956 };
1957 left.push(Span::styled(ch.to_string(), style));
1958 }
1959 left.push(Span::raw(" "));
1960 for ch in "LAKEHOUSE".chars() {
1961 left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
1962 }
1963 left.push(Span::styled(
1964 format!("· v{}", env!("CARGO_PKG_VERSION")),
1965 Style::default().fg(p.dim),
1966 ));
1967 if let Some(profile) = &app.profile {
1968 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
1969 left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
1970 left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
1971 }
1972 if let Some(Shape::Badge(b)) = &app.user_badge {
1973 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
1974 left.push(Span::styled(
1975 format!("{} {}", b.label, b.value),
1976 Style::default().fg(p.key),
1977 ));
1978 }
1979 if app.zoomed {
1980 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
1981 left.push(Span::styled(
1982 format!("⛶ {}", app.focus.title()),
1983 Style::default()
1984 .fg(accent(app.focus, p))
1985 .add_modifier(Modifier::BOLD),
1986 ));
1987 }
1988
1989 let (ok, pending, failed, idle) = app.status_counts();
1991 for (count, glyph, color) in [
1992 (ok, "●", p.ok),
1993 (pending, "◐", p.warn),
1994 (failed, "✗", p.err),
1995 (idle, "○", p.dim),
1996 ] {
1997 if count > 0 {
1998 left.push(Span::styled(" ", Style::default()));
1999 left.push(Span::styled(
2000 format!("{glyph} {count}"),
2001 Style::default().fg(color),
2002 ));
2003 }
2004 }
2005 f.render_widget(Paragraph::new(Line::from(left)), inner);
2006
2007 let right = if app.loading {
2008 Line::from(Span::styled(
2009 format!("{} refreshing ", app.spinner()),
2010 Style::default().fg(p.warn),
2011 ))
2012 } else {
2013 Line::from(Span::styled(
2014 format!("updated {}s ago ", app.last_refresh_age().as_secs()),
2015 Style::default().fg(p.dim),
2016 ))
2017 };
2018 f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
2019}
2020
2021fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2022 let key =
2023 |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
2024 let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
2025
2026 if let Some(confirm) = &app.confirm {
2027 let line = Line::from(vec![
2028 Span::styled(
2029 format!(" ⚠ {} ", confirm.message),
2030 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2031 ),
2032 key("y"),
2033 dim(" confirm · any other key cancels"),
2034 ]);
2035 f.render_widget(Paragraph::new(line), area);
2036 return;
2037 }
2038
2039 if app.filter_entry {
2040 let line = Line::from(vec![
2041 Span::styled(
2042 format!(" /{}", app.active_filter()),
2043 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2044 ),
2045 Span::styled("▏", Style::default().fg(p.warn)),
2046 dim(" type to filter "),
2047 key("enter"),
2048 dim(" apply "),
2049 key("esc"),
2050 dim(" clear"),
2051 ]);
2052 f.render_widget(Paragraph::new(line), area);
2053 return;
2054 }
2055
2056 if let Some(pv) = &app.preview {
2057 if pv.filter_entry {
2058 let line = Line::from(vec![
2059 Span::styled(
2060 format!(" cols /{}", pv.filter),
2061 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2062 ),
2063 Span::styled("▏", Style::default().fg(p.warn)),
2064 dim(" type to filter columns "),
2065 key("enter"),
2066 dim(" apply "),
2067 key("esc"),
2068 dim(" clear"),
2069 ]);
2070 f.render_widget(Paragraph::new(line), area);
2071 return;
2072 }
2073 }
2074
2075 let mut spans = if app.secret_form.is_some() {
2076 vec![
2077 dim(" type "),
2078 key("enter"),
2079 dim(" next / save "),
2080 key("esc"),
2081 dim(" cancel"),
2082 ]
2083 } else if app.help {
2084 vec![
2085 dim(" "),
2086 key("j"),
2087 dim("/"),
2088 key("k"),
2089 dim(" scroll any other key closes"),
2090 ]
2091 } else if app.pane_cfg.is_some() {
2092 vec![
2093 dim(" "),
2094 key("j"),
2095 dim("/"),
2096 key("k"),
2097 dim(" select "),
2098 key("space"),
2099 dim(" show/hide "),
2100 key("J"),
2101 dim("/"),
2102 key("K"),
2103 dim(" move "),
2104 key("esc"),
2105 dim(" done"),
2106 ]
2107 } else if app.jump.is_some() {
2108 vec![
2109 dim(" type to search everything "),
2110 key("\u{2191}"),
2111 dim("/"),
2112 key("\u{2193}"),
2113 dim(" select "),
2114 key("enter"),
2115 dim(" jump "),
2116 key("esc"),
2117 dim(" close"),
2118 ]
2119 } else if app.problems.is_some() {
2120 vec![
2121 dim(" "),
2122 key("j"),
2123 dim("/"),
2124 key("k"),
2125 dim(" select "),
2126 key("enter"),
2127 dim(" jump to item "),
2128 key("esc"),
2129 dim(" close"),
2130 ]
2131 } else if app.cost.is_some() {
2132 vec![
2133 dim(" "),
2134 key("esc"),
2135 dim(" back "),
2136 key("q"),
2137 dim(" quit"),
2138 ]
2139 } else if app.wh_picker.is_some() {
2140 vec![
2141 dim(" "),
2142 key("j"),
2143 dim("/"),
2144 key("k"),
2145 dim(" select "),
2146 key("enter"),
2147 dim(" run preview "),
2148 key("esc"),
2149 dim(" cancel"),
2150 ]
2151 } else if app.sql.is_some() {
2152 if app.hist_search.is_some() {
2153 vec![
2154 dim(" type to search history "),
2155 key("^r"),
2156 dim(" older match "),
2157 key("enter"),
2158 dim(" accept "),
2159 key("esc"),
2160 dim(" cancel"),
2161 ]
2162 } else {
2163 vec![
2164 dim(" "),
2165 key("enter"),
2166 dim(" run "),
2167 key("tab"),
2168 dim(" complete "),
2169 key("↑"),
2170 dim("/"),
2171 key("↓"),
2172 dim(" history "),
2173 key("^r"),
2174 dim(" search "),
2175 key("^x"),
2176 dim(" editor "),
2177 key("pgup"),
2178 dim("/"),
2179 key("pgdn"),
2180 dim(" scroll "),
2181 key("⇧←→"),
2182 dim(" cols "),
2183 key("^s"),
2184 dim(" export "),
2185 key("esc"),
2186 dim(if app.sql.as_ref().is_some_and(|c| c.running) {
2187 " cancel query"
2188 } else {
2189 " close"
2190 }),
2191 ]
2192 }
2193 } else if let Some(pv) = &app.preview {
2194 if pv.record {
2195 vec![
2196 dim(" "),
2197 key("j"),
2198 dim("/"),
2199 key("k"),
2200 dim(" fields "),
2201 key("h"),
2202 dim("/"),
2203 key("l"),
2204 dim(" prev/next row "),
2205 key("v"),
2206 dim(" grid "),
2207 key("/"),
2208 dim(" filter cols "),
2209 key("esc"),
2210 dim(" back"),
2211 ]
2212 } else {
2213 vec![
2214 dim(" "),
2215 key("esc"),
2216 dim(" back "),
2217 key("j"),
2218 dim("/"),
2219 key("k"),
2220 dim(" rows "),
2221 key("←"),
2222 dim("/"),
2223 key("→"),
2224 dim(" columns "),
2225 key("v"),
2226 dim(" row view "),
2227 key("/"),
2228 dim(" filter cols "),
2229 key("e"),
2230 dim(" export csv "),
2231 key("q"),
2232 dim(" quit"),
2233 ]
2234 }
2235 } else if app.picker.is_some() {
2236 vec![
2237 dim(" "),
2238 key("j"),
2239 dim("/"),
2240 key("k"),
2241 dim(" select "),
2242 key("enter"),
2243 dim(" switch workspace "),
2244 key("esc"),
2245 dim(" cancel"),
2246 ]
2247 } else if let Some(rv) = &app.run_view {
2248 if rv.show_output {
2249 vec![
2250 dim(" "),
2251 key("esc"),
2252 dim(" back to run "),
2253 key("j"),
2254 dim("/"),
2255 key("k"),
2256 dim(" scroll "),
2257 key("q"),
2258 dim(" quit"),
2259 ]
2260 } else {
2261 let mut spans = vec![
2262 dim(" "),
2263 key("esc"),
2264 dim(" back "),
2265 key("h"),
2266 dim("/"),
2267 key("l"),
2268 dim(" older/newer "),
2269 ];
2270 if rv.panel == Panel::Jobs {
2271 spans.push(key("o"));
2272 spans.push(dim(" output "));
2273 spans.push(key("t"));
2274 spans.push(dim(" timeline "));
2275 spans.push(key("r"));
2276 spans.push(dim(" repair "));
2277 }
2278 spans.extend([
2279 key("s"),
2280 dim(" cancel "),
2281 key("j"),
2282 dim("/"),
2283 key("k"),
2284 dim(" scroll "),
2285 key("J"),
2286 dim(" raw "),
2287 key("q"),
2288 dim(" quit"),
2289 ]);
2290 spans
2291 }
2292 } else if app.detail.is_some() {
2293 let mut spans = vec![dim(" "), key("esc"), dim(" back ")];
2294 match app.detail.as_ref() {
2295 Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
2296 spans.push(key("enter"));
2297 spans.push(dim(" latest run "));
2298 }
2299 Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
2300 spans.push(key("enter"));
2301 spans.push(dim(" latest update "));
2302 }
2303 _ => {}
2304 }
2305 spans.extend([
2306 key("j"),
2307 dim("/"),
2308 key("k"),
2309 dim(" scroll "),
2310 key("J"),
2311 dim(" raw "),
2312 key("o"),
2313 dim(" open "),
2314 key("q"),
2315 dim(" quit"),
2316 ]);
2317 spans
2318 } else {
2319 let mut spans = vec![
2320 dim(" "),
2321 key("tab"),
2322 dim("/"),
2323 key("h"),
2324 dim("/"),
2325 key("l"),
2326 dim(" switch "),
2327 key("j"),
2328 dim("/"),
2329 key("k"),
2330 dim(" select "),
2331 key("/"),
2332 dim(if app.active_filter().is_empty() {
2333 " filter "
2334 } else {
2335 " filter · esc clears "
2336 }),
2337 ];
2338 let enter_label = match app.focus {
2340 Panel::Secrets if app.secret_scope.is_some() => None,
2341 Panel::Secrets => Some(" open scope "),
2342 Panel::Catalog if app.uc_path.len() < 2 => Some(" open "),
2343 _ => Some(" details "),
2344 };
2345 if let Some(label) = enter_label {
2346 spans.push(key("enter"));
2347 spans.push(dim(label));
2348 }
2349 match app.focus {
2351 Panel::Catalog => {
2352 if !app.uc_path.is_empty() {
2353 spans.push(key("bksp"));
2354 spans.push(dim(" up "));
2355 }
2356 if app.uc_path.len() == 2 {
2357 spans.push(key("p"));
2358 spans.push(dim(" preview "));
2359 spans.push(key("L"));
2360 spans.push(dim(" lineage "));
2361 spans.push(key(":"));
2362 spans.push(dim(" query table "));
2363 }
2364 }
2365 Panel::Dashboards => {}
2366 Panel::Secrets => {
2367 if app.secret_scope.is_some() {
2368 spans.push(key("bksp"));
2369 spans.push(dim(" up "));
2370 }
2371 spans.push(key("a"));
2372 spans.push(dim(if app.secret_scope.is_some() {
2373 " add secret "
2374 } else {
2375 " new scope "
2376 }));
2377 spans.push(key("x"));
2378 spans.push(dim(" delete "));
2379 }
2380 _ => {
2381 spans.push(key("s"));
2382 spans.push(dim(" action "));
2383 }
2384 }
2385 spans.extend([
2386 key("g"),
2387 dim(" access "),
2388 key("$"),
2389 dim(" cost "),
2390 key("!"),
2391 dim(" problems "),
2392 ]);
2393 if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
2395 spans.push(key(":"));
2396 spans.push(dim(" sql "));
2397 }
2398 spans.extend([
2399 key("o"),
2400 dim(" open "),
2401 key("z"),
2402 dim(if app.zoomed { " unzoom " } else { " zoom " }),
2403 key("w"),
2404 dim(" workspace "),
2405 key("t"),
2406 dim(" theme "),
2407 key("r"),
2408 dim(" refresh "),
2409 key("?"),
2410 dim(" help "),
2411 key("q"),
2412 dim(" quit"),
2413 ]);
2414 spans
2415 };
2416 if let Some((msg, _)) = &app.flash {
2418 let color = if msg.starts_with('✗') { p.err } else { p.ok };
2419 let mut prefixed = vec![
2420 Span::styled(
2421 format!(" {msg}"),
2422 Style::default().fg(color).add_modifier(Modifier::BOLD),
2423 ),
2424 dim(" · "),
2425 ];
2426 prefixed.append(&mut spans);
2427 spans = prefixed;
2428 }
2429 f.render_widget(Paragraph::new(Line::from(spans)), area);
2430}
2431
2432#[allow(clippy::too_many_arguments)]
2433fn draw_panel(
2434 f: &mut Frame,
2435 area: Rect,
2436 panel: Panel,
2437 shape: Option<&Shape>,
2438 focused: bool,
2439 selected: Option<usize>,
2440 fresh: bool,
2441 breadcrumb: &str,
2442 spinner: &str,
2443 filter: &str,
2444 entering: bool,
2445 p: &Palette,
2446) {
2447 let accent = accent(panel, p);
2448 let (border_style, title_style) = if focused {
2449 (
2450 Style::default().fg(accent).add_modifier(Modifier::BOLD),
2451 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2452 )
2453 } else {
2454 (Style::default().fg(p.border), Style::default().fg(p.dim))
2455 };
2456 let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
2457 Some(Shape::List(items)) => Some(
2458 items
2459 .iter()
2460 .filter(|it| crate::shape::item_matches(it, filter))
2461 .collect(),
2462 ),
2463 _ => None,
2464 };
2465 let count = match (&visible, shape) {
2466 (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
2468 format!(" · {}/{}", v.len(), items.len())
2469 }
2470 (Some(v), _) => format!(" · {}", v.len()),
2471 (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
2472 _ => String::new(),
2473 };
2474 let crumb = if !breadcrumb.is_empty() {
2475 format!(" ▸ {breadcrumb}")
2476 } else {
2477 String::new()
2478 };
2479 let title_style = if fresh {
2481 Style::default()
2482 .bg(accent)
2483 .fg(Color::Black)
2484 .add_modifier(Modifier::BOLD)
2485 } else {
2486 title_style
2487 };
2488 let mut title_spans = vec![
2489 Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
2490 Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
2491 ];
2492 if entering || !filter.is_empty() {
2493 title_spans.push(Span::styled(
2494 format!("/{}{} ", filter, if entering { "▏" } else { "" }),
2495 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2496 ));
2497 }
2498 let title = Line::from(title_spans);
2499 let block = Block::default()
2500 .title(title)
2501 .borders(Borders::ALL)
2502 .border_type(if focused {
2503 BorderType::Thick
2504 } else {
2505 BorderType::Rounded
2506 })
2507 .border_style(border_style)
2508 .padding(Padding::horizontal(1));
2509
2510 match shape {
2511 None => {
2512 let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
2513 .style(Style::default().fg(p.warn))
2514 .block(block);
2515 f.render_widget(par, area);
2516 }
2517 Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
2518 let empty = if !items.is_empty() {
2519 format!("∅ nothing matches /{filter}")
2520 } else {
2521 let msg = match panel {
2522 Panel::Clusters => "no compute running",
2523 Panel::Jobs => "no jobs defined",
2524 Panel::Pipelines => "no pipelines",
2525 Panel::Warehouses => "no warehouses",
2526 Panel::Dashboards => "no dashboards yet",
2527 Panel::Catalog => "nothing at this level",
2528 Panel::Secrets => "no secret scopes",
2529 };
2530 format!("∅ {msg}")
2531 };
2532 let par = Paragraph::new(empty)
2533 .style(Style::default().fg(p.dim))
2534 .block(block);
2535 f.render_widget(par, area);
2536 }
2537 Some(Shape::List(_)) => {
2538 let list_items: Vec<ListItem> = visible
2539 .unwrap_or_default()
2540 .iter()
2541 .map(|item| {
2542 let color = status_color(&item.status, p);
2543 let chip = match item.status {
2544 Status::Stopped | Status::Unknown(_) => Span::styled(
2545 format!(" {} ", item.status.label()),
2546 Style::default().fg(color).add_modifier(Modifier::DIM),
2547 ),
2548 _ => Span::styled(
2549 format!(" {} ", item.status.label()),
2550 Style::default()
2551 .bg(color)
2552 .fg(Color::Black)
2553 .add_modifier(Modifier::BOLD),
2554 ),
2555 };
2556 let mut spans = vec![
2557 Span::styled("● ", Style::default().fg(color)),
2558 Span::styled(item.name.as_str(), Style::default().fg(p.text)),
2559 Span::raw(" "),
2560 chip,
2561 ];
2562 if !item.history.is_empty() {
2563 spans.push(Span::raw(" "));
2564 for run in &item.history {
2565 spans.push(Span::styled(
2566 history_glyph(run),
2567 Style::default().fg(status_color(run, p)),
2568 ));
2569 }
2570 }
2571 if let Some(detail) = &item.detail {
2572 spans.push(Span::styled(
2573 format!(" {}", detail),
2574 Style::default().fg(p.dim),
2575 ));
2576 }
2577 ListItem::new(Line::from(spans))
2578 })
2579 .collect();
2580 let list = List::new(list_items)
2581 .block(block)
2582 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
2583 match selected {
2584 Some(sel) => {
2585 let mut state = ListState::default().with_selected(Some(sel));
2586 f.render_stateful_widget(list, area, &mut state);
2587 }
2588 None => f.render_widget(list, area),
2589 }
2590 }
2591 Some(Shape::Table(data)) => {
2592 let header_cells: Vec<Cell> = data
2593 .headers
2594 .iter()
2595 .map(|h| {
2596 Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
2597 })
2598 .collect();
2599 let header = Row::new(header_cells).style(Style::default().fg(accent));
2600 let rows: Vec<Row> = data
2601 .rows
2602 .iter()
2603 .map(|r| Row::new(r.iter().map(|c| Cell::from(c.as_str())).collect::<Vec<_>>()))
2604 .collect();
2605 let widths: Vec<Constraint> = data
2606 .headers
2607 .iter()
2608 .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
2609 .collect();
2610 let table = Table::new(rows, widths).header(header).block(block);
2611 f.render_widget(table, area);
2612 }
2613 Some(Shape::Badge(b)) => {
2614 let text = format!("{}: {}", b.label, b.value);
2615 let par = Paragraph::new(text)
2616 .style(Style::default().fg(p.text))
2617 .block(block);
2618 f.render_widget(par, area);
2619 }
2620 Some(Shape::Text(t)) => {
2621 let color = if t.starts_with('✗') { p.err } else { p.text };
2622 let par = Paragraph::new(t.as_str())
2623 .style(Style::default().fg(color))
2624 .wrap(Wrap { trim: false })
2625 .block(block);
2626 f.render_widget(par, area);
2627 }
2628 }
2629}
2630
2631fn status_color(status: &Status, p: &Palette) -> Color {
2632 match status {
2633 Status::Running | Status::Success => p.ok,
2634 Status::Stopped => p.dim,
2635 Status::Pending => p.warn,
2636 Status::Failed => p.err,
2637 Status::Unknown(_) => p.text,
2638 }
2639}
2640
2641fn history_glyph(status: &Status) -> &'static str {
2642 match status {
2643 Status::Success => "✓",
2644 Status::Failed => "✗",
2645 Status::Running => "●",
2646 Status::Pending => "◐",
2647 Status::Stopped => "○",
2648 Status::Unknown(_) => "·",
2649 }
2650}