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