Skip to main content

databricks_tui/
ui.rs

1use crate::app::{App, Panel, ThemeMode};
2use crate::shape::{Shape, Status};
3use ratatui::{
4    layout::{Alignment, Constraint, Direction, Layout, Rect},
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{
8        Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph,
9        Row, Table, Wrap,
10    },
11    Frame,
12};
13use std::time::Duration;
14
15struct Palette {
16    text: Color,
17    dim: Color,
18    border: Color,
19    warn: Color,
20    ok: Color,
21    err: Color,
22    key: Color,
23    brand: Color,
24    clusters: Color,
25    jobs: Color,
26    pipelines: Color,
27    warehouses: Color,
28    catalog: Color,
29    /// Header wordmark gradient endpoints.
30    grad_from: (u8, u8, u8),
31    grad_to: (u8, u8, u8),
32}
33
34const fn rgb(hex: u32) -> Color {
35    Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
36}
37
38const fn rgb3(hex: u32) -> (u8, u8, u8) {
39    ((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
40}
41
42fn palette(mode: ThemeMode) -> Palette {
43    match mode {
44        // Dark theme sticks to ANSI colors so it follows the terminal's own scheme.
45        ThemeMode::Dark => Palette {
46            text: Color::White,
47            dim: Color::DarkGray,
48            border: Color::DarkGray,
49            warn: Color::Yellow,
50            ok: Color::Green,
51            err: Color::Red,
52            key: Color::Cyan,
53            brand: Color::Red,
54            clusters: Color::Cyan,
55            jobs: Color::Magenta,
56            pipelines: Color::Green,
57            warehouses: Color::Blue,
58            catalog: rgb(0xFF8C42),
59            grad_from: rgb3(0xFF3621),
60            grad_to: rgb3(0xFFA046),
61        },
62        // Light theme uses explicit darker shades that stay readable on a white background.
63        ThemeMode::Light => Palette {
64            text: Color::Black,
65            dim: rgb(0x6B7280),
66            border: rgb(0x9CA3AF),
67            warn: rgb(0xB45309),
68            ok: rgb(0x15803D),
69            err: rgb(0xB91C1C),
70            key: rgb(0x0891B2),
71            brand: rgb(0xDC2626),
72            clusters: rgb(0x0891B2),
73            jobs: rgb(0xA21CAF),
74            pipelines: rgb(0x15803D),
75            warehouses: rgb(0x1D4ED8),
76            catalog: rgb(0xC2410C),
77            grad_from: rgb3(0xB91C1C),
78            grad_to: rgb3(0xC2410C),
79        },
80        ThemeMode::CatppuccinMocha => Palette {
81            text: rgb(0xCDD6F4),
82            dim: rgb(0x6C7086),
83            border: rgb(0x585B70),
84            warn: rgb(0xF9E2AF),
85            ok: rgb(0xA6E3A1),
86            err: rgb(0xF38BA8),
87            key: rgb(0x89DCEB),
88            brand: rgb(0xF38BA8),
89            clusters: rgb(0x89DCEB),
90            jobs: rgb(0xCBA6F7),
91            pipelines: rgb(0xA6E3A1),
92            warehouses: rgb(0x89B4FA),
93            catalog: rgb(0xFAB387),
94            grad_from: rgb3(0xF38BA8),
95            grad_to: rgb3(0xFAB387),
96        },
97        ThemeMode::CatppuccinLatte => Palette {
98            text: rgb(0x4C4F69),
99            dim: rgb(0x8C8FA1),
100            border: rgb(0xACB0BE),
101            warn: rgb(0xDF8E1D),
102            ok: rgb(0x40A02B),
103            err: rgb(0xD20F39),
104            key: rgb(0x04A5E5),
105            brand: rgb(0xD20F39),
106            clusters: rgb(0x04A5E5),
107            jobs: rgb(0x8839EF),
108            pipelines: rgb(0x40A02B),
109            warehouses: rgb(0x1E66F5),
110            catalog: rgb(0xFE640B),
111            grad_from: rgb3(0xD20F39),
112            grad_to: rgb3(0xFE640B),
113        },
114        ThemeMode::GruvboxDark => Palette {
115            text: rgb(0xEBDBB2),
116            dim: rgb(0x928374),
117            border: rgb(0x665C54),
118            warn: rgb(0xFABD2F),
119            ok: rgb(0xB8BB26),
120            err: rgb(0xFB4934),
121            key: rgb(0x8EC07C),
122            brand: rgb(0xFB4934),
123            clusters: rgb(0x8EC07C),
124            jobs: rgb(0xD3869B),
125            pipelines: rgb(0xB8BB26),
126            warehouses: rgb(0x83A598),
127            catalog: rgb(0xFE8019),
128            grad_from: rgb3(0xFB4934),
129            grad_to: rgb3(0xFE8019),
130        },
131        ThemeMode::Dracula => Palette {
132            text: rgb(0xF8F8F2),
133            dim: rgb(0x6272A4),
134            border: rgb(0x44475A),
135            warn: rgb(0xF1FA8C),
136            ok: rgb(0x50FA7B),
137            err: rgb(0xFF5555),
138            key: rgb(0x8BE9FD),
139            brand: rgb(0xFF5555),
140            clusters: rgb(0x8BE9FD),
141            jobs: rgb(0xFF79C6),
142            pipelines: rgb(0x50FA7B),
143            warehouses: rgb(0xBD93F9),
144            catalog: rgb(0xFFB86C),
145            grad_from: rgb3(0xFF5555),
146            grad_to: rgb3(0xFFB86C),
147        },
148        ThemeMode::Nord => Palette {
149            text: rgb(0xD8DEE9),
150            dim: rgb(0x4C566A),
151            border: rgb(0x434C5E),
152            warn: rgb(0xEBCB8B),
153            ok: rgb(0xA3BE8C),
154            err: rgb(0xBF616A),
155            key: rgb(0x88C0D0),
156            brand: rgb(0xBF616A),
157            clusters: rgb(0x88C0D0),
158            jobs: rgb(0xB48EAD),
159            pipelines: rgb(0xA3BE8C),
160            warehouses: rgb(0x81A1C1),
161            catalog: rgb(0xD08770),
162            grad_from: rgb3(0xBF616A),
163            grad_to: rgb3(0xD08770),
164        },
165        ThemeMode::TokyoNight => Palette {
166            text: rgb(0xC0CAF5),
167            dim: rgb(0x565F89),
168            border: rgb(0x3B4261),
169            warn: rgb(0xE0AF68),
170            ok: rgb(0x9ECE6A),
171            err: rgb(0xF7768E),
172            key: rgb(0x7DCFFF),
173            brand: rgb(0xF7768E),
174            clusters: rgb(0x7DCFFF),
175            jobs: rgb(0xBB9AF7),
176            pipelines: rgb(0x9ECE6A),
177            warehouses: rgb(0x7AA2F7),
178            catalog: rgb(0xFF9E64),
179            grad_from: rgb3(0xF7768E),
180            grad_to: rgb3(0xFF9E64),
181        },
182    }
183}
184
185fn accent(panel: Panel, p: &Palette) -> Color {
186    match panel {
187        Panel::Clusters => p.clusters,
188        Panel::Jobs => p.jobs,
189        Panel::Pipelines => p.pipelines,
190        Panel::Warehouses => p.warehouses,
191        Panel::Dashboards => p.key,
192        Panel::Catalog => p.catalog,
193    }
194}
195
196pub fn draw(f: &mut Frame, app: &App) {
197    let p = palette(app.theme);
198
199    if app.splash_active() {
200        draw_splash(f, f.area(), app, &p);
201        return;
202    }
203
204    let root = Layout::default()
205        .direction(Direction::Vertical)
206        .constraints([
207            Constraint::Length(3),
208            Constraint::Min(0),
209            Constraint::Length(1),
210        ])
211        .split(f.area());
212
213    draw_header(f, root[0], app, &p);
214    draw_footer(f, root[2], app, &p);
215
216    if app.sql.is_some() {
217        draw_sql(f, root[1], app, &p);
218        // Running a query may pop the warehouse picker over the console.
219        if app.wh_picker.is_some() {
220            draw_wh_picker(f, root[1], app, &p);
221        }
222        return;
223    }
224
225    if app.cost.is_some() {
226        draw_cost(f, root[1], app, &p);
227        return;
228    }
229
230    if app.preview.is_some() {
231        draw_preview(f, root[1], app, &p);
232        return;
233    }
234
235    if app.run_view.is_some() {
236        draw_run(f, root[1], app, &p);
237        return;
238    }
239
240    if app.detail.is_some() {
241        draw_detail(f, root[1], app, &p);
242        return;
243    }
244
245    if app.zoomed {
246        let idx = Panel::ALL
247            .iter()
248            .position(|pn| pn == &app.focus)
249            .unwrap_or(0);
250        draw_panel(
251            f,
252            root[1],
253            app.focus,
254            app.shapes[idx].as_ref(),
255            true,
256            Some(app.selection(idx)),
257            false,
258            &app.uc_path.join("."),
259            app.spinner(),
260            &app.filters[idx],
261            app.filter_entry,
262            &p,
263        );
264        // Overlays must still render on top of the zoomed pane.
265        if app.picker.is_some() {
266            draw_picker(f, root[1], app, &p);
267        }
268        if app.wh_picker.is_some() {
269            draw_wh_picker(f, root[1], app, &p);
270        }
271        if app.problems.is_some() {
272            draw_problems(f, root[1], app, &p);
273        }
274        if app.jump.is_some() {
275            draw_jump(f, root[1], app, &p);
276        }
277        return;
278    }
279
280    let body = Layout::default()
281        .direction(Direction::Horizontal)
282        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
283        .split(root[1]);
284
285    let rows = [
286        Constraint::Percentage(34),
287        Constraint::Percentage(33),
288        Constraint::Percentage(33),
289    ];
290    let left = Layout::default()
291        .direction(Direction::Vertical)
292        .constraints(rows)
293        .split(body[0]);
294
295    let right = Layout::default()
296        .direction(Direction::Vertical)
297        .constraints(rows)
298        .split(body[1]);
299
300    let areas = [left[0], left[1], right[0], right[1], right[2], left[2]];
301
302    for (i, panel) in Panel::ALL.iter().enumerate() {
303        let focused = app.focus == *panel;
304        let shape = app.shapes[i].as_ref();
305        let selected = focused.then(|| app.selection(i));
306        let fresh = app.updated_at[i]
307            .map(|t| t.elapsed() < Duration::from_millis(1200))
308            .unwrap_or(false);
309        draw_panel(
310            f,
311            areas[i],
312            *panel,
313            shape,
314            focused,
315            selected,
316            fresh,
317            &app.uc_path.join("."),
318            app.spinner(),
319            &app.filters[i],
320            app.filter_entry && focused,
321            &p,
322        );
323    }
324
325    if app.picker.is_some() {
326        draw_picker(f, root[1], app, &p);
327    }
328    if app.wh_picker.is_some() {
329        draw_wh_picker(f, root[1], app, &p);
330    }
331    if app.problems.is_some() {
332        draw_problems(f, root[1], app, &p);
333    }
334    if app.jump.is_some() {
335        draw_jump(f, root[1], app, &p);
336    }
337}
338
339fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
340    let Some(jump) = &app.jump else {
341        return;
342    };
343    let matches = app.jump_matches();
344    let width = 70.min(area.width.saturating_sub(4));
345    let height = (matches.len() as u16 + 5).min(area.height);
346    let popup = Rect {
347        x: area.x + (area.width.saturating_sub(width)) / 2,
348        y: area.y + 2.min(area.height.saturating_sub(height)),
349        width,
350        height,
351    };
352    f.render_widget(Clear, popup);
353    let block = Block::default()
354        .title(Line::from(vec![
355            Span::styled(" ⌕ ", Style::default().fg(p.key)),
356            Span::styled(
357                "Jump to ",
358                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
359            ),
360        ]))
361        .borders(Borders::ALL)
362        .border_type(BorderType::Thick)
363        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
364        .padding(Padding::horizontal(1));
365    let inner = block.inner(popup);
366    f.render_widget(block, popup);
367
368    let parts = Layout::default()
369        .direction(Direction::Vertical)
370        .constraints([Constraint::Length(2), Constraint::Min(0)])
371        .split(inner);
372    f.render_widget(
373        Paragraph::new(Line::from(vec![
374            Span::styled("❯ ", Style::default().fg(p.key)),
375            Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
376            Span::styled("▏", Style::default().fg(p.warn)),
377        ])),
378        parts[0],
379    );
380
381    if matches.is_empty() {
382        f.render_widget(
383            Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
384            parts[1],
385        );
386        return;
387    }
388    let items: Vec<ListItem> = matches
389        .iter()
390        .map(|(panel_idx, name, label)| {
391            let panel = Panel::ALL[*panel_idx];
392            ListItem::new(Line::from(vec![
393                Span::styled(
394                    format!("{} ", panel.icon()),
395                    Style::default().fg(accent(panel, p)),
396                ),
397                Span::styled(format!("{name}  "), Style::default().fg(p.text)),
398                Span::styled(label.clone(), Style::default().fg(p.dim)),
399            ]))
400        })
401        .collect();
402    let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
403    let mut state = ListState::default().with_selected(Some(jump.index));
404    f.render_stateful_widget(list, parts[1], &mut state);
405}
406
407fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
408    let Some(pr) = &app.problems else {
409        return;
410    };
411    let truncate = |s: &str, max: usize| -> String {
412        if s.chars().count() <= max {
413            s.to_string()
414        } else {
415            let cut: String = s.chars().take(max.saturating_sub(1)).collect();
416            format!("{cut}…")
417        }
418    };
419    // Names get a bounded column; notes (which can be whole CLI error
420    // lines) take whatever room is left and are truncated to fit.
421    let name_w = pr
422        .items
423        .iter()
424        .map(|i| i.name.chars().count())
425        .max()
426        .unwrap_or(10)
427        .clamp(10, 40);
428    let note_w = pr
429        .items
430        .iter()
431        .map(|i| i.note.chars().count())
432        .max()
433        .unwrap_or(0);
434    // dot(2) + name + gap(2) + icon+title(20) + note + padding/borders(8)
435    let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
436    let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
437    // Room left for the note on each row inside borders and padding.
438    let note_space = (width as usize).saturating_sub(name_w + 30);
439    let popup = Rect {
440        x: area.x + (area.width.saturating_sub(width)) / 2,
441        y: area.y + (area.height.saturating_sub(height)) / 2,
442        width,
443        height,
444    };
445    f.render_widget(Clear, popup);
446    let block = Block::default()
447        .title(Line::from(vec![
448            Span::styled(" ✗ ", Style::default().fg(p.err)),
449            Span::styled(
450                format!("Problems · {} ", pr.items.len()),
451                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
452            ),
453        ]))
454        .borders(Borders::ALL)
455        .border_type(BorderType::Rounded)
456        .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
457        .padding(Padding::new(1, 1, 1, 1));
458
459    if pr.items.is_empty() {
460        let par = Paragraph::new("✓ all clear — nothing failing right now")
461            .style(Style::default().fg(p.ok))
462            .block(block);
463        f.render_widget(par, popup);
464        return;
465    }
466
467    let items: Vec<ListItem> = pr
468        .items
469        .iter()
470        .map(|problem| {
471            let panel = Panel::ALL[problem.panel];
472            ListItem::new(Line::from(vec![
473                Span::styled("✗ ", Style::default().fg(p.err)),
474                Span::styled(
475                    format!(
476                        "{:<width$}",
477                        truncate(&problem.name, name_w),
478                        width = name_w + 2
479                    ),
480                    Style::default().fg(p.text),
481                ),
482                Span::styled(
483                    format!("{} {:<18}", panel.icon(), panel.title()),
484                    Style::default().fg(accent(panel, p)),
485                ),
486                Span::styled(
487                    truncate(&problem.note, note_space),
488                    Style::default().fg(p.dim),
489                ),
490            ]))
491        })
492        .collect();
493    let list = List::new(items)
494        .block(block)
495        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
496    let mut state = ListState::default().with_selected(Some(pr.index));
497    f.render_stateful_widget(list, popup, &mut state);
498}
499
500fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
501    let Some(picker) = &app.wh_picker else {
502        return;
503    };
504    let warehouses = app.warehouses();
505    let name_w = warehouses
506        .iter()
507        .map(|(n, _, _)| n.chars().count())
508        .max()
509        .unwrap_or(12)
510        .max(12);
511    // marker(2) + dot(2) + name + gap(2) + state(7) + gap(2) + id(16) + padding/borders(6)
512    let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
513    let height = (warehouses.len() as u16 + 4).min(area.height);
514    let popup = Rect {
515        x: area.x + (area.width.saturating_sub(width)) / 2,
516        y: area.y + (area.height.saturating_sub(height)) / 2,
517        width,
518        height,
519    };
520    f.render_widget(Clear, popup);
521    let block = Block::default()
522        .title(Line::from(vec![
523            Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
524            Span::styled(
525                "Run query on ",
526                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
527            ),
528        ]))
529        .borders(Borders::ALL)
530        .border_type(BorderType::Rounded)
531        .border_style(
532            Style::default()
533                .fg(p.warehouses)
534                .add_modifier(Modifier::BOLD),
535        )
536        .padding(Padding::new(1, 1, 1, 1));
537    let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
538    let items: Vec<ListItem> = warehouses
539        .iter()
540        .map(|(name, id, running)| {
541            let dot = if *running { p.ok } else { p.dim };
542            let marker = if current_id == Some(id.as_str()) {
543                "» "
544            } else {
545                "  "
546            };
547            let state = if *running { "running" } else { "idle   " };
548            ListItem::new(Line::from(vec![
549                Span::styled(marker, Style::default().fg(p.key)),
550                Span::styled("● ", Style::default().fg(dot)),
551                Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
552                Span::styled(
553                    format!("  {state}"),
554                    Style::default().fg(if *running { p.ok } else { p.dim }),
555                ),
556                Span::styled(format!("  {id}"), Style::default().fg(p.dim)),
557            ]))
558        })
559        .collect();
560    let list = List::new(items)
561        .block(block)
562        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
563    let mut state = ListState::default().with_selected(Some(picker.index));
564    f.render_stateful_widget(list, popup, &mut state);
565}
566
567const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
568const WORDMARK_BOT: &str = "█▄▀ █▀█  █  █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
569
570fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
571    let lines_total = 9u16;
572    let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
573    let frame = app.spinner_frame();
574
575    // Brick-mark pyramid in Databricks red.
576    let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
577    for (i, row) in bricks.iter().enumerate() {
578        let line = Line::from(Span::styled(
579            *row,
580            Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
581        ));
582        let rect = Rect {
583            x: area.x,
584            y: top + i as u16,
585            width: area.width,
586            height: 1,
587        };
588        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
589    }
590
591    // Wordmark with a light sweep that travels across the letters.
592    let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
593    for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
594        let spans: Vec<Span> = text
595            .chars()
596            .enumerate()
597            .map(|(i, c)| {
598                let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
599                let style = if dist < 3 {
600                    Style::default()
601                        .fg(Color::White)
602                        .add_modifier(Modifier::BOLD)
603                } else {
604                    Style::default().fg(p.brand)
605                };
606                Span::styled(c.to_string(), style)
607            })
608            .collect();
609        let rect = Rect {
610            x: area.x,
611            y: top + row,
612            width: area.width,
613            height: 1,
614        };
615        f.render_widget(
616            Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
617            rect,
618        );
619    }
620
621    let tagline = Line::from(vec![
622        Span::styled("the ", Style::default().fg(p.dim)),
623        Span::styled(
624            "Lakehouse",
625            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
626        ),
627        Span::styled(" in your terminal", Style::default().fg(p.dim)),
628    ]);
629    let version = Line::from(Span::styled(
630        format!("v{}", env!("CARGO_PKG_VERSION")),
631        Style::default().fg(p.dim),
632    ));
633    for (offset, line) in [(7u16, tagline), (8u16, version)] {
634        let rect = Rect {
635            x: area.x,
636            y: top + offset,
637            width: area.width,
638            height: 1,
639        };
640        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
641    }
642}
643
644fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
645    let selected = app.picker.unwrap_or(0);
646    let width = (app
647        .profiles
648        .iter()
649        .map(|s| s.len())
650        .max()
651        .unwrap_or(10)
652        .max(20) as u16
653        + 6)
654    .min(area.width);
655    let height = (app.profiles.len() as u16 + 2).min(area.height);
656    let popup = Rect {
657        x: area.x + (area.width.saturating_sub(width)) / 2,
658        y: area.y + (area.height.saturating_sub(height)) / 2,
659        width,
660        height,
661    };
662    f.render_widget(Clear, popup);
663    let block = Block::default()
664        .title(Line::from(Span::styled(
665            " ⌂ Workspace ",
666            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
667        )))
668        .borders(Borders::ALL)
669        .border_type(BorderType::Rounded)
670        .border_style(Style::default().fg(p.key))
671        .padding(Padding::horizontal(1));
672    let items: Vec<ListItem> = app
673        .profiles
674        .iter()
675        .map(|name| {
676            let current = app.profile.as_deref() == Some(name.as_str());
677            let marker = if current { "● " } else { "  " };
678            ListItem::new(Line::from(vec![
679                Span::styled(marker, Style::default().fg(p.ok)),
680                Span::styled(name.as_str(), Style::default().fg(p.text)),
681            ]))
682        })
683        .collect();
684    let list = List::new(items)
685        .block(block)
686        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
687    let mut state = ListState::default().with_selected(Some(selected));
688    f.render_stateful_widget(list, popup, &mut state);
689}
690
691fn bucket_color(bucket: &str, p: &Palette) -> Color {
692    match bucket {
693        "Jobs" => p.jobs,
694        "SQL" => p.warehouses,
695        "All-Purpose" => p.clusters,
696        "DLT" => p.pipelines,
697        _ => p.dim,
698    }
699}
700
701fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
702    let cv = app.cost.as_ref().unwrap();
703    let scope = match &cv.data {
704        Some(Ok(d)) if d.scoped => " · this workspace",
705        Some(Ok(_)) => " · all workspaces",
706        _ => "",
707    };
708    let title = Line::from(vec![
709        Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
710        Span::styled(
711            format!("Usage · last 14 days{scope} "),
712            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
713        ),
714        Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
715    ]);
716    let block = Block::default()
717        .title(title)
718        .borders(Borders::ALL)
719        .border_type(BorderType::Rounded)
720        .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
721        .padding(Padding::new(1, 1, 1, 1));
722
723    match &cv.data {
724        None => {
725            let par = Paragraph::new(format!(
726                "{} querying system.billing.usage — the warehouse may need to start…",
727                app.spinner()
728            ))
729            .style(Style::default().fg(p.warn))
730            .block(block);
731            f.render_widget(par, area);
732        }
733        Some(Err(e)) => {
734            let par = Paragraph::new(format!(
735                "✗ {e}\n\nsystem tables need to be enabled and readable \
736                 (grants on the `system` catalog)"
737            ))
738            .style(Style::default().fg(p.err))
739            .wrap(Wrap { trim: false })
740            .block(block);
741            f.render_widget(par, area);
742        }
743        Some(Ok(data)) if data.days.is_empty() => {
744            let par = Paragraph::new("∅ no usage recorded in the last 14 days")
745                .style(Style::default().fg(p.dim))
746                .block(block);
747            f.render_widget(par, area);
748        }
749        Some(Ok(data)) => {
750            let mut lines: Vec<Line> = Vec::new();
751
752            // Legend with per-bucket totals, largest first.
753            let mut legend = vec![Span::raw("")];
754            for (bucket, total, usd) in &data.buckets {
755                legend.push(Span::styled(
756                    "■ ",
757                    Style::default().fg(bucket_color(bucket, p)),
758                ));
759                let amount = if data.priced {
760                    format!("{bucket} {total:.1} (${usd:.0})   ")
761                } else {
762                    format!("{bucket} {total:.1}   ")
763                };
764                legend.push(Span::styled(amount, Style::default().fg(p.text)));
765            }
766            let sigma = if data.priced {
767                format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
768            } else {
769                format!("Σ {:.1} DBU", data.total)
770            };
771            legend.push(Span::styled(
772                sigma,
773                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
774            ));
775            lines.push(Line::from(legend));
776            if data.priced {
777                lines.push(Line::from(Span::styled(
778                    " list prices before discounts",
779                    Style::default().fg(p.dim),
780                )));
781            }
782            if !data.scoped {
783                lines.push(Line::from(Span::styled(
784                    " couldn't resolve this workspace's id — showing the whole account",
785                    Style::default().fg(p.warn),
786                )));
787            }
788            lines.push(Line::default());
789
790            let max_day = data
791                .days
792                .iter()
793                .map(|d| d.total)
794                .fold(0.0_f64, f64::max)
795                .max(f64::EPSILON);
796            let inner_w = area.width.saturating_sub(4) as usize;
797            let bar_w = inner_w.saturating_sub(22).max(10);
798
799            for day in &data.days {
800                let mut spans = vec![Span::styled(
801                    // "2026-07-01" -> "07-01"
802                    format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
803                    Style::default().fg(p.dim),
804                )];
805                for (bucket, value) in &day.by_bucket {
806                    let chars = ((value / max_day) * bar_w as f64)
807                        .round()
808                        .max(if *value > 0.0 { 1.0 } else { 0.0 })
809                        as usize;
810                    spans.push(Span::styled(
811                        "█".repeat(chars),
812                        Style::default().fg(bucket_color(bucket, p)),
813                    ));
814                }
815                let day_label = if data.priced {
816                    format!("  {:.1} · ${:.2}", day.total, day.total_usd)
817                } else {
818                    format!("  {:.1}", day.total)
819                };
820                spans.push(Span::styled(day_label, Style::default().fg(p.text)));
821                lines.push(Line::from(spans));
822            }
823
824            if !data.spenders.is_empty() {
825                lines.push(Line::default());
826                lines.push(Line::from(Span::styled(
827                    "TOP SPENDERS · 14 days",
828                    Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
829                )));
830                let name_w = data
831                    .spenders
832                    .iter()
833                    .map(|s| {
834                        app.resource_name(&s.kind, &s.id)
835                            .unwrap_or_else(|| s.id.clone())
836                            .chars()
837                            .count()
838                    })
839                    .max()
840                    .unwrap_or(0)
841                    .max(8);
842                for (i, s) in data.spenders.iter().enumerate() {
843                    let name = app
844                        .resource_name(&s.kind, &s.id)
845                        .unwrap_or_else(|| s.id.clone());
846                    let kind_color = match s.kind.as_str() {
847                        "job" => p.jobs,
848                        "cluster" => p.clusters,
849                        "warehouse" => p.warehouses,
850                        _ => p.dim,
851                    };
852                    let amount = if data.priced {
853                        format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
854                    } else {
855                        format!("{:>8.1} DBU", s.dbus)
856                    };
857                    lines.push(Line::from(vec![
858                        Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
859                        Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
860                        Span::styled(
861                            format!("{:<width$}", name, width = name_w + 2),
862                            Style::default().fg(p.text),
863                        ),
864                        Span::styled(amount, Style::default().fg(p.text)),
865                    ]));
866                }
867            }
868
869            let par = Paragraph::new(lines).block(block);
870            f.render_widget(par, area);
871        }
872    }
873}
874
875fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
876    let console = app.sql.as_ref().unwrap();
877    let parts = Layout::default()
878        .direction(Direction::Vertical)
879        .constraints([Constraint::Length(3), Constraint::Min(0)])
880        .split(area);
881
882    let via = if console.warehouse.is_empty() {
883        String::new()
884    } else {
885        format!("via {} ", console.warehouse)
886    };
887    let prompt_block = Block::default()
888        .title(Line::from(vec![
889            Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
890            Span::styled(
891                "SQL Console ",
892                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
893            ),
894            Span::styled(via, Style::default().fg(p.dim)),
895        ]))
896        .borders(Borders::ALL)
897        .border_type(BorderType::Thick)
898        .border_style(
899            Style::default()
900                .fg(p.warehouses)
901                .add_modifier(Modifier::BOLD),
902        )
903        .padding(Padding::horizontal(1));
904    if let Some((query, _)) = &app.hist_search {
905        // Readline-style incremental search over history.
906        let matched = app
907            .hist_search_current()
908            .map(|s| s.replace('\n', " "))
909            .unwrap_or_default();
910        let prompt = Paragraph::new(Line::from(vec![
911            Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
912            Span::styled(
913                query.as_str(),
914                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
915            ),
916            Span::styled("`: ", Style::default().fg(p.dim)),
917            Span::styled(matched, Style::default().fg(p.text)),
918        ]))
919        .block(prompt_block);
920        f.render_widget(prompt, parts[0]);
921    } else {
922        // Caret sits at the cursor, not the end; long inputs scroll so
923        // the caret stays visible. Newlines from $EDITOR render as
924        // spaces (byte-for-byte, so the caret math holds).
925        let caret_byte = console
926            .input
927            .char_indices()
928            .nth(console.cursor)
929            .map(|(i, _)| i)
930            .unwrap_or(console.input.len());
931        let (before, after) = console.input.split_at(caret_byte);
932        let inner_w = parts[0].width.saturating_sub(4) as usize; // borders + padding
933        let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
934        let prompt = Paragraph::new(Line::from(vec![
935            Span::styled("❯ ", Style::default().fg(p.key)),
936            Span::styled(before.replace('\n', " "), Style::default().fg(p.text)),
937            Span::styled("▏", Style::default().fg(p.warn)),
938            Span::styled(after.replace('\n', " "), Style::default().fg(p.text)),
939        ]))
940        .scroll((0, hscroll))
941        .block(prompt_block);
942        f.render_widget(prompt, parts[0]);
943    }
944
945    let row_info = match &console.data {
946        Some(Ok(t)) => format!("{} rows ", t.rows.len()),
947        _ => String::new(),
948    };
949    let results_block = Block::default()
950        .title(Line::from(vec![
951            Span::styled(" Results ", Style::default().fg(p.text)),
952            Span::styled(row_info, Style::default().fg(p.dim)),
953        ]))
954        .borders(Borders::ALL)
955        .border_type(BorderType::Rounded)
956        .border_style(Style::default().fg(p.border))
957        .padding(Padding::horizontal(1));
958
959    if console.running {
960        let par = Paragraph::new(format!(
961            "{} running on {} — the warehouse may need a moment to start…",
962            app.spinner(),
963            console.warehouse
964        ))
965        .style(Style::default().fg(p.warn))
966        .block(results_block);
967        f.render_widget(par, parts[1]);
968        return;
969    }
970    match &console.data {
971        None => {
972            let par = Paragraph::new(
973                "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
974            )
975            .style(Style::default().fg(p.dim))
976            .block(results_block);
977            f.render_widget(par, parts[1]);
978        }
979        Some(Err(e)) => {
980            let par = Paragraph::new(format!("✗ {e}"))
981                .style(Style::default().fg(p.err))
982                .wrap(Wrap { trim: false })
983                .block(results_block);
984            f.render_widget(par, parts[1]);
985        }
986        Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
987            let par = Paragraph::new("✓ statement succeeded — no result set")
988                .style(Style::default().fg(p.ok))
989                .block(results_block);
990            f.render_widget(par, parts[1]);
991        }
992        Some(Ok(data)) => {
993            let header_cells: Vec<Cell> = data
994                .headers
995                .iter()
996                .map(|h| {
997                    Cell::from(h.as_str()).style(
998                        Style::default()
999                            .fg(p.warehouses)
1000                            .add_modifier(Modifier::BOLD),
1001                    )
1002                })
1003                .collect();
1004            let header = Row::new(header_cells);
1005            let rows: Vec<Row> = data
1006                .rows
1007                .iter()
1008                .skip(console.scroll)
1009                .map(|r| {
1010                    Row::new(
1011                        r.iter()
1012                            .map(|c| Cell::from(c.as_str()).style(Style::default().fg(p.text)))
1013                            .collect::<Vec<_>>(),
1014                    )
1015                })
1016                .collect();
1017            let n = data.headers.len().max(1) as u16;
1018            let widths: Vec<Constraint> = data
1019                .headers
1020                .iter()
1021                .map(|_| Constraint::Ratio(1, n as u32))
1022                .collect();
1023            let table = Table::new(rows, widths)
1024                .header(header)
1025                .column_spacing(1)
1026                .block(results_block);
1027            f.render_widget(table, parts[1]);
1028        }
1029    }
1030}
1031
1032fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1033    let pv = app.preview.as_ref().unwrap();
1034    let acc = p.catalog;
1035    let row_info = match &pv.data {
1036        Some(Ok(t)) => format!(" · {} rows", t.rows.len()),
1037        _ => String::new(),
1038    };
1039    let title = Line::from(vec![
1040        Span::styled(" ◫ ", Style::default().fg(acc)),
1041        Span::styled(
1042            format!("{}{} · preview ", pv.name, row_info),
1043            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1044        ),
1045        Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1046    ]);
1047    let block = Block::default()
1048        .title(title)
1049        .borders(Borders::ALL)
1050        .border_type(BorderType::Rounded)
1051        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1052        .padding(Padding::horizontal(1));
1053
1054    match &pv.data {
1055        None => {
1056            let par = Paragraph::new(format!(
1057                "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1058                app.spinner()
1059            ))
1060            .style(Style::default().fg(p.warn))
1061            .block(block);
1062            f.render_widget(par, area);
1063        }
1064        Some(Err(e)) => {
1065            let text = format!(
1066                "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1067                pv.warehouse,
1068                pv.warehouse_id,
1069                app.profile.as_deref().unwrap_or("default"),
1070                app.host.as_deref().unwrap_or("unknown"),
1071            );
1072            let par = Paragraph::new(text)
1073                .style(Style::default().fg(p.err))
1074                .wrap(Wrap { trim: false })
1075                .block(block);
1076            f.render_widget(par, area);
1077        }
1078        Some(Ok(data)) => {
1079            let header_cells: Vec<Cell> = data
1080                .headers
1081                .iter()
1082                .map(|h| {
1083                    Cell::from(h.as_str())
1084                        .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1085                })
1086                .collect();
1087            let header = Row::new(header_cells);
1088            let rows: Vec<Row> = data
1089                .rows
1090                .iter()
1091                .skip(pv.scroll)
1092                .map(|r| {
1093                    Row::new(
1094                        r.iter()
1095                            .map(|c| Cell::from(c.as_str()).style(Style::default().fg(p.text)))
1096                            .collect::<Vec<_>>(),
1097                    )
1098                })
1099                .collect();
1100            let n = data.headers.len().max(1) as u16;
1101            let widths: Vec<Constraint> = data
1102                .headers
1103                .iter()
1104                .map(|_| Constraint::Ratio(1, n as u32))
1105                .collect();
1106            let table = Table::new(rows, widths)
1107                .header(header)
1108                .column_spacing(1)
1109                .block(block);
1110            f.render_widget(table, area);
1111        }
1112    }
1113}
1114
1115fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1116    let d = app.detail.as_ref().unwrap();
1117    let acc = accent(d.panel, p);
1118    let title = Line::from(vec![
1119        Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1120        Span::styled(
1121            format!("{} · {} ", d.panel.title(), d.name),
1122            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1123        ),
1124    ]);
1125    let block = Block::default()
1126        .title(title)
1127        .borders(Borders::ALL)
1128        .border_type(BorderType::Rounded)
1129        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1130        .padding(Padding::horizontal(1));
1131
1132    let Some(data) = &d.data else {
1133        let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1134            .style(Style::default().fg(p.warn))
1135            .block(block);
1136        f.render_widget(par, area);
1137        return;
1138    };
1139
1140    // Fall back to raw when there's nothing structured to show (e.g. errors).
1141    if d.show_raw || data.summary.is_empty() {
1142        let par = Paragraph::new(data.raw.as_str())
1143            .style(Style::default().fg(p.text))
1144            .wrap(Wrap { trim: false })
1145            .scroll((d.scroll, 0))
1146            .block(block);
1147        f.render_widget(par, area);
1148        return;
1149    }
1150
1151    let mut lines: Vec<Line> = data
1152        .summary
1153        .iter()
1154        .map(|(k, v)| {
1155            Line::from(vec![
1156                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1157                Span::styled(v.as_str(), Style::default().fg(p.text)),
1158            ])
1159        })
1160        .collect();
1161    if !data.activity.is_empty() {
1162        lines.push(Line::default());
1163        lines.push(Line::from(Span::styled(
1164            d.section,
1165            Style::default().fg(acc).add_modifier(Modifier::BOLD),
1166        )));
1167        for (status, text) in &data.activity {
1168            lines.push(Line::from(vec![
1169                Span::styled("● ", Style::default().fg(status_color(status, p))),
1170                Span::styled(text.as_str(), Style::default().fg(p.text)),
1171            ]));
1172        }
1173    }
1174    let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1175    f.render_widget(par, area);
1176}
1177
1178fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1179    let rv = app.run_view.as_ref().unwrap();
1180    let acc = accent(rv.panel, p);
1181    let noun = if rv.panel == Panel::Jobs {
1182        "run"
1183    } else {
1184        "update"
1185    };
1186    let mut title_spans = vec![
1187        Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1188        Span::styled(
1189            format!("{} · {noun} ", rv.owner_name),
1190            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1191        ),
1192    ];
1193    if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1194        title_spans.push(Span::styled(
1195            format!("{run_id} "),
1196            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1197        ));
1198        title_spans.push(Span::styled(
1199            format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1200            Style::default().fg(p.dim),
1201        ));
1202    }
1203    if rv.live {
1204        title_spans.push(Span::styled(
1205            format!("{} live ", app.spinner()),
1206            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1207        ));
1208    }
1209    let block = Block::default()
1210        .title(Line::from(title_spans))
1211        .borders(Borders::ALL)
1212        .border_type(BorderType::Rounded)
1213        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1214        .padding(Padding::horizontal(1));
1215
1216    let Some(data) = &rv.data else {
1217        let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
1218            .style(Style::default().fg(p.warn))
1219            .block(block);
1220        f.render_widget(par, area);
1221        return;
1222    };
1223
1224    if rv.show_raw || data.summary.is_empty() {
1225        let par = Paragraph::new(data.raw.as_str())
1226            .style(Style::default().fg(p.text))
1227            .wrap(Wrap { trim: false })
1228            .scroll((rv.scroll, 0))
1229            .block(block);
1230        f.render_widget(par, area);
1231        return;
1232    }
1233
1234    let mut lines: Vec<Line> = data
1235        .summary
1236        .iter()
1237        .map(|(k, v)| {
1238            Line::from(vec![
1239                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1240                Span::styled(v.as_str(), Style::default().fg(p.text)),
1241            ])
1242        })
1243        .collect();
1244    if !data.activity.is_empty() {
1245        lines.push(Line::default());
1246        lines.push(Line::from(Span::styled(
1247            if rv.panel == Panel::Jobs {
1248                "Tasks"
1249            } else {
1250                "Event log"
1251            },
1252            Style::default().fg(acc).add_modifier(Modifier::BOLD),
1253        )));
1254        for (status, text) in &data.activity {
1255            // Error continuation lines carry their own ↳ marker.
1256            if text.starts_with("  ↳") {
1257                lines.push(Line::from(Span::styled(
1258                    text.as_str(),
1259                    Style::default().fg(p.err),
1260                )));
1261            } else {
1262                lines.push(Line::from(vec![
1263                    Span::styled("● ", Style::default().fg(status_color(status, p))),
1264                    Span::styled(text.as_str(), Style::default().fg(p.text)),
1265                ]));
1266            }
1267        }
1268    }
1269    let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
1270    f.render_widget(par, area);
1271}
1272
1273fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1274    let block = Block::default()
1275        .borders(Borders::ALL)
1276        .border_type(BorderType::Rounded)
1277        .border_style(Style::default().fg(p.border));
1278    let inner = block.inner(area);
1279    f.render_widget(block, area);
1280
1281    // Brand block: a two-tone brick mark, then DATABRICKS in the theme's
1282    // gradient with a bright shimmer sweeping across it while data loads,
1283    // then LAKEHOUSE letter-spaced in the accent color.
1284    let (from, to) = (p.grad_from, p.grad_to);
1285    let grad = |t: f32| {
1286        Color::Rgb(
1287            (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
1288            (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
1289            (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
1290        )
1291    };
1292    let mut left = vec![
1293        Span::styled(
1294            " ◢",
1295            Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
1296        ),
1297        Span::styled(
1298            "◤ ",
1299            Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
1300        ),
1301    ];
1302    let word: Vec<char> = "DATABRICKS".chars().collect();
1303    let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
1304    for (i, ch) in word.iter().enumerate() {
1305        let t = i as f32 / (word.len() - 1) as f32;
1306        let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
1307            Style::default().fg(p.text).add_modifier(Modifier::BOLD)
1308        } else {
1309            Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
1310        };
1311        left.push(Span::styled(ch.to_string(), style));
1312    }
1313    left.push(Span::raw(" "));
1314    for ch in "LAKEHOUSE".chars() {
1315        left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
1316    }
1317    left.push(Span::styled(
1318        format!("· v{}", env!("CARGO_PKG_VERSION")),
1319        Style::default().fg(p.dim),
1320    ));
1321    if let Some(profile) = &app.profile {
1322        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
1323        left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
1324        left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
1325    }
1326    if let Some(Shape::Badge(b)) = &app.user_badge {
1327        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
1328        left.push(Span::styled(
1329            format!("{} {}", b.label, b.value),
1330            Style::default().fg(p.key),
1331        ));
1332    }
1333    if app.zoomed {
1334        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
1335        left.push(Span::styled(
1336            format!("⛶ {}", app.focus.title()),
1337            Style::default()
1338                .fg(accent(app.focus, p))
1339                .add_modifier(Modifier::BOLD),
1340        ));
1341    }
1342
1343    // Workspace health at a glance: counts of items per status bucket.
1344    let (ok, pending, failed, idle) = app.status_counts();
1345    for (count, glyph, color) in [
1346        (ok, "●", p.ok),
1347        (pending, "◐", p.warn),
1348        (failed, "✗", p.err),
1349        (idle, "○", p.dim),
1350    ] {
1351        if count > 0 {
1352            left.push(Span::styled("  ", Style::default()));
1353            left.push(Span::styled(
1354                format!("{glyph} {count}"),
1355                Style::default().fg(color),
1356            ));
1357        }
1358    }
1359    f.render_widget(Paragraph::new(Line::from(left)), inner);
1360
1361    let right = if app.loading {
1362        Line::from(Span::styled(
1363            format!("{} refreshing ", app.spinner()),
1364            Style::default().fg(p.warn),
1365        ))
1366    } else {
1367        Line::from(Span::styled(
1368            format!("updated {}s ago ", app.last_refresh_age().as_secs()),
1369            Style::default().fg(p.dim),
1370        ))
1371    };
1372    f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
1373}
1374
1375fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1376    let key =
1377        |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
1378    let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
1379
1380    if let Some(confirm) = &app.confirm {
1381        let line = Line::from(vec![
1382            Span::styled(
1383                format!(" ⚠ {} ", confirm.message),
1384                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1385            ),
1386            key("y"),
1387            dim(" confirm · any other key cancels"),
1388        ]);
1389        f.render_widget(Paragraph::new(line), area);
1390        return;
1391    }
1392
1393    if app.filter_entry {
1394        let line = Line::from(vec![
1395            Span::styled(
1396                format!(" /{}", app.active_filter()),
1397                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1398            ),
1399            Span::styled("▏", Style::default().fg(p.warn)),
1400            dim("  type to filter   "),
1401            key("enter"),
1402            dim(" apply   "),
1403            key("esc"),
1404            dim(" clear"),
1405        ]);
1406        f.render_widget(Paragraph::new(line), area);
1407        return;
1408    }
1409
1410    let mut spans = if app.jump.is_some() {
1411        vec![
1412            dim(" type to search everything   "),
1413            key("\u{2191}"),
1414            dim("/"),
1415            key("\u{2193}"),
1416            dim(" select   "),
1417            key("enter"),
1418            dim(" jump   "),
1419            key("esc"),
1420            dim(" close"),
1421        ]
1422    } else if app.problems.is_some() {
1423        vec![
1424            dim(" "),
1425            key("j"),
1426            dim("/"),
1427            key("k"),
1428            dim(" select   "),
1429            key("enter"),
1430            dim(" jump to item   "),
1431            key("esc"),
1432            dim(" close"),
1433        ]
1434    } else if app.cost.is_some() {
1435        vec![
1436            dim(" "),
1437            key("esc"),
1438            dim(" back   "),
1439            key("q"),
1440            dim(" quit"),
1441        ]
1442    } else if app.wh_picker.is_some() {
1443        vec![
1444            dim(" "),
1445            key("j"),
1446            dim("/"),
1447            key("k"),
1448            dim(" select   "),
1449            key("enter"),
1450            dim(" run preview   "),
1451            key("esc"),
1452            dim(" cancel"),
1453        ]
1454    } else if app.sql.is_some() {
1455        if app.hist_search.is_some() {
1456            vec![
1457                dim(" type to search history   "),
1458                key("^r"),
1459                dim(" older match   "),
1460                key("enter"),
1461                dim(" accept   "),
1462                key("esc"),
1463                dim(" cancel"),
1464            ]
1465        } else {
1466            vec![
1467                dim(" "),
1468                key("enter"),
1469                dim(" run   "),
1470                key("↑"),
1471                dim("/"),
1472                key("↓"),
1473                dim(" history   "),
1474                key("^r"),
1475                dim(" search   "),
1476                key("^x"),
1477                dim(" editor   "),
1478                key("pgup"),
1479                dim("/"),
1480                key("pgdn"),
1481                dim(" scroll   "),
1482                key("^s"),
1483                dim(" export   "),
1484                key("esc"),
1485                dim(if app.sql.as_ref().is_some_and(|c| c.running) {
1486                    " cancel query"
1487                } else {
1488                    " close"
1489                }),
1490            ]
1491        }
1492    } else if app.preview.is_some() {
1493        vec![
1494            dim(" "),
1495            key("esc"),
1496            dim(" back   "),
1497            key("j"),
1498            dim("/"),
1499            key("k"),
1500            dim(" scroll rows   "),
1501            key("e"),
1502            dim(" export csv   "),
1503            key("q"),
1504            dim(" quit"),
1505        ]
1506    } else if app.picker.is_some() {
1507        vec![
1508            dim(" "),
1509            key("j"),
1510            dim("/"),
1511            key("k"),
1512            dim(" select   "),
1513            key("enter"),
1514            dim(" switch workspace   "),
1515            key("esc"),
1516            dim(" cancel"),
1517        ]
1518    } else if app.run_view.is_some() {
1519        vec![
1520            dim(" "),
1521            key("esc"),
1522            dim(" back   "),
1523            key("h"),
1524            dim("/"),
1525            key("l"),
1526            dim(" older/newer   "),
1527            key("s"),
1528            dim(" cancel   "),
1529            key("j"),
1530            dim("/"),
1531            key("k"),
1532            dim(" scroll   "),
1533            key("J"),
1534            dim(" raw   "),
1535            key("q"),
1536            dim(" quit"),
1537        ]
1538    } else if app.detail.is_some() {
1539        let mut spans = vec![dim(" "), key("esc"), dim(" back   ")];
1540        match app.detail.as_ref() {
1541            Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
1542                spans.push(key("enter"));
1543                spans.push(dim(" latest run   "));
1544            }
1545            Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
1546                spans.push(key("enter"));
1547                spans.push(dim(" latest update   "));
1548            }
1549            _ => {}
1550        }
1551        spans.extend([
1552            key("j"),
1553            dim("/"),
1554            key("k"),
1555            dim(" scroll   "),
1556            key("J"),
1557            dim(" raw   "),
1558            key("o"),
1559            dim(" open   "),
1560            key("q"),
1561            dim(" quit"),
1562        ]);
1563        spans
1564    } else {
1565        let mut spans = vec![
1566            dim(" "),
1567            key("tab"),
1568            dim("/"),
1569            key("h"),
1570            dim("/"),
1571            key("l"),
1572            dim(" switch   "),
1573            key("j"),
1574            dim("/"),
1575            key("k"),
1576            dim(" select   "),
1577            key("/"),
1578            dim(if app.active_filter().is_empty() {
1579                " filter   "
1580            } else {
1581                " filter · esc clears   "
1582            }),
1583            key("enter"),
1584            dim(if app.focus == Panel::Catalog && app.uc_path.len() < 2 {
1585                " open   "
1586            } else {
1587                " details   "
1588            }),
1589        ];
1590        // Only show keys that do something in the focused pane.
1591        match app.focus {
1592            Panel::Catalog => {
1593                if !app.uc_path.is_empty() {
1594                    spans.push(key("bksp"));
1595                    spans.push(dim(" up   "));
1596                }
1597                if app.uc_path.len() == 2 {
1598                    spans.push(key("p"));
1599                    spans.push(dim(" preview   "));
1600                    spans.push(key("L"));
1601                    spans.push(dim(" lineage   "));
1602                    spans.push(key(":"));
1603                    spans.push(dim(" query table   "));
1604                }
1605            }
1606            Panel::Dashboards => {}
1607            _ => {
1608                spans.push(key("s"));
1609                spans.push(dim(" action   "));
1610            }
1611        }
1612        spans.extend([
1613            key("g"),
1614            dim(" access   "),
1615            key("$"),
1616            dim(" cost   "),
1617            key("!"),
1618            dim(" problems   "),
1619        ]);
1620        // At the catalog's table level the ':' hint already reads "query table".
1621        if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
1622            spans.push(key(":"));
1623            spans.push(dim(" sql   "));
1624        }
1625        spans.extend([
1626            key("o"),
1627            dim(" open   "),
1628            key("z"),
1629            dim(if app.zoomed { " unzoom   " } else { " zoom   " }),
1630            key("w"),
1631            dim(" workspace   "),
1632            key("t"),
1633            dim(" theme   "),
1634            key("r"),
1635            dim(" refresh   "),
1636            key("q"),
1637            dim(" quit"),
1638        ]);
1639        spans
1640    };
1641    // A flash message prefixes the key hints instead of hiding them.
1642    if let Some((msg, _)) = &app.flash {
1643        let color = if msg.starts_with('✗') { p.err } else { p.ok };
1644        let mut prefixed = vec![
1645            Span::styled(
1646                format!(" {msg}"),
1647                Style::default().fg(color).add_modifier(Modifier::BOLD),
1648            ),
1649            dim("  ·  "),
1650        ];
1651        prefixed.append(&mut spans);
1652        spans = prefixed;
1653    }
1654    f.render_widget(Paragraph::new(Line::from(spans)), area);
1655}
1656
1657#[allow(clippy::too_many_arguments)]
1658fn draw_panel(
1659    f: &mut Frame,
1660    area: Rect,
1661    panel: Panel,
1662    shape: Option<&Shape>,
1663    focused: bool,
1664    selected: Option<usize>,
1665    fresh: bool,
1666    breadcrumb: &str,
1667    spinner: &str,
1668    filter: &str,
1669    entering: bool,
1670    p: &Palette,
1671) {
1672    let accent = accent(panel, p);
1673    let (border_style, title_style) = if focused {
1674        (
1675            Style::default().fg(accent).add_modifier(Modifier::BOLD),
1676            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1677        )
1678    } else {
1679        (Style::default().fg(p.border), Style::default().fg(p.dim))
1680    };
1681    let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
1682        Some(Shape::List(items)) => Some(
1683            items
1684                .iter()
1685                .filter(|it| crate::shape::item_matches(it, filter))
1686                .collect(),
1687        ),
1688        _ => None,
1689    };
1690    let count = match (&visible, shape) {
1691        // With a filter active, show how many made the cut.
1692        (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
1693            format!(" · {}/{}", v.len(), items.len())
1694        }
1695        (Some(v), _) => format!(" · {}", v.len()),
1696        (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
1697        _ => String::new(),
1698    };
1699    let crumb = if panel == Panel::Catalog && !breadcrumb.is_empty() {
1700        format!(" ▸ {breadcrumb}")
1701    } else {
1702        String::new()
1703    };
1704    // A short reversed flash on the title when fresh data lands.
1705    let title_style = if fresh {
1706        Style::default()
1707            .bg(accent)
1708            .fg(Color::Black)
1709            .add_modifier(Modifier::BOLD)
1710    } else {
1711        title_style
1712    };
1713    let mut title_spans = vec![
1714        Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
1715        Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
1716    ];
1717    if entering || !filter.is_empty() {
1718        title_spans.push(Span::styled(
1719            format!("/{}{} ", filter, if entering { "▏" } else { "" }),
1720            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1721        ));
1722    }
1723    let title = Line::from(title_spans);
1724    let block = Block::default()
1725        .title(title)
1726        .borders(Borders::ALL)
1727        .border_type(if focused {
1728            BorderType::Thick
1729        } else {
1730            BorderType::Rounded
1731        })
1732        .border_style(border_style)
1733        .padding(Padding::horizontal(1));
1734
1735    match shape {
1736        None => {
1737            let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
1738                .style(Style::default().fg(p.warn))
1739                .block(block);
1740            f.render_widget(par, area);
1741        }
1742        Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
1743            let empty = if !items.is_empty() {
1744                format!("∅ nothing matches /{filter}")
1745            } else {
1746                let msg = match panel {
1747                    Panel::Clusters => "no compute running",
1748                    Panel::Jobs => "no jobs defined",
1749                    Panel::Pipelines => "no pipelines",
1750                    Panel::Warehouses => "no warehouses",
1751                    Panel::Dashboards => "no dashboards yet",
1752                    Panel::Catalog => "nothing at this level",
1753                };
1754                format!("∅ {msg}")
1755            };
1756            let par = Paragraph::new(empty)
1757                .style(Style::default().fg(p.dim))
1758                .block(block);
1759            f.render_widget(par, area);
1760        }
1761        Some(Shape::List(_)) => {
1762            let list_items: Vec<ListItem> = visible
1763                .unwrap_or_default()
1764                .iter()
1765                .map(|item| {
1766                    let color = status_color(&item.status, p);
1767                    let chip = match item.status {
1768                        Status::Stopped | Status::Unknown(_) => Span::styled(
1769                            format!(" {} ", item.status.label()),
1770                            Style::default().fg(color).add_modifier(Modifier::DIM),
1771                        ),
1772                        _ => Span::styled(
1773                            format!(" {} ", item.status.label()),
1774                            Style::default()
1775                                .bg(color)
1776                                .fg(Color::Black)
1777                                .add_modifier(Modifier::BOLD),
1778                        ),
1779                    };
1780                    let mut spans = vec![
1781                        Span::styled("● ", Style::default().fg(color)),
1782                        Span::styled(item.name.as_str(), Style::default().fg(p.text)),
1783                        Span::raw("  "),
1784                        chip,
1785                    ];
1786                    if !item.history.is_empty() {
1787                        spans.push(Span::raw("  "));
1788                        for run in &item.history {
1789                            spans.push(Span::styled(
1790                                history_glyph(run),
1791                                Style::default().fg(status_color(run, p)),
1792                            ));
1793                        }
1794                    }
1795                    if let Some(detail) = &item.detail {
1796                        spans.push(Span::styled(
1797                            format!("  {}", detail),
1798                            Style::default().fg(p.dim),
1799                        ));
1800                    }
1801                    ListItem::new(Line::from(spans))
1802                })
1803                .collect();
1804            let list = List::new(list_items)
1805                .block(block)
1806                .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1807            match selected {
1808                Some(sel) => {
1809                    let mut state = ListState::default().with_selected(Some(sel));
1810                    f.render_stateful_widget(list, area, &mut state);
1811                }
1812                None => f.render_widget(list, area),
1813            }
1814        }
1815        Some(Shape::Table(data)) => {
1816            let header_cells: Vec<Cell> = data
1817                .headers
1818                .iter()
1819                .map(|h| {
1820                    Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
1821                })
1822                .collect();
1823            let header = Row::new(header_cells).style(Style::default().fg(accent));
1824            let rows: Vec<Row> = data
1825                .rows
1826                .iter()
1827                .map(|r| Row::new(r.iter().map(|c| Cell::from(c.as_str())).collect::<Vec<_>>()))
1828                .collect();
1829            let widths: Vec<Constraint> = data
1830                .headers
1831                .iter()
1832                .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
1833                .collect();
1834            let table = Table::new(rows, widths).header(header).block(block);
1835            f.render_widget(table, area);
1836        }
1837        Some(Shape::Badge(b)) => {
1838            let text = format!("{}: {}", b.label, b.value);
1839            let par = Paragraph::new(text)
1840                .style(Style::default().fg(p.text))
1841                .block(block);
1842            f.render_widget(par, area);
1843        }
1844        Some(Shape::Text(t)) => {
1845            let color = if t.starts_with('✗') { p.err } else { p.text };
1846            let par = Paragraph::new(t.as_str())
1847                .style(Style::default().fg(color))
1848                .wrap(Wrap { trim: false })
1849                .block(block);
1850            f.render_widget(par, area);
1851        }
1852    }
1853}
1854
1855fn status_color(status: &Status, p: &Palette) -> Color {
1856    match status {
1857        Status::Running | Status::Success => p.ok,
1858        Status::Stopped => p.dim,
1859        Status::Pending => p.warn,
1860        Status::Failed => p.err,
1861        Status::Unknown(_) => p.text,
1862    }
1863}
1864
1865fn history_glyph(status: &Status) -> &'static str {
1866    match status {
1867        Status::Success => "✓",
1868        Status::Failed => "✗",
1869        Status::Running => "●",
1870        Status::Pending => "◐",
1871        Status::Stopped => "○",
1872        Status::Unknown(_) => "·",
1873    }
1874}