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