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