Skip to main content

databricks_tui/
ui.rs

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