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