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