Skip to main content

databricks_tui/
ui.rs

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