Skip to main content

databricks_tui/
ui.rs

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    /// Header wordmark gradient endpoints.
30    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        // Dark theme sticks to ANSI colors so it follows the terminal's own scheme.
45        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        // Light theme uses explicit darker shades that stay readable on a white background.
63        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        // Running a query may pop the warehouse picker over the console.
219        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        // Overlays must still render on top of the zoomed pane.
265        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    // Names get a bounded column; notes (which can be whole CLI error
346    // lines) take whatever room is left and are truncated to fit.
347    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    // dot(2) + name + gap(2) + icon+title(20) + note + padding/borders(8)
361    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    // Room left for the note on each row inside borders and padding.
364    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    // marker(2) + dot(2) + name + gap(2) + state(7) + gap(2) + id(16) + padding/borders(6)
438    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    // Brick-mark pyramid in Databricks red.
502    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    // Wordmark with a light sweep that travels across the letters.
518    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            // Legend with per-bucket totals, largest first.
679            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                    // "2026-07-01" -> "07-01"
728                    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        // Readline-style incremental search over history.
832        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        // Caret sits at the cursor, not the end; long inputs scroll so
849        // the caret stays visible. Newlines from $EDITOR render as
850        // spaces (byte-for-byte, so the caret math holds).
851        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; // borders + padding
859        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    // Fall back to raw when there's nothing structured to show (e.g. errors).
1067    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            // Error continuation lines carry their own ↳ marker.
1182            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    // Brand block: a two-tone brick mark, then DATABRICKS in the theme's
1208    // gradient with a bright shimmer sweeping across it while data loads,
1209    // then LAKEHOUSE letter-spaced in the accent color.
1210    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    // Workspace health at a glance: counts of items per status bucket.
1270    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        // Only show keys that do something in the focused pane.
1499        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        // At the catalog's table level the ':' hint already reads "query table".
1529        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    // A flash message prefixes the key hints instead of hiding them.
1550    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        // With a filter active, show how many made the cut.
1600        (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    // A short reversed flash on the title when fresh data lands.
1613    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}