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, Margin, 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, Scrollbar, ScrollbarOrientation, ScrollbarState, 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.upcoming.is_some() {
287            draw_upcoming(f, root[1], app, &p);
288        }
289        if app.jump.is_some() {
290            draw_jump(f, root[1], app, &p);
291        }
292        if app.pane_cfg.is_some() {
293            draw_pane_cfg(f, root[1], app, &p);
294        }
295        if app.secret_form.is_some() {
296            draw_secret_form(f, root[1], app, &p);
297        }
298        if app.help {
299            draw_help(f, root[1], app, &p);
300        }
301        return;
302    }
303
304    // The grid adapts to however many panes are visible, in the user's
305    // order: left column gets the first half (rounded up), right the rest.
306    let visible = app.visible_panes();
307    if visible.is_empty() {
308        let par = Paragraph::new("all panes hidden — press H to bring them back")
309            .style(Style::default().fg(p.dim))
310            .alignment(Alignment::Center);
311        f.render_widget(par, root[1]);
312    } else {
313        let rows_of = |n: usize| -> Vec<Constraint> {
314            (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
315        };
316        let left_count = visible.len().div_ceil(2);
317        let right_count = visible.len() - left_count;
318        let mut areas: Vec<Rect> = Vec::new();
319        if right_count == 0 {
320            areas.extend(
321                Layout::default()
322                    .direction(Direction::Vertical)
323                    .constraints(rows_of(left_count))
324                    .split(root[1])
325                    .iter(),
326            );
327        } else {
328            let body = Layout::default()
329                .direction(Direction::Horizontal)
330                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
331                .split(root[1]);
332            areas.extend(
333                Layout::default()
334                    .direction(Direction::Vertical)
335                    .constraints(rows_of(left_count))
336                    .split(body[0])
337                    .iter(),
338            );
339            areas.extend(
340                Layout::default()
341                    .direction(Direction::Vertical)
342                    .constraints(rows_of(right_count))
343                    .split(body[1])
344                    .iter(),
345            );
346        }
347
348        for (slot, &i) in visible.iter().enumerate() {
349            let panel = Panel::ALL[i];
350            let focused = app.focus == panel;
351            let shape = app.shapes[i].as_ref();
352            let selected = focused.then(|| app.selection(i));
353            let fresh = app.updated_at[i]
354                .map(|t| t.elapsed() < Duration::from_millis(1200))
355                .unwrap_or(false);
356            draw_panel(
357                f,
358                areas[slot],
359                panel,
360                shape,
361                focused,
362                selected,
363                fresh,
364                &pane_breadcrumb(app, panel),
365                app.spinner(),
366                &app.filters[i],
367                app.filter_entry && focused,
368                &p,
369            );
370        }
371    }
372
373    if app.picker.is_some() {
374        draw_picker(f, root[1], app, &p);
375    }
376    if app.wh_picker.is_some() {
377        draw_wh_picker(f, root[1], app, &p);
378    }
379    if app.problems.is_some() {
380        draw_problems(f, root[1], app, &p);
381    }
382    if app.upcoming.is_some() {
383        draw_upcoming(f, root[1], app, &p);
384    }
385    if app.jump.is_some() {
386        draw_jump(f, root[1], app, &p);
387    }
388    if app.pane_cfg.is_some() {
389        draw_pane_cfg(f, root[1], app, &p);
390    }
391    if app.secret_form.is_some() {
392        draw_secret_form(f, root[1], app, &p);
393    }
394    if app.help {
395        draw_help(f, root[1], app, &p);
396    }
397}
398
399fn draw_secret_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
400    let Some(form) = &app.secret_form else {
401        return;
402    };
403    let width = 60.min(area.width.saturating_sub(4));
404    let height = 6.min(area.height);
405    let popup = Rect {
406        x: area.x + (area.width.saturating_sub(width)) / 2,
407        y: area.y + (area.height.saturating_sub(height)) / 2,
408        width,
409        height,
410    };
411    f.render_widget(Clear, popup);
412    let title = match &form.scope {
413        None => "New secret scope ".to_string(),
414        Some(s) => format!("New secret in {s} "),
415    };
416    let block = Block::default()
417        .title(Line::from(vec![
418            Span::styled(" ◈ ", Style::default().fg(p.warn)),
419            Span::styled(
420                title,
421                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
422            ),
423        ]))
424        .borders(Borders::ALL)
425        .border_type(BorderType::Thick)
426        .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
427        .padding(Padding::new(1, 1, 1, 0));
428    let label0 = if form.scope.is_none() {
429        "name "
430    } else {
431        "key  "
432    };
433    let mut lines = vec![Line::from(vec![
434        Span::styled(label0, Style::default().fg(p.dim)),
435        Span::styled(form.key.as_str(), Style::default().fg(p.text)),
436        if form.stage == 0 {
437            Span::styled("▏", Style::default().fg(p.warn))
438        } else {
439            Span::raw("")
440        },
441    ])];
442    if form.scope.is_some() {
443        // The value is never echoed — bullets only.
444        lines.push(Line::from(vec![
445            Span::styled("value ", Style::default().fg(p.dim)),
446            Span::styled(
447                "•".repeat(form.value.chars().count()),
448                Style::default().fg(p.text),
449            ),
450            if form.stage == 1 {
451                Span::styled("▏", Style::default().fg(p.warn))
452            } else {
453                Span::raw("")
454            },
455        ]));
456    }
457    f.render_widget(Paragraph::new(lines).block(block), popup);
458}
459
460fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
461    let Some(selected) = app.pane_cfg else {
462        return;
463    };
464    let width = 52.min(area.width.saturating_sub(4));
465    let height = 11.min(area.height);
466    let popup = Rect {
467        x: area.x + (area.width.saturating_sub(width)) / 2,
468        y: area.y + (area.height.saturating_sub(height)) / 2,
469        width,
470        height,
471    };
472    f.render_widget(Clear, popup);
473    let block = Block::default()
474        .title(Line::from(vec![
475            Span::styled(" ▦ ", Style::default().fg(p.key)),
476            Span::styled(
477                "Panes ",
478                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
479            ),
480        ]))
481        .borders(Borders::ALL)
482        .border_type(BorderType::Thick)
483        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
484        .padding(Padding::new(1, 1, 1, 0));
485    let items: Vec<ListItem> = app
486        .pane_order
487        .iter()
488        .map(|&i| {
489            let panel = Panel::ALL[i];
490            let (mark, style) = if app.hidden[i] {
491                ("○ ", Style::default().fg(p.dim))
492            } else {
493                ("● ", Style::default().fg(p.ok))
494            };
495            ListItem::new(Line::from(vec![
496                Span::styled(mark, style),
497                Span::styled(
498                    format!("{} ", panel.icon()),
499                    Style::default().fg(accent(panel, p)),
500                ),
501                Span::styled(
502                    panel.title(),
503                    if app.hidden[i] {
504                        Style::default().fg(p.dim)
505                    } else {
506                        Style::default().fg(p.text)
507                    },
508                ),
509            ]))
510        })
511        .collect();
512    let list = List::new(items)
513        .block(block)
514        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
515    let mut state = ListState::default().with_selected(Some(selected));
516    f.render_stateful_widget(list, popup, &mut state);
517}
518
519fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
520    let width = 74.min(area.width.saturating_sub(4));
521    let height = area.height.saturating_sub(2);
522    let popup = Rect {
523        x: area.x + (area.width.saturating_sub(width)) / 2,
524        y: area.y + 1,
525        width,
526        height,
527    };
528    f.render_widget(Clear, popup);
529    let block = Block::default()
530        .title(Line::from(vec![
531            Span::styled(" ? ", Style::default().fg(p.key)),
532            Span::styled(
533                "Keys ",
534                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
535            ),
536        ]))
537        .borders(Borders::ALL)
538        .border_type(BorderType::Thick)
539        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
540        .padding(Padding::new(1, 1, 1, 0));
541
542    let sections: &[(&str, &[(&str, &str)])] = &[
543        (
544            "Global",
545            &[
546                ("tab / l, shift+tab / h", "focus next / previous pane"),
547                ("j / k", "select item"),
548                ("enter", "open details / drill down"),
549                ("/", "filter the focused pane (esc clears)"),
550                ("ctrl+p", "command palette: fuzzy-jump anywhere"),
551                ("!", "problems: failures in every workspace, enter jumps"),
552                ("u", "upcoming runs: what fires next, soonest first"),
553                (":", "SQL console (prefilled on a catalog table)"),
554                ("$", "cost view: DBUs, dollars, top spenders"),
555                ("H", "arrange panes: hide and reorder"),
556                ("z", "zoom the focused pane"),
557                ("w", "switch workspace profile"),
558                ("t", "cycle color theme"),
559                ("r", "refresh now"),
560                ("?", "this help"),
561                ("q / ctrl+c", "quit"),
562            ],
563        ),
564        (
565            "Focused pane",
566            &[
567                ("s", "start / stop / run the selected item"),
568                ("g", "access: grants and permissions"),
569                ("o", "open in the workspace web UI"),
570            ],
571        ),
572        (
573            "Unity Catalog pane",
574            &[
575                ("enter / backspace", "drill down / up (into volumes too)"),
576                ("p / P", "preview table rows (P picks the warehouse)"),
577                ("L", "lineage: upstream and downstream tables"),
578                (":", "query the selected table"),
579                ("enter on a file", "peek at its first 200 lines"),
580            ],
581        ),
582        (
583            "Details & runs",
584            &[
585                ("enter", "job/pipeline detail → latest run/update"),
586                ("h / l", "older / newer run"),
587                ("o", "full task output — keeps tailing while live"),
588                ("t", "timeline: per-task Gantt of the run"),
589                ("d", "dag: task dependency tree of the run"),
590                ("r", "repair a failed run — reruns only failed tasks"),
591                ("s", "cancel the shown run"),
592                ("j / k, J", "scroll · toggle raw JSON"),
593            ],
594        ),
595        (
596            "Table previews",
597            &[
598                ("j / k", "scroll rows"),
599                ("← / →", "page columns (wide tables)"),
600                ("/", "filter columns by name"),
601                ("v / enter", "record view: one row, fields stacked"),
602                ("e", "export rows to CSV"),
603            ],
604        ),
605        (
606            "Secret scopes pane",
607            &[
608                ("enter / backspace", "open a scope / back to scopes"),
609                ("a", "create a scope / add a secret (value masked)"),
610                ("x", "delete the selected scope or secret"),
611                ("g", "scope ACLs"),
612            ],
613        ),
614        (
615            "SQL console",
616            &[
617                ("enter", "run the statement"),
618                ("tab", "complete catalog/schema/table/column names"),
619                ("↑ / ↓, ctrl+r", "history · incremental search"),
620                ("ctrl+x", "compose in $EDITOR"),
621                ("ctrl+s", "export results to CSV"),
622                ("pgup / pgdn", "scroll results"),
623                ("shift+← / →", "page result columns"),
624                ("esc", "cancel a running query, else close"),
625            ],
626        ),
627    ];
628    let mut lines: Vec<Line> = Vec::new();
629    for (title, keys) in sections {
630        lines.push(Line::from(Span::styled(
631            *title,
632            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
633        )));
634        for (k, desc) in *keys {
635            lines.push(Line::from(vec![
636                Span::styled(format!("  {k:<24}"), Style::default().fg(p.warn)),
637                Span::styled(*desc, Style::default().fg(p.text)),
638            ]));
639        }
640        lines.push(Line::default());
641    }
642    let total = lines.len();
643    let par = Paragraph::new(lines)
644        .scroll((app.help_scroll, 0))
645        .block(block);
646    f.render_widget(par, popup);
647    // Viewport loses the borders plus the block's one line of top padding.
648    scrollbar(
649        f,
650        popup,
651        total,
652        popup.height.saturating_sub(3) as usize,
653        app.help_scroll as usize,
654        p,
655    );
656}
657
658fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
659    let Some(jump) = &app.jump else {
660        return;
661    };
662    let matches = app.jump_matches();
663    let width = 70.min(area.width.saturating_sub(4));
664    let height = (matches.len() as u16 + 5).min(area.height);
665    let popup = Rect {
666        x: area.x + (area.width.saturating_sub(width)) / 2,
667        y: area.y + 2.min(area.height.saturating_sub(height)),
668        width,
669        height,
670    };
671    f.render_widget(Clear, popup);
672    let block = Block::default()
673        .title(Line::from(vec![
674            Span::styled(" ⌕ ", Style::default().fg(p.key)),
675            Span::styled(
676                "Jump to ",
677                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
678            ),
679        ]))
680        .borders(Borders::ALL)
681        .border_type(BorderType::Thick)
682        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
683        .padding(Padding::horizontal(1));
684    let inner = block.inner(popup);
685    f.render_widget(block, popup);
686
687    let parts = Layout::default()
688        .direction(Direction::Vertical)
689        .constraints([Constraint::Length(2), Constraint::Min(0)])
690        .split(inner);
691    f.render_widget(
692        Paragraph::new(Line::from(vec![
693            Span::styled("❯ ", Style::default().fg(p.key)),
694            Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
695            Span::styled("▏", Style::default().fg(p.warn)),
696        ])),
697        parts[0],
698    );
699
700    if matches.is_empty() {
701        f.render_widget(
702            Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
703            parts[1],
704        );
705        return;
706    }
707    let items: Vec<ListItem> = matches
708        .iter()
709        .map(|(panel_idx, name, label)| {
710            let panel = Panel::ALL[*panel_idx];
711            ListItem::new(Line::from(vec![
712                Span::styled(
713                    format!("{} ", panel.icon()),
714                    Style::default().fg(accent(panel, p)),
715                ),
716                Span::styled(format!("{name}  "), Style::default().fg(p.text)),
717                Span::styled(label.clone(), Style::default().fg(p.dim)),
718            ]))
719        })
720        .collect();
721    let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
722    let mut state = ListState::default().with_selected(Some(jump.index));
723    f.render_stateful_widget(list, parts[1], &mut state);
724}
725
726fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
727    let Some(pr) = &app.problems else {
728        return;
729    };
730    let truncate = |s: &str, max: usize| -> String {
731        if s.chars().count() <= max {
732            s.to_string()
733        } else {
734            let cut: String = s.chars().take(max.saturating_sub(1)).collect();
735            format!("{cut}…")
736        }
737    };
738    // Names get a bounded column (cross-workspace rows carry a
739    // "profile ▸ " prefix); notes (which can be whole CLI error lines)
740    // take whatever room is left and are truncated to fit.
741    let prefix_of = |problem: &crate::app::Problem| -> String {
742        problem
743            .profile
744            .as_ref()
745            .map(|pr| format!("{pr} ▸ "))
746            .unwrap_or_default()
747    };
748    let name_w = pr
749        .items
750        .iter()
751        .map(|i| i.name.chars().count() + prefix_of(i).chars().count())
752        .max()
753        .unwrap_or(10)
754        .clamp(10, 40);
755    let note_w = pr
756        .items
757        .iter()
758        .map(|i| i.note.chars().count())
759        .max()
760        .unwrap_or(0);
761    // dot(2) + name + gap(2) + icon+title(20) + note + padding/borders(8)
762    let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
763    let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
764    // Room left for the note on each row inside borders and padding.
765    let note_space = (width as usize).saturating_sub(name_w + 30);
766    let popup = Rect {
767        x: area.x + (area.width.saturating_sub(width)) / 2,
768        y: area.y + (area.height.saturating_sub(height)) / 2,
769        width,
770        height,
771    };
772    f.render_widget(Clear, popup);
773    let mut title_spans = vec![
774        Span::styled(" ✗ ", Style::default().fg(p.err)),
775        Span::styled(
776            format!("Problems · {} ", pr.items.len()),
777            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
778        ),
779    ];
780    if pr.scanning {
781        title_spans.push(Span::styled(
782            format!("· {} scanning workspaces ", app.spinner()),
783            Style::default().fg(p.warn),
784        ));
785    }
786    let block = Block::default()
787        .title(Line::from(title_spans))
788        .borders(Borders::ALL)
789        .border_type(BorderType::Rounded)
790        .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
791        .padding(Padding::new(1, 1, 1, 1));
792
793    if pr.items.is_empty() {
794        let (text, color) = if pr.scanning {
795            (
796                format!(
797                    "✓ nothing failing here — {} scanning other workspaces…",
798                    app.spinner()
799                ),
800                p.warn,
801            )
802        } else {
803            ("✓ all clear — nothing failing right now".to_string(), p.ok)
804        };
805        let par = Paragraph::new(text)
806            .style(Style::default().fg(color))
807            .block(block);
808        f.render_widget(par, popup);
809        return;
810    }
811
812    let items: Vec<ListItem> = pr
813        .items
814        .iter()
815        .map(|problem| {
816            let prefix = prefix_of(problem);
817            let plen = prefix.chars().count().min(name_w.saturating_sub(4));
818            let mut spans = vec![
819                Span::styled("✗ ", Style::default().fg(p.err)),
820                Span::styled(truncate(&prefix, plen), Style::default().fg(p.dim)),
821                Span::styled(
822                    format!(
823                        "{:<width$}",
824                        truncate(&problem.name, name_w - plen),
825                        width = name_w + 2 - plen
826                    ),
827                    Style::default().fg(p.text),
828                ),
829            ];
830            spans.push(match problem.panel {
831                Some(i) => {
832                    let panel = Panel::ALL[i];
833                    Span::styled(
834                        format!("{} {:<18}", panel.icon(), panel.title()),
835                        Style::default().fg(accent(panel, p)),
836                    )
837                }
838                None => Span::styled(format!("⌂ {:<18}", "workspace"), Style::default().fg(p.dim)),
839            });
840            spans.push(Span::styled(
841                truncate(&problem.note, note_space),
842                Style::default().fg(p.dim),
843            ));
844            ListItem::new(Line::from(spans))
845        })
846        .collect();
847    let list = List::new(items)
848        .block(block)
849        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
850    let mut state = ListState::default().with_selected(Some(pr.index));
851    f.render_stateful_widget(list, popup, &mut state);
852}
853
854/// Overlay listing every job that will run again on its own, soonest
855/// first: countdown, job name, and the schedule that drives it.
856fn draw_upcoming(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
857    let Some(u) = &app.upcoming else {
858        return;
859    };
860    let label_w = u
861        .items
862        .iter()
863        .map(|i| i.next.label.chars().count())
864        .max()
865        .unwrap_or(8)
866        .clamp(8, 18);
867    let name_w = u
868        .items
869        .iter()
870        .map(|i| i.name.chars().count())
871        .max()
872        .unwrap_or(10)
873        .clamp(10, 40);
874    let desc_w = u
875        .items
876        .iter()
877        .map(|i| i.next.desc.chars().count())
878        .max()
879        .unwrap_or(0);
880    let width = ((label_w + name_w + desc_w + 12) as u16).min(area.width.saturating_sub(4));
881    let height = (u.items.len().max(1) as u16 + 4).min(area.height);
882    let desc_space = (width as usize).saturating_sub(label_w + name_w + 10);
883    let popup = Rect {
884        x: area.x + (area.width.saturating_sub(width)) / 2,
885        y: area.y + (area.height.saturating_sub(height)) / 2,
886        width,
887        height,
888    };
889    f.render_widget(Clear, popup);
890    let block = Block::default()
891        .title(Line::from(vec![
892            Span::styled(" ⏱ ", Style::default().fg(p.key)),
893            Span::styled(
894                format!("Upcoming runs · {} ", u.items.len()),
895                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
896            ),
897        ]))
898        .borders(Borders::ALL)
899        .border_type(BorderType::Rounded)
900        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
901        .padding(Padding::new(1, 1, 1, 1));
902
903    if u.loading {
904        let par = Paragraph::new(format!("{} reading job schedules…", app.spinner()))
905            .style(Style::default().fg(p.warn))
906            .block(block);
907        f.render_widget(par, popup);
908        return;
909    }
910    if u.items.is_empty() {
911        let par = Paragraph::new("no scheduled or triggered jobs — everything runs on demand")
912            .style(Style::default().fg(p.dim))
913            .block(block);
914        f.render_widget(par, popup);
915        return;
916    }
917
918    let truncate = |s: &str, max: usize| -> String {
919        if s.chars().count() <= max {
920            s.to_string()
921        } else {
922            let cut: String = s.chars().take(max.saturating_sub(1)).collect();
923            format!("{cut}…")
924        }
925    };
926    let items: Vec<ListItem> = u
927        .items
928        .iter()
929        .map(|it| {
930            let when_color = if it.next.paused {
931                p.dim
932            } else if it.next.at_ms.is_some() {
933                p.ok
934            } else {
935                p.warn
936            };
937            ListItem::new(Line::from(vec![
938                Span::styled(
939                    format!(
940                        "{:<width$}",
941                        truncate(&it.next.label, label_w),
942                        width = label_w + 2
943                    ),
944                    Style::default().fg(when_color),
945                ),
946                Span::styled(
947                    format!("{:<width$}", truncate(&it.name, name_w), width = name_w + 2),
948                    Style::default().fg(p.text),
949                ),
950                Span::styled(
951                    truncate(&it.next.desc, desc_space),
952                    Style::default().fg(p.dim),
953                ),
954            ]))
955        })
956        .collect();
957    let list = List::new(items)
958        .block(block)
959        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
960    let mut state = ListState::default().with_selected(Some(u.index));
961    f.render_stateful_widget(list, popup, &mut state);
962}
963
964fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
965    let Some(picker) = &app.wh_picker else {
966        return;
967    };
968    let warehouses = app.warehouses();
969    let name_w = warehouses
970        .iter()
971        .map(|(n, _, _)| n.chars().count())
972        .max()
973        .unwrap_or(12)
974        .max(12);
975    // marker(2) + dot(2) + name + gap(2) + state(7) + gap(2) + id(16) + padding/borders(6)
976    let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
977    let height = (warehouses.len() as u16 + 4).min(area.height);
978    let popup = Rect {
979        x: area.x + (area.width.saturating_sub(width)) / 2,
980        y: area.y + (area.height.saturating_sub(height)) / 2,
981        width,
982        height,
983    };
984    f.render_widget(Clear, popup);
985    let block = Block::default()
986        .title(Line::from(vec![
987            Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
988            Span::styled(
989                "Run query on ",
990                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
991            ),
992        ]))
993        .borders(Borders::ALL)
994        .border_type(BorderType::Rounded)
995        .border_style(
996            Style::default()
997                .fg(p.warehouses)
998                .add_modifier(Modifier::BOLD),
999        )
1000        .padding(Padding::new(1, 1, 1, 1));
1001    let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
1002    let items: Vec<ListItem> = warehouses
1003        .iter()
1004        .map(|(name, id, running)| {
1005            let dot = if *running { p.ok } else { p.dim };
1006            let marker = if current_id == Some(id.as_str()) {
1007                "» "
1008            } else {
1009                "  "
1010            };
1011            let state = if *running { "running" } else { "idle   " };
1012            ListItem::new(Line::from(vec![
1013                Span::styled(marker, Style::default().fg(p.key)),
1014                Span::styled("● ", Style::default().fg(dot)),
1015                Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
1016                Span::styled(
1017                    format!("  {state}"),
1018                    Style::default().fg(if *running { p.ok } else { p.dim }),
1019                ),
1020                Span::styled(format!("  {id}"), Style::default().fg(p.dim)),
1021            ]))
1022        })
1023        .collect();
1024    let list = List::new(items)
1025        .block(block)
1026        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1027    let mut state = ListState::default().with_selected(Some(picker.index));
1028    f.render_stateful_widget(list, popup, &mut state);
1029}
1030
1031const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
1032const WORDMARK_BOT: &str = "█▄▀ █▀█  █  █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
1033
1034fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1035    let lines_total = 9u16;
1036    let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
1037    let frame = app.spinner_frame();
1038
1039    // Brick-mark pyramid in Databricks red.
1040    let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
1041    for (i, row) in bricks.iter().enumerate() {
1042        let line = Line::from(Span::styled(
1043            *row,
1044            Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
1045        ));
1046        let rect = Rect {
1047            x: area.x,
1048            y: top + i as u16,
1049            width: area.width,
1050            height: 1,
1051        };
1052        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1053    }
1054
1055    // Wordmark with a light sweep that travels across the letters.
1056    let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
1057    for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
1058        let spans: Vec<Span> = text
1059            .chars()
1060            .enumerate()
1061            .map(|(i, c)| {
1062                let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
1063                let style = if dist < 3 {
1064                    Style::default()
1065                        .fg(Color::White)
1066                        .add_modifier(Modifier::BOLD)
1067                } else {
1068                    Style::default().fg(p.brand)
1069                };
1070                Span::styled(c.to_string(), style)
1071            })
1072            .collect();
1073        let rect = Rect {
1074            x: area.x,
1075            y: top + row,
1076            width: area.width,
1077            height: 1,
1078        };
1079        f.render_widget(
1080            Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
1081            rect,
1082        );
1083    }
1084
1085    let tagline = Line::from(vec![
1086        Span::styled("the ", Style::default().fg(p.dim)),
1087        Span::styled(
1088            "Lakehouse",
1089            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1090        ),
1091        Span::styled(" in your terminal", Style::default().fg(p.dim)),
1092    ]);
1093    let version = Line::from(Span::styled(
1094        format!("v{}", env!("CARGO_PKG_VERSION")),
1095        Style::default().fg(p.dim),
1096    ));
1097    for (offset, line) in [(7u16, tagline), (8u16, version)] {
1098        let rect = Rect {
1099            x: area.x,
1100            y: top + offset,
1101            width: area.width,
1102            height: 1,
1103        };
1104        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1105    }
1106}
1107
1108fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1109    let selected = app.picker.unwrap_or(0);
1110    let width = (app
1111        .profiles
1112        .iter()
1113        .map(|s| s.len())
1114        .max()
1115        .unwrap_or(10)
1116        .max(20) as u16
1117        + 6)
1118    .min(area.width);
1119    let height = (app.profiles.len() as u16 + 2).min(area.height);
1120    let popup = Rect {
1121        x: area.x + (area.width.saturating_sub(width)) / 2,
1122        y: area.y + (area.height.saturating_sub(height)) / 2,
1123        width,
1124        height,
1125    };
1126    f.render_widget(Clear, popup);
1127    let block = Block::default()
1128        .title(Line::from(Span::styled(
1129            " ⌂ Workspace ",
1130            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1131        )))
1132        .borders(Borders::ALL)
1133        .border_type(BorderType::Rounded)
1134        .border_style(Style::default().fg(p.key))
1135        .padding(Padding::horizontal(1));
1136    let items: Vec<ListItem> = app
1137        .profiles
1138        .iter()
1139        .map(|name| {
1140            let current = app.profile.as_deref() == Some(name.as_str());
1141            let marker = if current { "● " } else { "  " };
1142            ListItem::new(Line::from(vec![
1143                Span::styled(marker, Style::default().fg(p.ok)),
1144                Span::styled(name.as_str(), Style::default().fg(p.text)),
1145            ]))
1146        })
1147        .collect();
1148    let list = List::new(items)
1149        .block(block)
1150        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1151    let mut state = ListState::default().with_selected(Some(selected));
1152    f.render_stateful_widget(list, popup, &mut state);
1153}
1154
1155fn bucket_color(bucket: &str, p: &Palette) -> Color {
1156    match bucket {
1157        "Jobs" => p.jobs,
1158        "SQL" => p.warehouses,
1159        "All-Purpose" => p.clusters,
1160        "DLT" => p.pipelines,
1161        _ => p.dim,
1162    }
1163}
1164
1165fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1166    let cv = app.cost.as_ref().unwrap();
1167    let scope = match &cv.data {
1168        Some(Ok(d)) if d.scoped => " · this workspace",
1169        Some(Ok(_)) => " · all workspaces",
1170        _ => "",
1171    };
1172    let title = Line::from(vec![
1173        Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1174        Span::styled(
1175            format!("Usage · last 14 days{scope} "),
1176            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1177        ),
1178        Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1179    ]);
1180    let block = Block::default()
1181        .title(title)
1182        .borders(Borders::ALL)
1183        .border_type(BorderType::Rounded)
1184        .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1185        .padding(Padding::new(1, 1, 1, 1));
1186
1187    match &cv.data {
1188        None => {
1189            let par = Paragraph::new(format!(
1190                "{} querying system.billing.usage — the warehouse may need to start…",
1191                app.spinner()
1192            ))
1193            .style(Style::default().fg(p.warn))
1194            .block(block);
1195            f.render_widget(par, area);
1196        }
1197        Some(Err(e)) => {
1198            let par = Paragraph::new(format!(
1199                "✗ {e}\n\nsystem tables need to be enabled and readable \
1200                 (grants on the `system` catalog)"
1201            ))
1202            .style(Style::default().fg(p.err))
1203            .wrap(Wrap { trim: false })
1204            .block(block);
1205            f.render_widget(par, area);
1206        }
1207        Some(Ok(data)) if data.days.is_empty() => {
1208            let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1209                .style(Style::default().fg(p.dim))
1210                .block(block);
1211            f.render_widget(par, area);
1212        }
1213        Some(Ok(data)) => {
1214            let mut lines: Vec<Line> = Vec::new();
1215
1216            // Legend with per-bucket totals, largest first.
1217            let mut legend = vec![Span::raw("")];
1218            for (bucket, total, usd) in &data.buckets {
1219                legend.push(Span::styled(
1220                    "■ ",
1221                    Style::default().fg(bucket_color(bucket, p)),
1222                ));
1223                let amount = if data.priced {
1224                    format!("{bucket} {total:.1} (${usd:.0})   ")
1225                } else {
1226                    format!("{bucket} {total:.1}   ")
1227                };
1228                legend.push(Span::styled(amount, Style::default().fg(p.text)));
1229            }
1230            let sigma = if data.priced {
1231                format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1232            } else {
1233                format!("Σ {:.1} DBU", data.total)
1234            };
1235            legend.push(Span::styled(
1236                sigma,
1237                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1238            ));
1239            lines.push(Line::from(legend));
1240            if data.priced {
1241                lines.push(Line::from(Span::styled(
1242                    " list prices before discounts",
1243                    Style::default().fg(p.dim),
1244                )));
1245            }
1246            if !data.scoped {
1247                lines.push(Line::from(Span::styled(
1248                    " couldn't resolve this workspace's id — showing the whole account",
1249                    Style::default().fg(p.warn),
1250                )));
1251            }
1252            lines.push(Line::default());
1253
1254            let max_day = data
1255                .days
1256                .iter()
1257                .map(|d| d.total)
1258                .fold(0.0_f64, f64::max)
1259                .max(f64::EPSILON);
1260            let inner_w = area.width.saturating_sub(4) as usize;
1261            let bar_w = inner_w.saturating_sub(22).max(10);
1262
1263            for day in &data.days {
1264                let mut spans = vec![Span::styled(
1265                    // "2026-07-01" -> "07-01"
1266                    format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1267                    Style::default().fg(p.dim),
1268                )];
1269                for (bucket, value) in &day.by_bucket {
1270                    let chars = ((value / max_day) * bar_w as f64)
1271                        .round()
1272                        .max(if *value > 0.0 { 1.0 } else { 0.0 })
1273                        as usize;
1274                    spans.push(Span::styled(
1275                        "█".repeat(chars),
1276                        Style::default().fg(bucket_color(bucket, p)),
1277                    ));
1278                }
1279                let day_label = if data.priced {
1280                    format!("  {:.1} · ${:.2}", day.total, day.total_usd)
1281                } else {
1282                    format!("  {:.1}", day.total)
1283                };
1284                spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1285                lines.push(Line::from(spans));
1286            }
1287
1288            if !data.spenders.is_empty() {
1289                lines.push(Line::default());
1290                lines.push(Line::from(Span::styled(
1291                    "TOP SPENDERS · 14 days",
1292                    Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1293                )));
1294                let name_w = data
1295                    .spenders
1296                    .iter()
1297                    .map(|s| {
1298                        app.resource_name(&s.kind, &s.id)
1299                            .unwrap_or_else(|| s.id.clone())
1300                            .chars()
1301                            .count()
1302                    })
1303                    .max()
1304                    .unwrap_or(0)
1305                    .max(8);
1306                for (i, s) in data.spenders.iter().enumerate() {
1307                    let name = app
1308                        .resource_name(&s.kind, &s.id)
1309                        .unwrap_or_else(|| s.id.clone());
1310                    let kind_color = match s.kind.as_str() {
1311                        "job" => p.jobs,
1312                        "cluster" => p.clusters,
1313                        "warehouse" => p.warehouses,
1314                        _ => p.dim,
1315                    };
1316                    let amount = if data.priced {
1317                        format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1318                    } else {
1319                        format!("{:>8.1} DBU", s.dbus)
1320                    };
1321                    lines.push(Line::from(vec![
1322                        Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1323                        Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1324                        Span::styled(
1325                            format!("{:<width$}", name, width = name_w + 2),
1326                            Style::default().fg(p.text),
1327                        ),
1328                        Span::styled(amount, Style::default().fg(p.text)),
1329                    ]));
1330                }
1331            }
1332
1333            let par = Paragraph::new(lines).block(block);
1334            f.render_widget(par, area);
1335        }
1336    }
1337}
1338
1339fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1340    let console = app.sql.as_ref().unwrap();
1341    let parts = Layout::default()
1342        .direction(Direction::Vertical)
1343        .constraints([Constraint::Length(3), Constraint::Min(0)])
1344        .split(area);
1345
1346    let via = if console.warehouse.is_empty() {
1347        String::new()
1348    } else {
1349        format!("via {} ", console.warehouse)
1350    };
1351    let prompt_block = Block::default()
1352        .title(Line::from(vec![
1353            Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1354            Span::styled(
1355                "SQL Console ",
1356                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1357            ),
1358            Span::styled(via, Style::default().fg(p.dim)),
1359        ]))
1360        .borders(Borders::ALL)
1361        .border_type(BorderType::Thick)
1362        .border_style(
1363            Style::default()
1364                .fg(p.warehouses)
1365                .add_modifier(Modifier::BOLD),
1366        )
1367        .padding(Padding::horizontal(1));
1368    if let Some((query, _)) = &app.hist_search {
1369        // Readline-style incremental search over history.
1370        let matched = app
1371            .hist_search_current()
1372            .map(|s| s.replace('\n', " "))
1373            .unwrap_or_default();
1374        let mut spans = vec![
1375            Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1376            Span::styled(
1377                query.as_str(),
1378                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1379            ),
1380            Span::styled("`: ", Style::default().fg(p.dim)),
1381        ];
1382        spans.extend(sql_spans(&matched, p));
1383        let prompt = Paragraph::new(Line::from(spans)).block(prompt_block);
1384        f.render_widget(prompt, parts[0]);
1385    } else {
1386        // Caret sits at the cursor, not the end; long inputs scroll so
1387        // the caret stays visible. Newlines from $EDITOR render as
1388        // spaces (byte-for-byte, so the caret math holds).
1389        let caret_byte = console
1390            .input
1391            .char_indices()
1392            .nth(console.cursor)
1393            .map(|(i, _)| i)
1394            .unwrap_or(console.input.len());
1395        let (before, after) = console.input.split_at(caret_byte);
1396        let inner_w = parts[0].width.saturating_sub(4) as usize; // borders + padding
1397        let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1398        // Highlight each side of the caret separately — a token split by
1399        // the caret loses its color for a keystroke, which is fine.
1400        let mut spans = vec![Span::styled("❯ ", Style::default().fg(p.key))];
1401        spans.extend(sql_spans(&before.replace('\n', " "), p));
1402        spans.push(Span::styled("▏", Style::default().fg(p.warn)));
1403        spans.extend(sql_spans(&after.replace('\n', " "), p));
1404        let prompt = Paragraph::new(Line::from(spans))
1405            .scroll((0, hscroll))
1406            .block(prompt_block);
1407        f.render_widget(prompt, parts[0]);
1408    }
1409
1410    let row_info = match &console.data {
1411        Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1412        _ => String::new(),
1413    };
1414    let mut results_title = vec![
1415        Span::styled(" Results ", Style::default().fg(p.text)),
1416        Span::styled(row_info, Style::default().fg(p.dim)),
1417    ];
1418    // Wide results page horizontally; say where we are.
1419    let avail = parts[1].width.saturating_sub(4) as usize;
1420    let sliced = match &console.data {
1421        Some(Ok(t)) if !t.headers.is_empty() => {
1422            let cols: Vec<usize> = (0..t.headers.len()).collect();
1423            let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
1424            if shown.len() < cols.len() {
1425                let first = console.col.min(cols.len() - 1) + 1;
1426                results_title.push(Span::styled(
1427                    format!(
1428                        "· cols {}–{} of {} (⇧←/→) ",
1429                        first,
1430                        first + shown.len() - 1,
1431                        cols.len()
1432                    ),
1433                    Style::default().fg(p.dim),
1434                ));
1435            }
1436            Some((shown, constraints))
1437        }
1438        _ => None,
1439    };
1440    let results_block = Block::default()
1441        .title(Line::from(results_title))
1442        .borders(Borders::ALL)
1443        .border_type(BorderType::Rounded)
1444        .border_style(Style::default().fg(p.border))
1445        .padding(Padding::horizontal(1));
1446
1447    if console.running {
1448        let par = Paragraph::new(format!(
1449            "{} running on {} — the warehouse may need a moment to start…",
1450            app.spinner(),
1451            console.warehouse
1452        ))
1453        .style(Style::default().fg(p.warn))
1454        .block(results_block);
1455        f.render_widget(par, parts[1]);
1456        return;
1457    }
1458    match &console.data {
1459        None => {
1460            let par = Paragraph::new(
1461                "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1462            )
1463            .style(Style::default().fg(p.dim))
1464            .block(results_block);
1465            f.render_widget(par, parts[1]);
1466        }
1467        Some(Err(e)) => {
1468            let par = Paragraph::new(format!("✗ {e}"))
1469                .style(Style::default().fg(p.err))
1470                .wrap(Wrap { trim: false })
1471                .block(results_block);
1472            f.render_widget(par, parts[1]);
1473        }
1474        Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1475            let par = Paragraph::new("✓ statement succeeded — no result set")
1476                .style(Style::default().fg(p.ok))
1477                .block(results_block);
1478            f.render_widget(par, parts[1]);
1479        }
1480        Some(Ok(data)) => {
1481            let (shown, constraints) = sliced.unwrap_or_default();
1482            let header_cells: Vec<Cell> = shown
1483                .iter()
1484                .map(|&i| {
1485                    Cell::from(data.headers[i].as_str()).style(
1486                        Style::default()
1487                            .fg(p.warehouses)
1488                            .add_modifier(Modifier::BOLD),
1489                    )
1490                })
1491                .collect();
1492            let header = Row::new(header_cells);
1493            let rows: Vec<Row> = data
1494                .rows
1495                .iter()
1496                .skip(console.scroll)
1497                .map(|r| {
1498                    Row::new(
1499                        shown
1500                            .iter()
1501                            .map(|&i| {
1502                                Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
1503                                    .style(Style::default().fg(p.text))
1504                            })
1505                            .collect::<Vec<_>>(),
1506                    )
1507                })
1508                .collect();
1509            let table = Table::new(rows, constraints)
1510                .header(header)
1511                .column_spacing(1)
1512                .block(results_block);
1513            f.render_widget(table, parts[1]);
1514            // Borders plus the header row eat three lines of the viewport.
1515            scrollbar(
1516                f,
1517                parts[1],
1518                data.rows.len(),
1519                parts[1].height.saturating_sub(3) as usize,
1520                console.scroll,
1521                p,
1522            );
1523        }
1524    }
1525}
1526
1527/// Tab-completion popup, anchored under the segment being completed.
1528fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1529    let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
1530        return;
1531    };
1532    // Mirror the prompt geometry from draw_sql to find the anchor column.
1533    let inner_w = area.width.saturating_sub(4) as usize;
1534    let hscroll = (console.cursor + 3).saturating_sub(inner_w);
1535    let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
1536
1537    let lines: Vec<Line> = if comp.loading {
1538        vec![Line::from(Span::styled(
1539            format!("{} fetching names…", app.spinner()),
1540            Style::default().fg(p.warn),
1541        ))]
1542    } else {
1543        // Keep the highlighted candidate inside the window.
1544        let visible = 8.min(comp.items.len());
1545        let skip = comp.index.saturating_sub(visible.saturating_sub(1));
1546        comp.items
1547            .iter()
1548            .enumerate()
1549            .skip(skip)
1550            .take(visible)
1551            .map(|(i, item)| {
1552                let style = if i == comp.index {
1553                    Style::default()
1554                        .fg(p.key)
1555                        .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1556                } else {
1557                    Style::default().fg(p.text)
1558                };
1559                Line::from(Span::styled(format!(" {item} "), style))
1560            })
1561            .collect()
1562    };
1563    let widest = comp
1564        .items
1565        .iter()
1566        .map(|i| i.chars().count())
1567        .max()
1568        .unwrap_or(16);
1569    let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
1570    let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
1571    let popup = Rect {
1572        x: anchor.min(area.x + area.width.saturating_sub(width)),
1573        y: area.y + 3,
1574        width,
1575        height,
1576    };
1577    let title = if comp.loading {
1578        String::new()
1579    } else {
1580        format!(" {}/{} ", comp.index + 1, comp.items.len())
1581    };
1582    let block = Block::default()
1583        .title(Span::styled(title, Style::default().fg(p.dim)))
1584        .borders(Borders::ALL)
1585        .border_type(BorderType::Rounded)
1586        .border_style(Style::default().fg(p.key));
1587    f.render_widget(Clear, popup);
1588    f.render_widget(Paragraph::new(lines).block(block), popup);
1589}
1590
1591/// Natural display width of each column in `cols`: the widest of header
1592/// and sampled cells, clamped so one huge value can't eat the pane.
1593fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
1594    cols.iter()
1595        .map(|&i| {
1596            let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
1597            for row in data.rows.iter().take(50) {
1598                if let Some(c) = row.get(i) {
1599                    w = w.max(c.chars().count());
1600                }
1601            }
1602            w.clamp(4, 36) as u16
1603        })
1604        .collect()
1605}
1606
1607/// The slice of `cols` that fits in `avail` starting at offset `col`,
1608/// with Length constraints — at least one column so the grid never
1609/// vanishes. Wide results page through here instead of squeezing every
1610/// column into an unreadable sliver.
1611fn grid_slice(
1612    data: &TableData,
1613    cols: &[usize],
1614    col: usize,
1615    avail: usize,
1616) -> (Vec<usize>, Vec<Constraint>) {
1617    let widths = grid_widths(data, cols);
1618    let col = col.min(cols.len().saturating_sub(1));
1619    let mut take = 0usize;
1620    let mut used = 0usize;
1621    for w in widths.iter().skip(col) {
1622        let next = used + *w as usize + if take > 0 { 1 } else { 0 };
1623        if take > 0 && next > avail {
1624            break;
1625        }
1626        used = next;
1627        take += 1;
1628    }
1629    let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
1630    let constraints: Vec<Constraint> = widths
1631        .iter()
1632        .skip(col)
1633        .take(take)
1634        .map(|&w| Constraint::Length(w))
1635        .collect();
1636    (shown, constraints)
1637}
1638
1639fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1640    let pv = app.preview.as_ref().unwrap();
1641    let acc = p.catalog;
1642    let mut title_spans: Vec<Span<'static>> = vec![
1643        Span::styled(" ◫ ", Style::default().fg(acc)),
1644        Span::styled(
1645            format!("{} · preview ", pv.name),
1646            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1647        ),
1648        Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1649    ];
1650    let make_block = |spans: Vec<Span<'static>>| {
1651        Block::default()
1652            .title(Line::from(spans))
1653            .borders(Borders::ALL)
1654            .border_type(BorderType::Rounded)
1655            .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1656            .padding(Padding::horizontal(1))
1657    };
1658
1659    match &pv.data {
1660        None => {
1661            let par = Paragraph::new(format!(
1662                "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1663                app.spinner()
1664            ))
1665            .style(Style::default().fg(p.warn))
1666            .block(make_block(title_spans));
1667            f.render_widget(par, area);
1668        }
1669        Some(Err(e)) => {
1670            let text = format!(
1671                "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1672                pv.warehouse,
1673                pv.warehouse_id,
1674                app.profile.as_deref().unwrap_or("default"),
1675                app.host.as_deref().unwrap_or("unknown"),
1676            );
1677            let par = Paragraph::new(text)
1678                .style(Style::default().fg(p.err))
1679                .wrap(Wrap { trim: false })
1680                .block(make_block(title_spans));
1681            f.render_widget(par, area);
1682        }
1683        Some(Ok(data)) => {
1684            title_spans.push(Span::styled(
1685                format!("· {} rows ", data.rows.len()),
1686                Style::default().fg(p.dim),
1687            ));
1688            let cols = pv.visible_cols();
1689            if !pv.filter.is_empty() {
1690                title_spans.push(Span::styled(
1691                    format!("· cols /{} ", pv.filter),
1692                    Style::default().fg(p.warn),
1693                ));
1694            }
1695            if cols.is_empty() {
1696                let par = Paragraph::new(format!(
1697                    "no columns match /{} — backspace to widen, esc to clear",
1698                    pv.filter
1699                ))
1700                .style(Style::default().fg(p.warn))
1701                .block(make_block(title_spans));
1702                f.render_widget(par, area);
1703                return;
1704            }
1705            if pv.record {
1706                // Transposed: one row, fields stacked — the readable way
1707                // through a table with hundreds of columns.
1708                let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
1709                title_spans.push(Span::styled(
1710                    format!("· row {}/{} ", row_n + 1, data.rows.len()),
1711                    Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1712                ));
1713                let name_w = cols
1714                    .iter()
1715                    .map(|&i| data.headers[i].chars().count())
1716                    .max()
1717                    .unwrap_or(0)
1718                    .min(32);
1719                let empty: Vec<String> = Vec::new();
1720                let row = data.rows.get(row_n).unwrap_or(&empty);
1721                let lines: Vec<Line> = cols
1722                    .iter()
1723                    .map(|&i| {
1724                        let value = row.get(i).map(String::as_str).unwrap_or("");
1725                        let vstyle = if value == "␀" {
1726                            Style::default().fg(p.dim)
1727                        } else {
1728                            Style::default().fg(p.text)
1729                        };
1730                        Line::from(vec![
1731                            Span::styled(
1732                                format!("{:<name_w$}  ", data.headers[i]),
1733                                Style::default().fg(acc),
1734                            ),
1735                            Span::styled(value.to_string(), vstyle),
1736                        ])
1737                    })
1738                    .collect();
1739                let total = lines.len();
1740                let par = Paragraph::new(lines)
1741                    .scroll((pv.rscroll, 0))
1742                    .block(make_block(title_spans));
1743                f.render_widget(par, area);
1744                scrollbar(
1745                    f,
1746                    area,
1747                    total,
1748                    area.height.saturating_sub(2) as usize,
1749                    pv.rscroll as usize,
1750                    p,
1751                );
1752                return;
1753            }
1754            let avail = area.width.saturating_sub(4) as usize; // borders + padding
1755            let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
1756            if shown.len() < cols.len() {
1757                let first = pv.col.min(cols.len() - 1) + 1;
1758                title_spans.push(Span::styled(
1759                    format!(
1760                        "· cols {}–{} of {} ",
1761                        first,
1762                        first + shown.len() - 1,
1763                        cols.len()
1764                    ),
1765                    Style::default().fg(p.dim),
1766                ));
1767            }
1768            let header = Row::new(
1769                shown
1770                    .iter()
1771                    .map(|&i| {
1772                        Cell::from(data.headers[i].clone())
1773                            .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1774                    })
1775                    .collect::<Vec<_>>(),
1776            );
1777            let rows: Vec<Row> = data
1778                .rows
1779                .iter()
1780                .skip(pv.scroll)
1781                .map(|r| {
1782                    Row::new(
1783                        shown
1784                            .iter()
1785                            .map(|&i| {
1786                                Cell::from(r.get(i).cloned().unwrap_or_default())
1787                                    .style(Style::default().fg(p.text))
1788                            })
1789                            .collect::<Vec<_>>(),
1790                    )
1791                })
1792                .collect();
1793            let table = Table::new(rows, constraints)
1794                .header(header)
1795                .column_spacing(1)
1796                .block(make_block(title_spans));
1797            f.render_widget(table, area);
1798            // Borders plus the header row eat three lines of the viewport.
1799            scrollbar(
1800                f,
1801                area,
1802                data.rows.len(),
1803                area.height.saturating_sub(3) as usize,
1804                pv.scroll,
1805                p,
1806            );
1807        }
1808    }
1809}
1810
1811fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1812    let d = app.detail.as_ref().unwrap();
1813    let acc = accent(d.panel, p);
1814    let title = Line::from(vec![
1815        Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1816        Span::styled(
1817            format!("{} · {} ", d.panel.title(), d.name),
1818            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1819        ),
1820    ]);
1821    let block = Block::default()
1822        .title(title)
1823        .borders(Borders::ALL)
1824        .border_type(BorderType::Rounded)
1825        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1826        .padding(Padding::horizontal(1));
1827
1828    let Some(data) = &d.data else {
1829        let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1830            .style(Style::default().fg(p.warn))
1831            .block(block);
1832        f.render_widget(par, area);
1833        return;
1834    };
1835
1836    // Fall back to raw when there's nothing structured to show (e.g. errors).
1837    if d.show_raw || data.summary.is_empty() {
1838        let par = Paragraph::new(data.raw.as_str())
1839            .style(Style::default().fg(p.text))
1840            .wrap(Wrap { trim: false })
1841            .scroll((d.scroll, 0))
1842            .block(block);
1843        f.render_widget(par, area);
1844        let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
1845        scrollbar(
1846            f,
1847            area,
1848            total,
1849            area.height.saturating_sub(2) as usize,
1850            d.scroll as usize,
1851            p,
1852        );
1853        return;
1854    }
1855
1856    let mut lines: Vec<Line> = data
1857        .summary
1858        .iter()
1859        .map(|(k, v)| {
1860            Line::from(vec![
1861                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1862                Span::styled(v.as_str(), Style::default().fg(p.text)),
1863            ])
1864        })
1865        .collect();
1866    if !data.activity.is_empty() {
1867        lines.push(Line::default());
1868        lines.push(Line::from(Span::styled(
1869            d.section,
1870            Style::default().fg(acc).add_modifier(Modifier::BOLD),
1871        )));
1872        for (status, text) in &data.activity {
1873            // Lineage tree guides carry their own structure — no dot.
1874            let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
1875            if treeish {
1876                lines.push(Line::from(Span::styled(
1877                    text.as_str(),
1878                    Style::default().fg(status_color(status, p)),
1879                )));
1880            } else {
1881                lines.push(Line::from(vec![
1882                    Span::styled("● ", Style::default().fg(status_color(status, p))),
1883                    Span::styled(text.as_str(), Style::default().fg(p.text)),
1884                ]));
1885            }
1886        }
1887    }
1888    let total = lines.len();
1889    let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1890    f.render_widget(par, area);
1891    scrollbar(
1892        f,
1893        area,
1894        total,
1895        area.height.saturating_sub(2) as usize,
1896        d.scroll as usize,
1897        p,
1898    );
1899}
1900
1901fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1902    let rv = app.run_view.as_ref().unwrap();
1903    let acc = accent(rv.panel, p);
1904    let noun = if rv.panel == Panel::Jobs {
1905        "run"
1906    } else {
1907        "update"
1908    };
1909    let mut title_spans = vec![
1910        Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1911        Span::styled(
1912            format!("{} · {noun} ", rv.owner_name),
1913            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1914        ),
1915    ];
1916    if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1917        title_spans.push(Span::styled(
1918            format!("{run_id} "),
1919            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1920        ));
1921        title_spans.push(Span::styled(
1922            format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1923            Style::default().fg(p.dim),
1924        ));
1925    }
1926    if rv.live {
1927        title_spans.push(Span::styled(
1928            format!("{} live ", app.spinner()),
1929            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1930        ));
1931    }
1932    if rv.show_output {
1933        title_spans.push(Span::styled(
1934            if rv.live {
1935                "· output (tailing) "
1936            } else {
1937                "· output "
1938            },
1939            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1940        ));
1941    }
1942    if rv.show_timeline {
1943        title_spans.push(Span::styled(
1944            "· timeline ",
1945            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1946        ));
1947    }
1948    if rv.show_dag {
1949        title_spans.push(Span::styled(
1950            "· dag ",
1951            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1952        ));
1953    }
1954    let block = Block::default()
1955        .title(Line::from(title_spans))
1956        .borders(Borders::ALL)
1957        .border_type(BorderType::Rounded)
1958        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1959        .padding(Padding::horizontal(1));
1960
1961    // Full task output/logs, layered over the run detail.
1962    if rv.show_output {
1963        let par = match &rv.output {
1964            Some(text) => {
1965                let lines: Vec<Line> = text.lines().map(|l| output_line(l, p)).collect();
1966                Paragraph::new(lines)
1967                    .wrap(Wrap { trim: false })
1968                    .scroll((rv.scroll, 0))
1969                    .block(block)
1970            }
1971            None => Paragraph::new(format!(
1972                "{} fetching task output — one call per task…",
1973                app.spinner()
1974            ))
1975            .style(Style::default().fg(p.warn))
1976            .block(block),
1977        };
1978        f.render_widget(par, area);
1979        if let Some(text) = &rv.output {
1980            let total = wrapped_height(text, area.width.saturating_sub(4) as usize);
1981            scrollbar(
1982                f,
1983                area,
1984                total,
1985                area.height.saturating_sub(2) as usize,
1986                rv.scroll as usize,
1987                p,
1988            );
1989        }
1990        return;
1991    }
1992
1993    let Some(data) = &rv.data else {
1994        let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
1995            .style(Style::default().fg(p.warn))
1996            .block(block);
1997        f.render_widget(par, area);
1998        return;
1999    };
2000
2001    if rv.show_timeline {
2002        draw_run_timeline(f, area, rv.scroll, data, block, p);
2003        return;
2004    }
2005
2006    if rv.show_dag {
2007        draw_run_dag(f, area, rv.scroll, data, block, p);
2008        return;
2009    }
2010
2011    if rv.show_raw || data.summary.is_empty() {
2012        let par = Paragraph::new(data.raw.as_str())
2013            .style(Style::default().fg(p.text))
2014            .wrap(Wrap { trim: false })
2015            .scroll((rv.scroll, 0))
2016            .block(block);
2017        f.render_widget(par, area);
2018        let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2019        scrollbar(
2020            f,
2021            area,
2022            total,
2023            area.height.saturating_sub(2) as usize,
2024            rv.scroll as usize,
2025            p,
2026        );
2027        return;
2028    }
2029
2030    let mut lines: Vec<Line> = data
2031        .summary
2032        .iter()
2033        .map(|(k, v)| {
2034            Line::from(vec![
2035                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2036                Span::styled(v.as_str(), Style::default().fg(p.text)),
2037            ])
2038        })
2039        .collect();
2040    if !data.activity.is_empty() {
2041        lines.push(Line::default());
2042        lines.push(Line::from(Span::styled(
2043            if rv.panel == Panel::Jobs {
2044                "Tasks"
2045            } else {
2046                "Event log"
2047            },
2048            Style::default().fg(acc).add_modifier(Modifier::BOLD),
2049        )));
2050        for (status, text) in &data.activity {
2051            // Error continuation lines carry their own ↳ marker.
2052            if text.starts_with("  ↳") {
2053                lines.push(Line::from(Span::styled(
2054                    text.as_str(),
2055                    Style::default().fg(p.err),
2056                )));
2057            } else {
2058                lines.push(Line::from(vec![
2059                    Span::styled("● ", Style::default().fg(status_color(status, p))),
2060                    Span::styled(text.as_str(), Style::default().fg(p.text)),
2061                ]));
2062            }
2063        }
2064    }
2065    let total = lines.len();
2066    let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2067    f.render_widget(par, area);
2068    scrollbar(
2069        f,
2070        area,
2071        total,
2072        area.height.saturating_sub(2) as usize,
2073        rv.scroll as usize,
2074        p,
2075    );
2076}
2077
2078/// Gantt chart of a job run: one bar per task on a shared time axis,
2079/// colored by state, so the long pole is visible at a glance.
2080fn draw_run_timeline(
2081    f: &mut Frame,
2082    area: Rect,
2083    scroll: u16,
2084    data: &DetailData,
2085    block: Block,
2086    p: &Palette,
2087) {
2088    let tasks = crate::fetchers::runs::timeline(&data.raw);
2089    let now = std::time::SystemTime::now()
2090        .duration_since(std::time::UNIX_EPOCH)
2091        .map(|d| d.as_millis() as u64)
2092        .unwrap_or(0);
2093    let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
2094    let Some(t0) = t0 else {
2095        let par = Paragraph::new(
2096            "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
2097        )
2098        .style(Style::default().fg(p.dim))
2099        .wrap(Wrap { trim: false })
2100        .block(block);
2101        f.render_widget(par, area);
2102        return;
2103    };
2104    let t1 = tasks
2105        .iter()
2106        .filter(|t| t.start > 0)
2107        .map(|t| t.end.unwrap_or(now).max(t.start))
2108        .max()
2109        .unwrap_or(t0);
2110    let span = (t1 - t0).max(1);
2111
2112    let name_w = tasks
2113        .iter()
2114        .map(|t| t.key.chars().count())
2115        .max()
2116        .unwrap_or(4)
2117        .clamp(4, 24);
2118    let inner_w = area.width.saturating_sub(4) as usize;
2119    // Room for the widest duration ("59m 59s…" = 8) after the bar.
2120    let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
2121
2122    let mut lines: Vec<Line> = vec![
2123        Line::from(Span::styled(
2124            format!(
2125                "◷ {} task{} · span {}",
2126                tasks.len(),
2127                if tasks.len() == 1 { "" } else { "s" },
2128                crate::shape::fmt_duration_ms(span)
2129            ),
2130            Style::default().fg(p.dim),
2131        )),
2132        Line::default(),
2133    ];
2134    for t in &tasks {
2135        let mut key: String = t.key.chars().take(name_w).collect();
2136        if key.len() < t.key.len() {
2137            key.pop();
2138            key.push('…');
2139        }
2140        let name = Span::styled(format!("{key:<name_w$}  "), Style::default().fg(p.text));
2141        if t.start == 0 {
2142            lines.push(Line::from(vec![
2143                name,
2144                Span::styled(
2145                    format!("– {}", t.status.label().to_lowercase()),
2146                    Style::default().fg(p.dim),
2147                ),
2148            ]));
2149            continue;
2150        }
2151        let end = t.end.unwrap_or(now).max(t.start);
2152        let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
2153        let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
2154        let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
2155        let dur = format!(
2156            " {}{}",
2157            crate::shape::fmt_duration_ms(end - t.start),
2158            if t.end.is_none() { "…" } else { "" }
2159        );
2160        lines.push(Line::from(vec![
2161            name,
2162            Span::styled(" ".repeat(off), Style::default()),
2163            Span::styled(
2164                "█".repeat(len),
2165                Style::default().fg(status_color(&t.status, p)),
2166            ),
2167            Span::styled(dur, Style::default().fg(p.dim)),
2168        ]));
2169    }
2170    let total = lines.len();
2171    let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2172    f.render_widget(par, area);
2173    scrollbar(
2174        f,
2175        area,
2176        total,
2177        area.height.saturating_sub(2) as usize,
2178        scroll as usize,
2179        p,
2180    );
2181}
2182
2183/// Dependency tree of a job run's tasks: each task under the task it
2184/// waits for, colored by state, with extra dependencies annotated.
2185fn draw_run_dag(
2186    f: &mut Frame,
2187    area: Rect,
2188    scroll: u16,
2189    data: &DetailData,
2190    block: Block,
2191    p: &Palette,
2192) {
2193    let rows = crate::fetchers::runs::dag(&data.raw);
2194    if rows.is_empty() {
2195        let par = Paragraph::new(
2196            "no task graph recorded — legacy single-task runs carry none (J shows the raw JSON)",
2197        )
2198        .style(Style::default().fg(p.dim))
2199        .wrap(Wrap { trim: false })
2200        .block(block);
2201        f.render_widget(par, area);
2202        return;
2203    }
2204    let mut lines: Vec<Line> = vec![
2205        Line::from(Span::styled(
2206            format!(
2207                "⧉ {} task{} · order of execution, top to bottom",
2208                rows.len(),
2209                if rows.len() == 1 { "" } else { "s" },
2210            ),
2211            Style::default().fg(p.dim),
2212        )),
2213        Line::default(),
2214    ];
2215    for r in &rows {
2216        let mut spans = vec![
2217            Span::styled(r.prefix.clone(), Style::default().fg(p.dim)),
2218            Span::styled("● ", Style::default().fg(status_color(&r.status, p))),
2219            Span::styled(r.key.clone(), Style::default().fg(p.text)),
2220            Span::styled(
2221                format!("  {}", r.status.label().to_lowercase()),
2222                Style::default().fg(status_color(&r.status, p)),
2223            ),
2224        ];
2225        if let Some(d) = r.duration {
2226            spans.push(Span::styled(
2227                format!("  ·  {}", crate::shape::fmt_duration_ms(d)),
2228                Style::default().fg(p.dim),
2229            ));
2230        }
2231        if !r.also_after.is_empty() {
2232            spans.push(Span::styled(
2233                format!("  (also after {})", r.also_after.join(", ")),
2234                Style::default().fg(p.dim),
2235            ));
2236        }
2237        lines.push(Line::from(spans));
2238    }
2239    let total = lines.len();
2240    let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2241    f.render_widget(par, area);
2242    scrollbar(
2243        f,
2244        area,
2245        total,
2246        area.height.saturating_sub(2) as usize,
2247        scroll as usize,
2248        p,
2249    );
2250}
2251
2252fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2253    let block = Block::default()
2254        .borders(Borders::ALL)
2255        .border_type(BorderType::Rounded)
2256        .border_style(Style::default().fg(p.border));
2257    let inner = block.inner(area);
2258    f.render_widget(block, area);
2259
2260    // Brand block: a two-tone brick mark, then DATABRICKS in the theme's
2261    // gradient with a bright shimmer sweeping across it while data loads,
2262    // then LAKEHOUSE letter-spaced in the accent color.
2263    let (from, to) = (p.grad_from, p.grad_to);
2264    let grad = |t: f32| {
2265        Color::Rgb(
2266            (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
2267            (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
2268            (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
2269        )
2270    };
2271    let mut left = vec![
2272        Span::styled(
2273            " ◢",
2274            Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
2275        ),
2276        Span::styled(
2277            "◤ ",
2278            Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
2279        ),
2280    ];
2281    let word: Vec<char> = "DATABRICKS".chars().collect();
2282    let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
2283    for (i, ch) in word.iter().enumerate() {
2284        let t = i as f32 / (word.len() - 1) as f32;
2285        let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
2286            Style::default().fg(p.text).add_modifier(Modifier::BOLD)
2287        } else {
2288            Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
2289        };
2290        left.push(Span::styled(ch.to_string(), style));
2291    }
2292    left.push(Span::raw(" "));
2293    for ch in "LAKEHOUSE".chars() {
2294        left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
2295    }
2296    left.push(Span::styled(
2297        format!("· v{}", env!("CARGO_PKG_VERSION")),
2298        Style::default().fg(p.dim),
2299    ));
2300    if let Some(profile) = &app.profile {
2301        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
2302        left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
2303        left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
2304    }
2305    if let Some(Shape::Badge(b)) = &app.user_badge {
2306        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
2307        left.push(Span::styled(
2308            format!("{} {}", b.label, b.value),
2309            Style::default().fg(p.key),
2310        ));
2311    }
2312    if app.zoomed {
2313        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
2314        left.push(Span::styled(
2315            format!("⛶ {}", app.focus.title()),
2316            Style::default()
2317                .fg(accent(app.focus, p))
2318                .add_modifier(Modifier::BOLD),
2319        ));
2320    }
2321
2322    // Workspace health at a glance: counts of items per status bucket.
2323    let (ok, pending, failed, idle) = app.status_counts();
2324    for (count, glyph, color) in [
2325        (ok, "●", p.ok),
2326        (pending, "◐", p.warn),
2327        (failed, "✗", p.err),
2328        (idle, "○", p.dim),
2329    ] {
2330        if count > 0 {
2331            left.push(Span::styled("  ", Style::default()));
2332            left.push(Span::styled(
2333                format!("{glyph} {count}"),
2334                Style::default().fg(color),
2335            ));
2336        }
2337    }
2338    f.render_widget(Paragraph::new(Line::from(left)), inner);
2339
2340    let right = if app.loading {
2341        Line::from(Span::styled(
2342            format!("{} refreshing ", app.spinner()),
2343            Style::default().fg(p.warn),
2344        ))
2345    } else {
2346        Line::from(Span::styled(
2347            format!("updated {}s ago ", app.last_refresh_age().as_secs()),
2348            Style::default().fg(p.dim),
2349        ))
2350    };
2351    f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
2352}
2353
2354fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2355    let key =
2356        |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
2357    let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
2358
2359    if let Some(confirm) = &app.confirm {
2360        let line = Line::from(vec![
2361            Span::styled(
2362                format!(" ⚠ {} ", confirm.message),
2363                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2364            ),
2365            key("y"),
2366            dim(" confirm · any other key cancels"),
2367        ]);
2368        f.render_widget(Paragraph::new(line), area);
2369        return;
2370    }
2371
2372    if app.filter_entry {
2373        let line = Line::from(vec![
2374            Span::styled(
2375                format!(" /{}", app.active_filter()),
2376                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2377            ),
2378            Span::styled("▏", Style::default().fg(p.warn)),
2379            dim("  type to filter   "),
2380            key("enter"),
2381            dim(" apply   "),
2382            key("esc"),
2383            dim(" clear"),
2384        ]);
2385        f.render_widget(Paragraph::new(line), area);
2386        return;
2387    }
2388
2389    if let Some(pv) = &app.preview {
2390        if pv.filter_entry {
2391            let line = Line::from(vec![
2392                Span::styled(
2393                    format!(" cols /{}", pv.filter),
2394                    Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2395                ),
2396                Span::styled("▏", Style::default().fg(p.warn)),
2397                dim("  type to filter columns   "),
2398                key("enter"),
2399                dim(" apply   "),
2400                key("esc"),
2401                dim(" clear"),
2402            ]);
2403            f.render_widget(Paragraph::new(line), area);
2404            return;
2405        }
2406    }
2407
2408    let mut spans = if app.secret_form.is_some() {
2409        vec![
2410            dim(" type   "),
2411            key("enter"),
2412            dim(" next / save   "),
2413            key("esc"),
2414            dim(" cancel"),
2415        ]
2416    } else if app.help {
2417        vec![
2418            dim(" "),
2419            key("j"),
2420            dim("/"),
2421            key("k"),
2422            dim(" scroll   any other key closes"),
2423        ]
2424    } else if app.pane_cfg.is_some() {
2425        vec![
2426            dim(" "),
2427            key("j"),
2428            dim("/"),
2429            key("k"),
2430            dim(" select   "),
2431            key("space"),
2432            dim(" show/hide   "),
2433            key("J"),
2434            dim("/"),
2435            key("K"),
2436            dim(" move   "),
2437            key("esc"),
2438            dim(" done"),
2439        ]
2440    } else if app.jump.is_some() {
2441        vec![
2442            dim(" type to search everything   "),
2443            key("\u{2191}"),
2444            dim("/"),
2445            key("\u{2193}"),
2446            dim(" select   "),
2447            key("enter"),
2448            dim(" jump   "),
2449            key("esc"),
2450            dim(" close"),
2451        ]
2452    } else if app.problems.is_some() {
2453        vec![
2454            dim(" "),
2455            key("j"),
2456            dim("/"),
2457            key("k"),
2458            dim(" select   "),
2459            key("enter"),
2460            dim(" jump to item / switch workspace   "),
2461            key("esc"),
2462            dim(" close"),
2463        ]
2464    } else if app.upcoming.is_some() {
2465        vec![
2466            dim(" "),
2467            key("j"),
2468            dim("/"),
2469            key("k"),
2470            dim(" select   "),
2471            key("enter"),
2472            dim(" jump to job   "),
2473            key("esc"),
2474            dim(" close"),
2475        ]
2476    } else if app.cost.is_some() {
2477        vec![
2478            dim(" "),
2479            key("esc"),
2480            dim(" back   "),
2481            key("q"),
2482            dim(" quit"),
2483        ]
2484    } else if app.wh_picker.is_some() {
2485        vec![
2486            dim(" "),
2487            key("j"),
2488            dim("/"),
2489            key("k"),
2490            dim(" select   "),
2491            key("enter"),
2492            dim(" run preview   "),
2493            key("esc"),
2494            dim(" cancel"),
2495        ]
2496    } else if app.sql.is_some() {
2497        if app.hist_search.is_some() {
2498            vec![
2499                dim(" type to search history   "),
2500                key("^r"),
2501                dim(" older match   "),
2502                key("enter"),
2503                dim(" accept   "),
2504                key("esc"),
2505                dim(" cancel"),
2506            ]
2507        } else {
2508            vec![
2509                dim(" "),
2510                key("enter"),
2511                dim(" run   "),
2512                key("tab"),
2513                dim(" complete   "),
2514                key("↑"),
2515                dim("/"),
2516                key("↓"),
2517                dim(" history   "),
2518                key("^r"),
2519                dim(" search   "),
2520                key("^x"),
2521                dim(" editor   "),
2522                key("pgup"),
2523                dim("/"),
2524                key("pgdn"),
2525                dim(" scroll   "),
2526                key("⇧←→"),
2527                dim(" cols   "),
2528                key("^s"),
2529                dim(" export   "),
2530                key("esc"),
2531                dim(if app.sql.as_ref().is_some_and(|c| c.running) {
2532                    " cancel query"
2533                } else {
2534                    " close"
2535                }),
2536            ]
2537        }
2538    } else if let Some(pv) = &app.preview {
2539        if pv.record {
2540            vec![
2541                dim(" "),
2542                key("j"),
2543                dim("/"),
2544                key("k"),
2545                dim(" fields   "),
2546                key("h"),
2547                dim("/"),
2548                key("l"),
2549                dim(" prev/next row   "),
2550                key("v"),
2551                dim(" grid   "),
2552                key("/"),
2553                dim(" filter cols   "),
2554                key("esc"),
2555                dim(" back"),
2556            ]
2557        } else {
2558            vec![
2559                dim(" "),
2560                key("esc"),
2561                dim(" back   "),
2562                key("j"),
2563                dim("/"),
2564                key("k"),
2565                dim(" rows   "),
2566                key("←"),
2567                dim("/"),
2568                key("→"),
2569                dim(" columns   "),
2570                key("v"),
2571                dim(" row view   "),
2572                key("/"),
2573                dim(" filter cols   "),
2574                key("e"),
2575                dim(" export csv   "),
2576                key("q"),
2577                dim(" quit"),
2578            ]
2579        }
2580    } else if app.picker.is_some() {
2581        vec![
2582            dim(" "),
2583            key("j"),
2584            dim("/"),
2585            key("k"),
2586            dim(" select   "),
2587            key("enter"),
2588            dim(" switch workspace   "),
2589            key("esc"),
2590            dim(" cancel"),
2591        ]
2592    } else if let Some(rv) = &app.run_view {
2593        if rv.show_output {
2594            vec![
2595                dim(" "),
2596                key("esc"),
2597                dim(" back to run   "),
2598                key("j"),
2599                dim("/"),
2600                key("k"),
2601                dim(" scroll   "),
2602                key("q"),
2603                dim(" quit"),
2604            ]
2605        } else {
2606            let mut spans = vec![
2607                dim(" "),
2608                key("esc"),
2609                dim(" back   "),
2610                key("h"),
2611                dim("/"),
2612                key("l"),
2613                dim(" older/newer   "),
2614            ];
2615            if rv.panel == Panel::Jobs {
2616                spans.push(key("o"));
2617                spans.push(dim(" output   "));
2618                spans.push(key("t"));
2619                spans.push(dim(" timeline   "));
2620                spans.push(key("d"));
2621                spans.push(dim(" dag   "));
2622                spans.push(key("r"));
2623                spans.push(dim(" repair   "));
2624            }
2625            spans.extend([
2626                key("s"),
2627                dim(" cancel   "),
2628                key("j"),
2629                dim("/"),
2630                key("k"),
2631                dim(" scroll   "),
2632                key("J"),
2633                dim(" raw   "),
2634                key("q"),
2635                dim(" quit"),
2636            ]);
2637            spans
2638        }
2639    } else if app.detail.is_some() {
2640        let mut spans = vec![dim(" "), key("esc"), dim(" back   ")];
2641        match app.detail.as_ref() {
2642            Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
2643                spans.push(key("enter"));
2644                spans.push(dim(" latest run   "));
2645            }
2646            Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
2647                spans.push(key("enter"));
2648                spans.push(dim(" latest update   "));
2649            }
2650            _ => {}
2651        }
2652        spans.extend([
2653            key("j"),
2654            dim("/"),
2655            key("k"),
2656            dim(" scroll   "),
2657            key("J"),
2658            dim(" raw   "),
2659            key("o"),
2660            dim(" open   "),
2661            key("q"),
2662            dim(" quit"),
2663        ]);
2664        spans
2665    } else {
2666        let mut spans = vec![
2667            dim(" "),
2668            key("tab"),
2669            dim("/"),
2670            key("h"),
2671            dim("/"),
2672            key("l"),
2673            dim(" switch   "),
2674            key("j"),
2675            dim("/"),
2676            key("k"),
2677            dim(" select   "),
2678            key("/"),
2679            dim(if app.active_filter().is_empty() {
2680                " filter   "
2681            } else {
2682                " filter · esc clears   "
2683            }),
2684        ];
2685        // Enter means different things per pane — and nothing on a secret key.
2686        let enter_label = match app.focus {
2687            Panel::Secrets if app.secret_scope.is_some() => None,
2688            Panel::Secrets => Some(" open scope   "),
2689            Panel::Catalog if app.uc_path.len() < 2 => Some(" open   "),
2690            _ => Some(" details   "),
2691        };
2692        if let Some(label) = enter_label {
2693            spans.push(key("enter"));
2694            spans.push(dim(label));
2695        }
2696        // Only show keys that do something in the focused pane.
2697        match app.focus {
2698            Panel::Catalog => {
2699                if !app.uc_path.is_empty() {
2700                    spans.push(key("bksp"));
2701                    spans.push(dim(" up   "));
2702                }
2703                if app.uc_path.len() == 2 {
2704                    spans.push(key("p"));
2705                    spans.push(dim(" preview   "));
2706                    spans.push(key("L"));
2707                    spans.push(dim(" lineage   "));
2708                    spans.push(key(":"));
2709                    spans.push(dim(" query table   "));
2710                }
2711            }
2712            Panel::Dashboards => {}
2713            Panel::Secrets => {
2714                if app.secret_scope.is_some() {
2715                    spans.push(key("bksp"));
2716                    spans.push(dim(" up   "));
2717                }
2718                spans.push(key("a"));
2719                spans.push(dim(if app.secret_scope.is_some() {
2720                    " add secret   "
2721                } else {
2722                    " new scope   "
2723                }));
2724                spans.push(key("x"));
2725                spans.push(dim(" delete   "));
2726            }
2727            _ => {
2728                spans.push(key("s"));
2729                spans.push(dim(" action   "));
2730            }
2731        }
2732        spans.extend([
2733            key("g"),
2734            dim(" access   "),
2735            key("$"),
2736            dim(" cost   "),
2737            key("!"),
2738            dim(" problems   "),
2739            key("u"),
2740            dim(" upcoming   "),
2741        ]);
2742        // At the catalog's table level the ':' hint already reads "query table".
2743        if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
2744            spans.push(key(":"));
2745            spans.push(dim(" sql   "));
2746        }
2747        spans.extend([
2748            key("o"),
2749            dim(" open   "),
2750            key("z"),
2751            dim(if app.zoomed { " unzoom   " } else { " zoom   " }),
2752            key("w"),
2753            dim(" workspace   "),
2754            key("t"),
2755            dim(" theme   "),
2756            key("r"),
2757            dim(" refresh   "),
2758            key("?"),
2759            dim(" help   "),
2760            key("q"),
2761            dim(" quit"),
2762        ]);
2763        spans
2764    };
2765    // A flash message prefixes the key hints instead of hiding them.
2766    if let Some((msg, _)) = &app.flash {
2767        let color = if msg.starts_with('✗') { p.err } else { p.ok };
2768        let mut prefixed = vec![
2769            Span::styled(
2770                format!(" {msg}"),
2771                Style::default().fg(color).add_modifier(Modifier::BOLD),
2772            ),
2773            dim("  ·  "),
2774        ];
2775        prefixed.append(&mut spans);
2776        spans = prefixed;
2777    }
2778    f.render_widget(Paragraph::new(Line::from(spans)), area);
2779}
2780
2781#[allow(clippy::too_many_arguments)]
2782fn draw_panel(
2783    f: &mut Frame,
2784    area: Rect,
2785    panel: Panel,
2786    shape: Option<&Shape>,
2787    focused: bool,
2788    selected: Option<usize>,
2789    fresh: bool,
2790    breadcrumb: &str,
2791    spinner: &str,
2792    filter: &str,
2793    entering: bool,
2794    p: &Palette,
2795) {
2796    let accent = accent(panel, p);
2797    // Unfocused panes keep their status colors (the cross-pane signal)
2798    // but their text steps back so the active pane pops.
2799    let dimmed = |s: Style| {
2800        if focused {
2801            s
2802        } else {
2803            s.add_modifier(Modifier::DIM)
2804        }
2805    };
2806    let (border_style, title_style) = if focused {
2807        (
2808            Style::default().fg(accent).add_modifier(Modifier::BOLD),
2809            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2810        )
2811    } else {
2812        (Style::default().fg(p.border), Style::default().fg(p.dim))
2813    };
2814    let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
2815        Some(Shape::List(items)) => Some(
2816            items
2817                .iter()
2818                .filter(|it| crate::shape::item_matches(it, filter))
2819                .collect(),
2820        ),
2821        _ => None,
2822    };
2823    let count = match (&visible, shape) {
2824        // With a filter active, show how many made the cut.
2825        (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
2826            format!(" · {}/{}", v.len(), items.len())
2827        }
2828        (Some(v), _) => format!(" · {}", v.len()),
2829        (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
2830        _ => String::new(),
2831    };
2832    let crumb = if !breadcrumb.is_empty() {
2833        format!(" ▸ {breadcrumb}")
2834    } else {
2835        String::new()
2836    };
2837    // A short reversed flash on the title when fresh data lands.
2838    let title_style = if fresh {
2839        Style::default()
2840            .bg(accent)
2841            .fg(Color::Black)
2842            .add_modifier(Modifier::BOLD)
2843    } else {
2844        title_style
2845    };
2846    let mut title_spans = vec![
2847        Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
2848        Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
2849    ];
2850    if entering || !filter.is_empty() {
2851        title_spans.push(Span::styled(
2852            format!("/{}{} ", filter, if entering { "▏" } else { "" }),
2853            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2854        ));
2855    }
2856    let title = Line::from(title_spans);
2857    let block = Block::default()
2858        .title(title)
2859        .borders(Borders::ALL)
2860        .border_type(if focused {
2861            BorderType::Thick
2862        } else {
2863            BorderType::Rounded
2864        })
2865        .border_style(border_style)
2866        .padding(Padding::horizontal(1));
2867
2868    match shape {
2869        None => {
2870            let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
2871                .style(Style::default().fg(p.warn))
2872                .block(block);
2873            f.render_widget(par, area);
2874        }
2875        Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
2876            let empty = if !items.is_empty() {
2877                format!("∅ nothing matches /{filter}")
2878            } else {
2879                let msg = match panel {
2880                    Panel::Clusters => "no compute running",
2881                    Panel::Jobs => "no jobs defined",
2882                    Panel::Pipelines => "no pipelines",
2883                    Panel::Warehouses => "no warehouses",
2884                    Panel::Dashboards => "no dashboards yet",
2885                    Panel::Catalog => "nothing at this level",
2886                    Panel::Secrets => "no secret scopes",
2887                };
2888                format!("∅ {msg}")
2889            };
2890            let par = Paragraph::new(empty)
2891                .style(Style::default().fg(p.dim))
2892                .block(block);
2893            f.render_widget(par, area);
2894        }
2895        Some(Shape::List(_)) => {
2896            let list_items: Vec<ListItem> = visible
2897                .unwrap_or_default()
2898                .iter()
2899                .map(|item| {
2900                    let color = status_color(&item.status, p);
2901                    let chip = match item.status {
2902                        Status::Stopped | Status::Unknown(_) => Span::styled(
2903                            format!(" {} ", item.status.label()),
2904                            Style::default().fg(color).add_modifier(Modifier::DIM),
2905                        ),
2906                        _ => Span::styled(
2907                            format!(" {} ", item.status.label()),
2908                            Style::default()
2909                                .bg(color)
2910                                .fg(Color::Black)
2911                                .add_modifier(Modifier::BOLD),
2912                        ),
2913                    };
2914                    let mut spans = vec![
2915                        Span::styled("● ", Style::default().fg(color)),
2916                        Span::styled(item.name.as_str(), dimmed(Style::default().fg(p.text))),
2917                        Span::raw("  "),
2918                        chip,
2919                    ];
2920                    if !item.history.is_empty() {
2921                        spans.push(Span::raw("  "));
2922                        for run in &item.history {
2923                            spans.push(Span::styled(
2924                                history_glyph(run),
2925                                Style::default().fg(status_color(run, p)),
2926                            ));
2927                        }
2928                    }
2929                    if let Some(detail) = &item.detail {
2930                        spans.push(Span::styled(
2931                            format!("  {}", detail),
2932                            dimmed(Style::default().fg(p.dim)),
2933                        ));
2934                    }
2935                    ListItem::new(Line::from(spans))
2936                })
2937                .collect();
2938            let list = List::new(list_items)
2939                .block(block)
2940                .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
2941            match selected {
2942                Some(sel) => {
2943                    let mut state = ListState::default().with_selected(Some(sel));
2944                    f.render_stateful_widget(list, area, &mut state);
2945                }
2946                None => f.render_widget(list, area),
2947            }
2948        }
2949        Some(Shape::Table(data)) => {
2950            let header_cells: Vec<Cell> = data
2951                .headers
2952                .iter()
2953                .map(|h| {
2954                    Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
2955                })
2956                .collect();
2957            let header = Row::new(header_cells).style(Style::default().fg(accent));
2958            let rows: Vec<Row> = data
2959                .rows
2960                .iter()
2961                .map(|r| {
2962                    Row::new(
2963                        r.iter()
2964                            .map(|c| Cell::from(c.as_str()).style(dimmed(Style::default())))
2965                            .collect::<Vec<_>>(),
2966                    )
2967                })
2968                .collect();
2969            let widths: Vec<Constraint> = data
2970                .headers
2971                .iter()
2972                .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
2973                .collect();
2974            let table = Table::new(rows, widths).header(header).block(block);
2975            f.render_widget(table, area);
2976        }
2977        Some(Shape::Badge(b)) => {
2978            let text = format!("{}: {}", b.label, b.value);
2979            let par = Paragraph::new(text)
2980                .style(dimmed(Style::default().fg(p.text)))
2981                .block(block);
2982            f.render_widget(par, area);
2983        }
2984        Some(Shape::Text(t)) => {
2985            let color = if t.starts_with('✗') { p.err } else { p.text };
2986            let par = Paragraph::new(t.as_str())
2987                .style(dimmed(Style::default().fg(color)))
2988                .wrap(Wrap { trim: false })
2989                .block(block);
2990            f.render_widget(par, area);
2991        }
2992    }
2993}
2994
2995/// Vertical scrollbar on the right border of a bordered block when the
2996/// content overflows the viewport; `total` and `pos` are in rendered
2997/// lines (or rows).
2998fn scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, pos: usize, p: &Palette) {
2999    if viewport == 0 || total <= viewport {
3000        return;
3001    }
3002    let max = total - viewport;
3003    let mut state = ScrollbarState::new(max).position(pos.min(max));
3004    let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
3005        .begin_symbol(None)
3006        .end_symbol(None)
3007        .track_symbol(Some("│"))
3008        .track_style(Style::default().fg(p.border))
3009        .thumb_symbol("┃")
3010        .thumb_style(Style::default().fg(p.text));
3011    f.render_stateful_widget(
3012        sb,
3013        area.inner(Margin {
3014            horizontal: 0,
3015            vertical: 1,
3016        }),
3017        &mut state,
3018    );
3019}
3020
3021/// Rendered height of text wrapped at `width` columns.
3022fn wrapped_height(text: &str, width: usize) -> usize {
3023    let w = width.max(1);
3024    text.lines()
3025        .map(|l| l.chars().count().div_ceil(w).max(1))
3026        .sum()
3027}
3028
3029/// Words highlighted as keywords in the SQL prompt beyond the
3030/// autocomplete list.
3031const SQL_EXTRA_KEYWORDS: &[&str] = &[
3032    "TABLE", "VIEW", "IF", "EXISTS", "USE", "TRUE", "FALSE", "ASC", "DESC", "INTERVAL",
3033];
3034
3035fn is_sql_keyword(word: &str) -> bool {
3036    let up = word.to_ascii_uppercase();
3037    crate::app::SQL_KEYWORDS
3038        .iter()
3039        .any(|k| k.split_whitespace().any(|w| w == up))
3040        || SQL_EXTRA_KEYWORDS.contains(&up.as_str())
3041}
3042
3043/// SQL tokenized into styled spans for the console prompt: keywords,
3044/// strings, quoted identifiers, numbers and comments each get a color.
3045/// Char counts are preserved so the caret and hscroll math hold.
3046fn sql_spans(sql: &str, p: &Palette) -> Vec<Span<'static>> {
3047    let chars: Vec<char> = sql.chars().collect();
3048    let mut spans = Vec::new();
3049    let mut i = 0;
3050    let push = |spans: &mut Vec<Span<'static>>, text: String, style: Style| {
3051        if !text.is_empty() {
3052            spans.push(Span::styled(text, style));
3053        }
3054    };
3055    while i < chars.len() {
3056        let c = chars[i];
3057        if c == '\'' || c == '`' || c == '"' {
3058            // String or quoted identifier — runs to the closing quote,
3059            // or to the end while still being typed.
3060            let start = i;
3061            i += 1;
3062            while i < chars.len() && chars[i] != c {
3063                i += 1;
3064            }
3065            if i < chars.len() {
3066                i += 1;
3067            }
3068            let color = if c == '\'' { p.ok } else { p.catalog };
3069            push(
3070                &mut spans,
3071                chars[start..i].iter().collect(),
3072                Style::default().fg(color),
3073            );
3074        } else if c == '-' && chars.get(i + 1) == Some(&'-') {
3075            push(
3076                &mut spans,
3077                chars[i..].iter().collect(),
3078                Style::default().fg(p.dim),
3079            );
3080            break;
3081        } else if c.is_ascii_digit() {
3082            let start = i;
3083            while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
3084                i += 1;
3085            }
3086            push(
3087                &mut spans,
3088                chars[start..i].iter().collect(),
3089                Style::default().fg(p.warn),
3090            );
3091        } else if c.is_alphanumeric() || c == '_' {
3092            let start = i;
3093            while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
3094                i += 1;
3095            }
3096            let word: String = chars[start..i].iter().collect();
3097            let style = if is_sql_keyword(&word) {
3098                Style::default().fg(p.key).add_modifier(Modifier::BOLD)
3099            } else {
3100                Style::default().fg(p.text)
3101            };
3102            push(&mut spans, word, style);
3103        } else {
3104            let start = i;
3105            i += 1;
3106            while i < chars.len() {
3107                let c = chars[i];
3108                if c.is_alphanumeric() || matches!(c, '_' | '\'' | '`' | '"' | '-') {
3109                    break;
3110                }
3111                i += 1;
3112            }
3113            push(
3114                &mut spans,
3115                chars[start..i].iter().collect(),
3116                Style::default().fg(p.text),
3117            );
3118        }
3119    }
3120    spans
3121}
3122
3123/// One rendered line of task output: section headers stand out, error
3124/// and warning lines are tinted, leading timestamps are dimmed so the
3125/// message carries the color.
3126fn output_line<'a>(line: &'a str, p: &Palette) -> Line<'a> {
3127    if let Some(rest) = line.strip_prefix("── ") {
3128        let color = if rest.contains("FAILED") {
3129            p.err
3130        } else if rest.contains("RUNNING") || rest.contains("PENDING") {
3131            p.warn
3132        } else if rest.contains("SUCCESS") {
3133            p.ok
3134        } else {
3135            p.text
3136        };
3137        return Line::from(Span::styled(
3138            line,
3139            Style::default().fg(color).add_modifier(Modifier::BOLD),
3140        ));
3141    }
3142    let trimmed = line.trim_start();
3143    let indented = line.starts_with(char::is_whitespace);
3144    let body = if line.contains("ERROR")
3145        || line.contains("FATAL")
3146        || line.contains("Exception")
3147        || trimmed.starts_with("Traceback")
3148        || trimmed.starts_with("Caused by")
3149        || (indented && (trimmed.starts_with("at ") || trimmed.starts_with("File \"")))
3150    {
3151        Style::default().fg(p.err)
3152    } else if line.contains("WARN") {
3153        Style::default().fg(p.warn)
3154    } else if line == "logs (tail):" || trimmed.starts_with("(no output") {
3155        Style::default().fg(p.dim)
3156    } else {
3157        Style::default().fg(p.text)
3158    };
3159    let ts = timestamp_prefix(line);
3160    if ts > 0 {
3161        Line::from(vec![
3162            Span::styled(&line[..ts], Style::default().fg(p.dim)),
3163            Span::styled(&line[ts..], body),
3164        ])
3165    } else {
3166        Line::from(Span::styled(line, body))
3167    }
3168}
3169
3170/// Byte length of a leading timestamp-ish prefix ("26/07/18 12:34:56 ",
3171/// ISO dates), 0 when there is none. Requires enough digits that plain
3172/// leading numbers don't get dimmed.
3173fn timestamp_prefix(line: &str) -> usize {
3174    let mut len = 0;
3175    let mut digits = 0;
3176    for (i, c) in line.char_indices() {
3177        if c.is_ascii_digit() {
3178            digits += 1;
3179        } else if !matches!(c, '/' | '-' | ':' | '.' | ',' | 'T' | 'Z' | ' ') {
3180            break;
3181        }
3182        len = i + c.len_utf8();
3183        if len >= 30 {
3184            break;
3185        }
3186    }
3187    if digits >= 6 && len >= 8 {
3188        len
3189    } else {
3190        0
3191    }
3192}
3193
3194fn status_color(status: &Status, p: &Palette) -> Color {
3195    match status {
3196        Status::Running | Status::Success => p.ok,
3197        Status::Stopped => p.dim,
3198        Status::Pending => p.warn,
3199        Status::Failed => p.err,
3200        Status::Unknown(_) => p.text,
3201    }
3202}
3203
3204fn history_glyph(status: &Status) -> &'static str {
3205    match status {
3206        Status::Success => "✓",
3207        Status::Failed => "✗",
3208        Status::Running => "●",
3209        Status::Pending => "◐",
3210        Status::Stopped => "○",
3211        Status::Unknown(_) => "·",
3212    }
3213}
3214
3215#[cfg(test)]
3216mod tests {
3217    use super::*;
3218
3219    fn find<'a>(spans: &'a [Span], text: &str) -> &'a Span<'a> {
3220        spans
3221            .iter()
3222            .find(|s| s.content == text)
3223            .unwrap_or_else(|| panic!("no span {text:?} in {spans:?}"))
3224    }
3225
3226    #[test]
3227    fn sql_spans_color_keywords_strings_numbers_comments() {
3228        let p = palette(ThemeMode::Dark);
3229        let spans = sql_spans("SELECT count(x) FROM t WHERE s = 'bob' LIMIT 10 -- hi", &p);
3230        assert_eq!(find(&spans, "SELECT").style.fg, Some(p.key));
3231        assert!(find(&spans, "SELECT")
3232            .style
3233            .add_modifier
3234            .contains(Modifier::BOLD));
3235        assert_eq!(find(&spans, "count").style.fg, Some(p.key));
3236        assert_eq!(find(&spans, "t").style.fg, Some(p.text));
3237        assert_eq!(find(&spans, "'bob'").style.fg, Some(p.ok));
3238        assert_eq!(find(&spans, "10").style.fg, Some(p.warn));
3239        assert_eq!(find(&spans, "-- hi").style.fg, Some(p.dim));
3240    }
3241
3242    #[test]
3243    fn sql_spans_preserve_every_char() {
3244        let p = palette(ThemeMode::Dark);
3245        for sql in [
3246            "SELECT `col a`, b - 1 FROM main.sales.orders WHERE s = 'unclosed",
3247            "-- only a comment",
3248            "  \"quoted id\" <> 3.5e2 ",
3249        ] {
3250            let joined: String = sql_spans(sql, &p)
3251                .iter()
3252                .map(|s| s.content.as_ref())
3253                .collect();
3254            assert_eq!(joined, sql);
3255        }
3256    }
3257}