Skip to main content

databricks_tui/
ui.rs

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