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", "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                spans.push(key("^v"));
3192                spans.push(dim(if record { " grid   " } else { " record   " }));
3193            }
3194            spans.extend([
3195                key("^s"),
3196                dim(" export   "),
3197                key("esc"),
3198                dim(if app.sql.as_ref().is_some_and(|c| c.running) {
3199                    " cancel query"
3200                } else if record {
3201                    " back to grid"
3202                } else {
3203                    " close"
3204                }),
3205            ]);
3206            spans
3207        }
3208    } else if let Some(pv) = &app.preview {
3209        if pv.record {
3210            vec![
3211                dim(" "),
3212                key("j"),
3213                dim("/"),
3214                key("k"),
3215                dim(" fields   "),
3216                key("h"),
3217                dim("/"),
3218                key("l"),
3219                dim(" prev/next row   "),
3220                key("v"),
3221                dim(" grid   "),
3222                key("/"),
3223                dim(" filter cols   "),
3224                key("esc"),
3225                dim(" back"),
3226            ]
3227        } else {
3228            vec![
3229                dim(" "),
3230                key("esc"),
3231                dim(" back   "),
3232                key("j"),
3233                dim("/"),
3234                key("k"),
3235                dim(" rows   "),
3236                key("←"),
3237                dim("/"),
3238                key("→"),
3239                dim(" columns   "),
3240                key("v"),
3241                dim(" row view   "),
3242                key("/"),
3243                dim(" filter cols   "),
3244                key("e"),
3245                dim(" export csv   "),
3246                key("q"),
3247                dim(" quit"),
3248            ]
3249        }
3250    } else if app.picker.is_some() {
3251        vec![
3252            dim(" "),
3253            key("j"),
3254            dim("/"),
3255            key("k"),
3256            dim(" select   "),
3257            key("enter"),
3258            dim(" switch workspace   "),
3259            key("esc"),
3260            dim(" cancel"),
3261        ]
3262    } else if let Some(rv) = &app.run_view {
3263        if rv.show_output {
3264            vec![
3265                dim(" "),
3266                key("esc"),
3267                dim(" back to run   "),
3268                key("j"),
3269                dim("/"),
3270                key("k"),
3271                dim(" scroll   "),
3272                key("q"),
3273                dim(" quit"),
3274            ]
3275        } else {
3276            let mut spans = vec![
3277                dim(" "),
3278                key("esc"),
3279                dim(" back   "),
3280                key("h"),
3281                dim("/"),
3282                key("l"),
3283                dim(" older/newer   "),
3284            ];
3285            if rv.panel == Panel::Jobs {
3286                spans.push(key("o"));
3287                spans.push(dim(" output   "));
3288                spans.push(key("t"));
3289                spans.push(dim(" timeline   "));
3290                spans.push(key("d"));
3291                spans.push(dim(" dag   "));
3292                spans.push(key("g"));
3293                spans.push(dim(" grid   "));
3294                spans.push(key("r"));
3295                spans.push(dim(" repair   "));
3296                spans.push(key("W"));
3297                spans.push(dim(
3298                    if app.watched.iter().any(|w| {
3299                        rv.runs
3300                            .get(rv.idx)
3301                            .is_some_and(|(id, _, _)| *id == w.run_id)
3302                    }) {
3303                        " unwatch   "
3304                    } else {
3305                        " watch   "
3306                    },
3307                ));
3308            }
3309            spans.extend([
3310                key("s"),
3311                dim(" cancel   "),
3312                key("j"),
3313                dim("/"),
3314                key("k"),
3315                dim(" scroll   "),
3316                key("J"),
3317                dim(" raw   "),
3318                key("q"),
3319                dim(" quit"),
3320            ]);
3321            spans
3322        }
3323    } else if app.detail.is_some() {
3324        let mut spans = vec![dim(" "), key("esc"), dim(" back   ")];
3325        match app.detail.as_ref() {
3326            Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
3327                spans.push(key("enter"));
3328                spans.push(dim(" latest run   "));
3329            }
3330            Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
3331                spans.push(key("enter"));
3332                spans.push(dim(" latest update   "));
3333            }
3334            _ => {}
3335        }
3336        spans.extend([
3337            key("j"),
3338            dim("/"),
3339            key("k"),
3340            dim(" scroll   "),
3341            key("J"),
3342            dim(" raw   "),
3343            key("o"),
3344            dim(" open   "),
3345            key("q"),
3346            dim(" quit"),
3347        ]);
3348        spans
3349    } else {
3350        let mut spans = vec![
3351            dim(" "),
3352            key("tab"),
3353            dim("/"),
3354            key("h"),
3355            dim("/"),
3356            key("l"),
3357            dim(" switch   "),
3358            key("j"),
3359            dim("/"),
3360            key("k"),
3361            dim(" select   "),
3362            key("/"),
3363            dim(if app.active_filter().is_empty() {
3364                " filter   "
3365            } else {
3366                " filter · esc clears   "
3367            }),
3368        ];
3369        // Enter means different things per pane — and nothing on a secret key.
3370        let enter_label = match app.focus {
3371            Panel::Secrets if app.secret_scope.is_some() => None,
3372            Panel::Secrets => Some(" open scope   "),
3373            Panel::Catalog if app.uc_path.len() < 2 => Some(" open   "),
3374            _ => Some(" details   "),
3375        };
3376        if let Some(label) = enter_label {
3377            spans.push(key("enter"));
3378            spans.push(dim(label));
3379        }
3380        // Only show keys that do something in the focused pane.
3381        match app.focus {
3382            Panel::Catalog => {
3383                if !app.uc_path.is_empty() {
3384                    spans.push(key("bksp"));
3385                    spans.push(dim(" up   "));
3386                }
3387                if app.uc_path.len() == 2 {
3388                    spans.push(key("p"));
3389                    spans.push(dim(" preview   "));
3390                    spans.push(key("L"));
3391                    spans.push(dim(" lineage   "));
3392                    spans.push(key(":"));
3393                    spans.push(dim(" query table   "));
3394                }
3395            }
3396            Panel::Dashboards => {}
3397            Panel::Secrets => {
3398                if app.secret_scope.is_some() {
3399                    spans.push(key("bksp"));
3400                    spans.push(dim(" up   "));
3401                }
3402                spans.push(key("a"));
3403                spans.push(dim(if app.secret_scope.is_some() {
3404                    " add secret   "
3405                } else {
3406                    " new scope   "
3407                }));
3408                spans.push(key("x"));
3409                spans.push(dim(" delete   "));
3410            }
3411            _ => {
3412                spans.push(key("s"));
3413                spans.push(dim(" action   "));
3414                if app.focus == Panel::Jobs {
3415                    spans.push(key("S"));
3416                    spans.push(dim(" pause   "));
3417                }
3418                if matches!(app.focus, Panel::Jobs | Panel::Pipelines) {
3419                    spans.push(key("c"));
3420                    spans.push(dim(" spend   "));
3421                }
3422            }
3423        }
3424        spans.extend([
3425            key("g"),
3426            dim(" access   "),
3427            key("$"),
3428            dim(" cost   "),
3429            key("!"),
3430            dim(" problems   "),
3431            key("u"),
3432            dim(" upcoming   "),
3433        ]);
3434        // At the catalog's table level the ':' hint already reads "query table".
3435        if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
3436            spans.push(key(":"));
3437            spans.push(dim(" sql   "));
3438        }
3439        spans.extend([
3440            key("o"),
3441            dim(" open   "),
3442            key("z"),
3443            dim(if app.zoomed { " unzoom   " } else { " zoom   " }),
3444            key("w"),
3445            dim(" workspace   "),
3446            key("t"),
3447            dim(" theme   "),
3448            key("r"),
3449            dim(" refresh   "),
3450            key("?"),
3451            dim(" help   "),
3452            key("q"),
3453            dim(" quit"),
3454        ]);
3455        spans
3456    };
3457    // A flash message prefixes the key hints instead of hiding them.
3458    if let Some((msg, _)) = &app.flash {
3459        let color = if msg.starts_with('✗') { p.err } else { p.ok };
3460        let mut prefixed = vec![
3461            Span::styled(
3462                format!(" {msg}"),
3463                Style::default().fg(color).add_modifier(Modifier::BOLD),
3464            ),
3465            dim("  ·  "),
3466        ];
3467        prefixed.append(&mut spans);
3468        spans = prefixed;
3469    }
3470    f.render_widget(Paragraph::new(Line::from(spans)), area);
3471}
3472
3473#[allow(clippy::too_many_arguments)]
3474fn draw_panel(
3475    f: &mut Frame,
3476    area: Rect,
3477    panel: Panel,
3478    shape: Option<&Shape>,
3479    focused: bool,
3480    selected: Option<usize>,
3481    fresh: bool,
3482    breadcrumb: &str,
3483    spinner: &str,
3484    filter: &str,
3485    entering: bool,
3486    favs: &std::collections::HashSet<String>,
3487    fav_only: bool,
3488    p: &Palette,
3489) {
3490    let accent = accent(panel, p);
3491    // Unfocused panes keep their status colors (the cross-pane signal)
3492    // but their text steps back so the active pane pops.
3493    let dimmed = |s: Style| {
3494        if focused {
3495            s
3496        } else {
3497            s.add_modifier(Modifier::DIM)
3498        }
3499    };
3500    let (border_style, title_style) = if focused {
3501        (
3502            Style::default().fg(accent).add_modifier(Modifier::BOLD),
3503            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
3504        )
3505    } else {
3506        (Style::default().fg(p.border), Style::default().fg(p.dim))
3507    };
3508    let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
3509        Some(Shape::List(items)) => Some(
3510            items
3511                .iter()
3512                .filter(|it| {
3513                    crate::shape::item_matches(it, filter)
3514                        && (!fav_only || favs.contains(&crate::shape::fav_key(it)))
3515                })
3516                .collect(),
3517        ),
3518        _ => None,
3519    };
3520    let count = match (&visible, shape) {
3521        // With a filter active, show how many made the cut.
3522        (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
3523            format!(" · {}/{}", v.len(), items.len())
3524        }
3525        (Some(v), _) => format!(" · {}", v.len()),
3526        (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
3527        _ => String::new(),
3528    };
3529    let crumb = if !breadcrumb.is_empty() {
3530        format!(" ▸ {breadcrumb}")
3531    } else {
3532        String::new()
3533    };
3534    // A short reversed flash on the title when fresh data lands.
3535    let title_style = if fresh {
3536        Style::default()
3537            .bg(accent)
3538            .fg(Color::Black)
3539            .add_modifier(Modifier::BOLD)
3540    } else {
3541        title_style
3542    };
3543    let mut title_spans = vec![
3544        Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
3545        Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
3546    ];
3547    if entering || !filter.is_empty() {
3548        title_spans.push(Span::styled(
3549            format!("/{}{} ", filter, if entering { "▏" } else { "" }),
3550            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3551        ));
3552    }
3553    if fav_only {
3554        title_spans.push(Span::styled(
3555            "★only ",
3556            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3557        ));
3558    }
3559    let title = Line::from(title_spans);
3560    let block = Block::default()
3561        .title(title)
3562        .borders(Borders::ALL)
3563        .border_type(if focused {
3564            BorderType::Thick
3565        } else {
3566            BorderType::Rounded
3567        })
3568        .border_style(border_style)
3569        .padding(Padding::horizontal(1));
3570
3571    match shape {
3572        None => {
3573            let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
3574                .style(Style::default().fg(p.warn))
3575                .block(block);
3576            f.render_widget(par, area);
3577        }
3578        Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
3579            let empty = if !items.is_empty() {
3580                format!("∅ nothing matches /{filter}")
3581            } else {
3582                let msg = match panel {
3583                    Panel::Clusters => "no compute running",
3584                    Panel::Jobs => "no jobs defined",
3585                    Panel::Pipelines => "no pipelines",
3586                    Panel::Warehouses => "no warehouses",
3587                    Panel::Dashboards => "no dashboards yet",
3588                    Panel::Catalog => "nothing at this level",
3589                    Panel::Secrets => "no secret scopes",
3590                };
3591                format!("∅ {msg}")
3592            };
3593            let par = Paragraph::new(empty)
3594                .style(Style::default().fg(p.dim))
3595                .block(block);
3596            f.render_widget(par, area);
3597        }
3598        Some(Shape::List(_)) => {
3599            let list_items: Vec<ListItem> = visible
3600                .unwrap_or_default()
3601                .iter()
3602                .map(|item| {
3603                    let color = status_color(&item.status, p);
3604                    let chip = match item.status {
3605                        Status::Stopped | Status::Unknown(_) => Span::styled(
3606                            format!(" {} ", item.status.label()),
3607                            Style::default().fg(color).add_modifier(Modifier::DIM),
3608                        ),
3609                        _ => Span::styled(
3610                            format!(" {} ", item.status.label()),
3611                            Style::default()
3612                                .bg(color)
3613                                .fg(Color::Black)
3614                                .add_modifier(Modifier::BOLD),
3615                        ),
3616                    };
3617                    let star = if favs.contains(&crate::shape::fav_key(item)) {
3618                        Span::styled("★ ", Style::default().fg(p.warn))
3619                    } else {
3620                        Span::raw("  ")
3621                    };
3622                    let mut spans = vec![
3623                        star,
3624                        Span::styled("● ", Style::default().fg(color)),
3625                        Span::styled(item.name.as_str(), dimmed(Style::default().fg(p.text))),
3626                        Span::raw("  "),
3627                        chip,
3628                    ];
3629                    if !item.history.is_empty() {
3630                        spans.push(Span::raw("  "));
3631                        for run in &item.history {
3632                            spans.push(Span::styled(
3633                                history_glyph(run),
3634                                Style::default().fg(status_color(run, p)),
3635                            ));
3636                        }
3637                    }
3638                    if let Some(detail) = &item.detail {
3639                        // A running-long warning turns the detail amber.
3640                        let fg = if item.alert.is_some() { p.warn } else { p.dim };
3641                        spans.push(Span::styled(
3642                            format!("  {}", detail),
3643                            dimmed(Style::default().fg(fg)),
3644                        ));
3645                    }
3646                    ListItem::new(Line::from(spans))
3647                })
3648                .collect();
3649            let list = List::new(list_items)
3650                .block(block)
3651                .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
3652            match selected {
3653                Some(sel) => {
3654                    let mut state = ListState::default().with_selected(Some(sel));
3655                    f.render_stateful_widget(list, area, &mut state);
3656                }
3657                None => f.render_widget(list, area),
3658            }
3659        }
3660        Some(Shape::Table(data)) => {
3661            let header_cells: Vec<Cell> = data
3662                .headers
3663                .iter()
3664                .map(|h| {
3665                    Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
3666                })
3667                .collect();
3668            let header = Row::new(header_cells).style(Style::default().fg(accent));
3669            let rows: Vec<Row> = data
3670                .rows
3671                .iter()
3672                .map(|r| {
3673                    Row::new(
3674                        r.iter()
3675                            .map(|c| Cell::from(c.as_str()).style(dimmed(Style::default())))
3676                            .collect::<Vec<_>>(),
3677                    )
3678                })
3679                .collect();
3680            let widths: Vec<Constraint> = data
3681                .headers
3682                .iter()
3683                .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
3684                .collect();
3685            let table = Table::new(rows, widths).header(header).block(block);
3686            f.render_widget(table, area);
3687        }
3688        Some(Shape::Badge(b)) => {
3689            let text = format!("{}: {}", b.label, b.value);
3690            let par = Paragraph::new(text)
3691                .style(dimmed(Style::default().fg(p.text)))
3692                .block(block);
3693            f.render_widget(par, area);
3694        }
3695        Some(Shape::Text(t)) => {
3696            let color = if t.starts_with('✗') { p.err } else { p.text };
3697            let par = Paragraph::new(t.as_str())
3698                .style(dimmed(Style::default().fg(color)))
3699                .wrap(Wrap { trim: false })
3700                .block(block);
3701            f.render_widget(par, area);
3702        }
3703    }
3704}
3705
3706/// Vertical scrollbar on the right border of a bordered block when the
3707/// content overflows the viewport; `total` and `pos` are in rendered
3708/// lines (or rows).
3709fn scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, pos: usize, p: &Palette) {
3710    if viewport == 0 || total <= viewport {
3711        return;
3712    }
3713    let max = total - viewport;
3714    let mut state = ScrollbarState::new(max).position(pos.min(max));
3715    let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
3716        .begin_symbol(None)
3717        .end_symbol(None)
3718        .track_symbol(Some("│"))
3719        .track_style(Style::default().fg(p.border))
3720        .thumb_symbol("┃")
3721        .thumb_style(Style::default().fg(p.text));
3722    f.render_stateful_widget(
3723        sb,
3724        area.inner(Margin {
3725            horizontal: 0,
3726            vertical: 1,
3727        }),
3728        &mut state,
3729    );
3730}
3731
3732/// Rendered height of text wrapped at `width` columns.
3733fn wrapped_height(text: &str, width: usize) -> usize {
3734    let w = width.max(1);
3735    text.lines()
3736        .map(|l| l.chars().count().div_ceil(w).max(1))
3737        .sum()
3738}
3739
3740/// Words highlighted as keywords in the SQL prompt beyond the
3741/// autocomplete list.
3742const SQL_EXTRA_KEYWORDS: &[&str] = &[
3743    "TABLE", "VIEW", "IF", "EXISTS", "USE", "TRUE", "FALSE", "ASC", "DESC", "INTERVAL",
3744];
3745
3746fn is_sql_keyword(word: &str) -> bool {
3747    let up = word.to_ascii_uppercase();
3748    crate::app::SQL_KEYWORDS
3749        .iter()
3750        .any(|k| k.split_whitespace().any(|w| w == up))
3751        || SQL_EXTRA_KEYWORDS.contains(&up.as_str())
3752}
3753
3754/// SQL tokenized into styled spans for the console prompt: keywords,
3755/// strings, quoted identifiers, numbers and comments each get a color.
3756/// Char counts are preserved so the caret and hscroll math hold.
3757fn sql_spans(sql: &str, p: &Palette) -> Vec<Span<'static>> {
3758    let chars: Vec<char> = sql.chars().collect();
3759    let mut spans = Vec::new();
3760    let mut i = 0;
3761    let push = |spans: &mut Vec<Span<'static>>, text: String, style: Style| {
3762        if !text.is_empty() {
3763            spans.push(Span::styled(text, style));
3764        }
3765    };
3766    while i < chars.len() {
3767        let c = chars[i];
3768        if c == '\'' || c == '`' || c == '"' {
3769            // String or quoted identifier — runs to the closing quote,
3770            // or to the end while still being typed.
3771            let start = i;
3772            i += 1;
3773            while i < chars.len() && chars[i] != c {
3774                i += 1;
3775            }
3776            if i < chars.len() {
3777                i += 1;
3778            }
3779            let color = if c == '\'' { p.ok } else { p.catalog };
3780            push(
3781                &mut spans,
3782                chars[start..i].iter().collect(),
3783                Style::default().fg(color),
3784            );
3785        } else if c == '-' && chars.get(i + 1) == Some(&'-') {
3786            push(
3787                &mut spans,
3788                chars[i..].iter().collect(),
3789                Style::default().fg(p.dim),
3790            );
3791            break;
3792        } else if c.is_ascii_digit() {
3793            let start = i;
3794            while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
3795                i += 1;
3796            }
3797            push(
3798                &mut spans,
3799                chars[start..i].iter().collect(),
3800                Style::default().fg(p.warn),
3801            );
3802        } else if c.is_alphanumeric() || c == '_' {
3803            let start = i;
3804            while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
3805                i += 1;
3806            }
3807            let word: String = chars[start..i].iter().collect();
3808            let style = if is_sql_keyword(&word) {
3809                Style::default().fg(p.key).add_modifier(Modifier::BOLD)
3810            } else {
3811                Style::default().fg(p.text)
3812            };
3813            push(&mut spans, word, style);
3814        } else {
3815            let start = i;
3816            i += 1;
3817            while i < chars.len() {
3818                let c = chars[i];
3819                if c.is_alphanumeric() || matches!(c, '_' | '\'' | '`' | '"' | '-') {
3820                    break;
3821                }
3822                i += 1;
3823            }
3824            push(
3825                &mut spans,
3826                chars[start..i].iter().collect(),
3827                Style::default().fg(p.text),
3828            );
3829        }
3830    }
3831    spans
3832}
3833
3834/// One rendered line of task output: section headers stand out, error
3835/// and warning lines are tinted, leading timestamps are dimmed so the
3836/// message carries the color.
3837fn output_line<'a>(line: &'a str, p: &Palette) -> Line<'a> {
3838    if let Some(rest) = line.strip_prefix("── ") {
3839        let color = if rest.contains("FAILED") {
3840            p.err
3841        } else if rest.contains("RUNNING") || rest.contains("PENDING") {
3842            p.warn
3843        } else if rest.contains("SUCCESS") {
3844            p.ok
3845        } else {
3846            p.text
3847        };
3848        return Line::from(Span::styled(
3849            line,
3850            Style::default().fg(color).add_modifier(Modifier::BOLD),
3851        ));
3852    }
3853    let trimmed = line.trim_start();
3854    let indented = line.starts_with(char::is_whitespace);
3855    let body = if line.contains("ERROR")
3856        || line.contains("FATAL")
3857        || line.contains("Exception")
3858        || trimmed.starts_with("Traceback")
3859        || trimmed.starts_with("Caused by")
3860        || (indented && (trimmed.starts_with("at ") || trimmed.starts_with("File \"")))
3861    {
3862        Style::default().fg(p.err)
3863    } else if line.contains("WARN") {
3864        Style::default().fg(p.warn)
3865    } else if line == "logs (tail):" || trimmed.starts_with("(no output") {
3866        Style::default().fg(p.dim)
3867    } else {
3868        Style::default().fg(p.text)
3869    };
3870    let ts = timestamp_prefix(line);
3871    if ts > 0 {
3872        Line::from(vec![
3873            Span::styled(&line[..ts], Style::default().fg(p.dim)),
3874            Span::styled(&line[ts..], body),
3875        ])
3876    } else {
3877        Line::from(Span::styled(line, body))
3878    }
3879}
3880
3881/// Byte length of a leading timestamp-ish prefix ("26/07/18 12:34:56 ",
3882/// ISO dates), 0 when there is none. Requires enough digits that plain
3883/// leading numbers don't get dimmed.
3884fn timestamp_prefix(line: &str) -> usize {
3885    let mut len = 0;
3886    let mut digits = 0;
3887    for (i, c) in line.char_indices() {
3888        if c.is_ascii_digit() {
3889            digits += 1;
3890        } else if !matches!(c, '/' | '-' | ':' | '.' | ',' | 'T' | 'Z' | ' ') {
3891            break;
3892        }
3893        len = i + c.len_utf8();
3894        if len >= 30 {
3895            break;
3896        }
3897    }
3898    if digits >= 6 && len >= 8 {
3899        len
3900    } else {
3901        0
3902    }
3903}
3904
3905fn status_color(status: &Status, p: &Palette) -> Color {
3906    match status {
3907        Status::Running | Status::Success => p.ok,
3908        Status::Stopped => p.dim,
3909        Status::Pending => p.warn,
3910        Status::Failed => p.err,
3911        Status::Unknown(_) => p.text,
3912    }
3913}
3914
3915fn history_glyph(status: &Status) -> &'static str {
3916    match status {
3917        Status::Success => "✓",
3918        Status::Failed => "✗",
3919        Status::Running => "●",
3920        Status::Pending => "◐",
3921        Status::Stopped => "○",
3922        Status::Unknown(_) => "·",
3923    }
3924}
3925
3926#[cfg(test)]
3927mod tests {
3928    use super::*;
3929
3930    fn find<'a>(spans: &'a [Span], text: &str) -> &'a Span<'a> {
3931        spans
3932            .iter()
3933            .find(|s| s.content == text)
3934            .unwrap_or_else(|| panic!("no span {text:?} in {spans:?}"))
3935    }
3936
3937    #[test]
3938    fn sql_spans_color_keywords_strings_numbers_comments() {
3939        let p = palette(ThemeMode::Dark);
3940        let spans = sql_spans("SELECT count(x) FROM t WHERE s = 'bob' LIMIT 10 -- hi", &p);
3941        assert_eq!(find(&spans, "SELECT").style.fg, Some(p.key));
3942        assert!(find(&spans, "SELECT")
3943            .style
3944            .add_modifier
3945            .contains(Modifier::BOLD));
3946        assert_eq!(find(&spans, "count").style.fg, Some(p.key));
3947        assert_eq!(find(&spans, "t").style.fg, Some(p.text));
3948        assert_eq!(find(&spans, "'bob'").style.fg, Some(p.ok));
3949        assert_eq!(find(&spans, "10").style.fg, Some(p.warn));
3950        assert_eq!(find(&spans, "-- hi").style.fg, Some(p.dim));
3951    }
3952
3953    #[test]
3954    fn sql_spans_preserve_every_char() {
3955        let p = palette(ThemeMode::Dark);
3956        for sql in [
3957            "SELECT `col a`, b - 1 FROM main.sales.orders WHERE s = 'unclosed",
3958            "-- only a comment",
3959            "  \"quoted id\" <> 3.5e2 ",
3960        ] {
3961            let joined: String = sql_spans(sql, &p)
3962                .iter()
3963                .map(|s| s.content.as_ref())
3964                .collect();
3965            assert_eq!(joined, sql);
3966        }
3967    }
3968
3969    #[test]
3970    fn spark_scales_min_to_max() {
3971        assert_eq!(spark(&[1, 1, 1]), "▄▄▄");
3972        assert_eq!(spark(&[0, 7]), "▁█");
3973        assert_eq!(spark(&[100, 150, 200]), "▁▄█");
3974        assert_eq!(spark(&[]), "");
3975    }
3976
3977    /// Renders one frame and returns it as plain text lines. Wide enough
3978    /// that assertions are about content, not the footer clipping.
3979    fn render_at(app: &App, width: u16) -> Vec<String> {
3980        let backend = ratatui::backend::TestBackend::new(width, 30);
3981        let mut terminal = ratatui::Terminal::new(backend).unwrap();
3982        terminal.draw(|f| draw(f, app)).unwrap();
3983        let buffer = terminal.backend().buffer().clone();
3984        (0..buffer.area.height)
3985            .map(|y| {
3986                (0..buffer.area.width)
3987                    .map(|x| buffer[(x, y)].symbol())
3988                    .collect::<String>()
3989                    .trim_end()
3990                    .to_string()
3991            })
3992            .collect()
3993    }
3994
3995    fn render(app: &App) -> Vec<String> {
3996        render_at(app, 100)
3997    }
3998
3999    fn item_cost_app(data: fetchers::cost::ResourceCost) -> App {
4000        let mut app = App::new(60, ThemeMode::Dark);
4001        app.dismiss_splash();
4002        app.item_cost = Some(crate::app::ItemCostView {
4003            warehouse: "Starter Warehouse".to_string(),
4004            kind: data.kind,
4005            name: "nightly-etl".to_string(),
4006            id: "42".to_string(),
4007            data: Some(Ok(data)),
4008        });
4009        app
4010    }
4011
4012    fn window(
4013        label: &'static str,
4014        days: i64,
4015        dbus: f64,
4016        usd: f64,
4017        prior: Option<(f64, f64)>,
4018    ) -> fetchers::cost::CostWindow {
4019        fetchers::cost::CostWindow {
4020            label,
4021            days,
4022            dbus,
4023            usd,
4024            prior,
4025        }
4026    }
4027
4028    #[test]
4029    fn item_cost_view_shows_every_window_with_its_trend() {
4030        let data = fetchers::cost::ResourceCost {
4031            kind: fetchers::cost::ResourceKind::Job,
4032            windows: vec![
4033                window("last week", 7, 12.4, 9.61, Some((10.0, 8.14))),
4034                window("last month", 30, 58.2, 45.1, Some((60.0, 47.0))),
4035                window("last quarter", 90, 140.9, 109.22, Some((0.0, 0.0))),
4036                window("last year", 365, 402.3, 311.8, None),
4037            ],
4038            months: vec![
4039                ("2026-06".to_string(), 40.1, 31.02),
4040                ("2026-07".to_string(), 58.2, 45.1),
4041            ],
4042            priced: true,
4043            scoped: true,
4044        };
4045        let text = render(&item_cost_app(data)).join("\n");
4046
4047        assert!(
4048            text.contains("Spend · nightly-etl · job · this workspace"),
4049            "{text}"
4050        );
4051        for label in ["last week", "last month", "last quarter", "last year"] {
4052            assert!(text.contains(label), "missing {label} in {text}");
4053        }
4054        assert!(text.contains("$9.61"), "{text}");
4055        // Rising against the prior window, falling, no prior usage, no
4056        // prior window at all.
4057        assert!(text.contains("▲ 18%"), "{text}");
4058        assert!(text.contains("▼ 4%"), "{text}");
4059        assert!(text.contains("BY MONTH"), "{text}");
4060        assert!(text.contains("2026-06"), "{text}");
4061    }
4062
4063    fn sql_app(record: bool) -> App {
4064        let mut app = App::new(60, ThemeMode::Dark);
4065        app.dismiss_splash();
4066        app.sql = Some(crate::app::SqlConsole {
4067            input: "SELECT * FROM main.sales.orders LIMIT 50".to_string(),
4068            cursor: 0,
4069            warehouse: "Starter Warehouse".to_string(),
4070            running: false,
4071            data: Some(Ok(TableData {
4072                headers: vec![
4073                    "order_id".to_string(),
4074                    "customer".to_string(),
4075                    "shipped_at".to_string(),
4076                ],
4077                rows: vec![
4078                    vec!["1001".to_string(), "acme".to_string(), "␀".to_string()],
4079                    vec![
4080                        "1002".to_string(),
4081                        "globex".to_string(),
4082                        "2026-08-01".to_string(),
4083                    ],
4084                ],
4085            })),
4086            last_sql: "SELECT * FROM main.sales.orders LIMIT 50".to_string(),
4087            scroll: 1,
4088            col: 0,
4089            record,
4090            rscroll: 0,
4091        });
4092        app
4093    }
4094
4095    #[test]
4096    fn sql_record_view_stacks_one_row_and_marks_which() {
4097        let text = render_at(&sql_app(true), 140).join("\n");
4098
4099        // The selected row, one field per line — not the grid header.
4100        assert!(text.contains("row 2/2"), "{text}");
4101        assert!(text.contains("order_id    1002"), "{text}");
4102        assert!(text.contains("customer    globex"), "{text}");
4103        assert!(text.contains("shipped_at  2026-08-01"), "{text}");
4104        // The footer relabels the keys it shares with the grid.
4105        assert!(text.contains("fields"), "{text}");
4106        assert!(text.contains("^v grid"), "{text}");
4107    }
4108
4109    #[test]
4110    fn sql_grid_view_is_unchanged_and_offers_the_record_toggle() {
4111        let text = render_at(&sql_app(false), 140).join("\n");
4112        // Grid keeps its header row and column labels.
4113        assert!(text.contains("order_id"), "{text}");
4114        assert!(text.contains("customer"), "{text}");
4115        assert!(!text.contains("row 2/2"), "{text}");
4116        assert!(text.contains("^v record"), "{text}");
4117    }
4118
4119    #[test]
4120    fn item_cost_view_explains_an_unattributed_job() {
4121        let data = fetchers::cost::ResourceCost {
4122            kind: fetchers::cost::ResourceKind::Job,
4123            windows: Vec::new(),
4124            months: Vec::new(),
4125            priced: true,
4126            scoped: true,
4127        };
4128        let text = render(&item_cost_app(data)).join("\n");
4129        assert!(text.contains("no usage attributed to this job"), "{text}");
4130        assert!(text.contains("all-purpose compute"), "{text}");
4131    }
4132}