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