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