1use crate::app::{App, Panel, ThemeMode};
2use crate::shape::{DetailData, Shape, Status, TableData};
3use ratatui::{
4 layout::{Alignment, Constraint, Direction, Layout, Margin, 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, Scrollbar, ScrollbarOrientation, ScrollbarState, 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 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 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 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 Panel::Secrets => p.warn,
194 }
195}
196
197fn pane_breadcrumb(app: &App, panel: Panel) -> String {
198 match panel {
199 Panel::Catalog => app.uc_path.join("."),
200 Panel::Secrets => app.secret_scope.clone().unwrap_or_default(),
201 _ => String::new(),
202 }
203}
204
205pub fn draw(f: &mut Frame, app: &App) {
206 let p = palette(app.theme);
207
208 if app.splash_active() {
209 draw_splash(f, f.area(), app, &p);
210 return;
211 }
212
213 let root = Layout::default()
214 .direction(Direction::Vertical)
215 .constraints([
216 Constraint::Length(3),
217 Constraint::Min(0),
218 Constraint::Length(1),
219 ])
220 .split(f.area());
221
222 draw_header(f, root[0], app, &p);
223 draw_footer(f, root[2], app, &p);
224
225 if app.sql.is_some() {
226 draw_sql(f, root[1], app, &p);
227 if app.sql_complete.is_some() {
228 draw_sql_complete(f, root[1], app, &p);
229 }
230 if app.wh_picker.is_some() {
232 draw_wh_picker(f, root[1], app, &p);
233 }
234 return;
235 }
236
237 if app.cost.is_some() {
238 draw_cost(f, root[1], app, &p);
239 return;
240 }
241
242 if app.preview.is_some() {
243 draw_preview(f, root[1], app, &p);
244 return;
245 }
246
247 if app.run_view.is_some() {
248 draw_run(f, root[1], app, &p);
249 return;
250 }
251
252 if app.detail.is_some() {
253 draw_detail(f, root[1], app, &p);
254 return;
255 }
256
257 if app.zoomed {
258 let idx = Panel::ALL
259 .iter()
260 .position(|pn| pn == &app.focus)
261 .unwrap_or(0);
262 draw_panel(
263 f,
264 root[1],
265 app.focus,
266 app.shapes[idx].as_ref(),
267 true,
268 Some(app.selection(idx)),
269 false,
270 &pane_breadcrumb(app, app.focus),
271 app.spinner(),
272 &app.filters[idx],
273 app.filter_entry,
274 &p,
275 );
276 if app.picker.is_some() {
278 draw_picker(f, root[1], app, &p);
279 }
280 if app.wh_picker.is_some() {
281 draw_wh_picker(f, root[1], app, &p);
282 }
283 if app.problems.is_some() {
284 draw_problems(f, root[1], app, &p);
285 }
286 if app.upcoming.is_some() {
287 draw_upcoming(f, root[1], app, &p);
288 }
289 if app.jump.is_some() {
290 draw_jump(f, root[1], app, &p);
291 }
292 if app.pane_cfg.is_some() {
293 draw_pane_cfg(f, root[1], app, &p);
294 }
295 if app.secret_form.is_some() {
296 draw_secret_form(f, root[1], app, &p);
297 }
298 if app.help {
299 draw_help(f, root[1], app, &p);
300 }
301 return;
302 }
303
304 let visible = app.visible_panes();
307 if visible.is_empty() {
308 let par = Paragraph::new("all panes hidden — press H to bring them back")
309 .style(Style::default().fg(p.dim))
310 .alignment(Alignment::Center);
311 f.render_widget(par, root[1]);
312 } else {
313 let rows_of = |n: usize| -> Vec<Constraint> {
314 (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
315 };
316 let left_count = visible.len().div_ceil(2);
317 let right_count = visible.len() - left_count;
318 let mut areas: Vec<Rect> = Vec::new();
319 if right_count == 0 {
320 areas.extend(
321 Layout::default()
322 .direction(Direction::Vertical)
323 .constraints(rows_of(left_count))
324 .split(root[1])
325 .iter(),
326 );
327 } else {
328 let body = Layout::default()
329 .direction(Direction::Horizontal)
330 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
331 .split(root[1]);
332 areas.extend(
333 Layout::default()
334 .direction(Direction::Vertical)
335 .constraints(rows_of(left_count))
336 .split(body[0])
337 .iter(),
338 );
339 areas.extend(
340 Layout::default()
341 .direction(Direction::Vertical)
342 .constraints(rows_of(right_count))
343 .split(body[1])
344 .iter(),
345 );
346 }
347
348 for (slot, &i) in visible.iter().enumerate() {
349 let panel = Panel::ALL[i];
350 let focused = app.focus == panel;
351 let shape = app.shapes[i].as_ref();
352 let selected = focused.then(|| app.selection(i));
353 let fresh = app.updated_at[i]
354 .map(|t| t.elapsed() < Duration::from_millis(1200))
355 .unwrap_or(false);
356 draw_panel(
357 f,
358 areas[slot],
359 panel,
360 shape,
361 focused,
362 selected,
363 fresh,
364 &pane_breadcrumb(app, panel),
365 app.spinner(),
366 &app.filters[i],
367 app.filter_entry && focused,
368 &p,
369 );
370 }
371 }
372
373 if app.picker.is_some() {
374 draw_picker(f, root[1], app, &p);
375 }
376 if app.wh_picker.is_some() {
377 draw_wh_picker(f, root[1], app, &p);
378 }
379 if app.problems.is_some() {
380 draw_problems(f, root[1], app, &p);
381 }
382 if app.upcoming.is_some() {
383 draw_upcoming(f, root[1], app, &p);
384 }
385 if app.jump.is_some() {
386 draw_jump(f, root[1], app, &p);
387 }
388 if app.pane_cfg.is_some() {
389 draw_pane_cfg(f, root[1], app, &p);
390 }
391 if app.secret_form.is_some() {
392 draw_secret_form(f, root[1], app, &p);
393 }
394 if app.help {
395 draw_help(f, root[1], app, &p);
396 }
397}
398
399fn draw_secret_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
400 let Some(form) = &app.secret_form else {
401 return;
402 };
403 let width = 60.min(area.width.saturating_sub(4));
404 let height = 6.min(area.height);
405 let popup = Rect {
406 x: area.x + (area.width.saturating_sub(width)) / 2,
407 y: area.y + (area.height.saturating_sub(height)) / 2,
408 width,
409 height,
410 };
411 f.render_widget(Clear, popup);
412 let title = match &form.scope {
413 None => "New secret scope ".to_string(),
414 Some(s) => format!("New secret in {s} "),
415 };
416 let block = Block::default()
417 .title(Line::from(vec![
418 Span::styled(" ◈ ", Style::default().fg(p.warn)),
419 Span::styled(
420 title,
421 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
422 ),
423 ]))
424 .borders(Borders::ALL)
425 .border_type(BorderType::Thick)
426 .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
427 .padding(Padding::new(1, 1, 1, 0));
428 let label0 = if form.scope.is_none() {
429 "name "
430 } else {
431 "key "
432 };
433 let mut lines = vec![Line::from(vec![
434 Span::styled(label0, Style::default().fg(p.dim)),
435 Span::styled(form.key.as_str(), Style::default().fg(p.text)),
436 if form.stage == 0 {
437 Span::styled("▏", Style::default().fg(p.warn))
438 } else {
439 Span::raw("")
440 },
441 ])];
442 if form.scope.is_some() {
443 lines.push(Line::from(vec![
445 Span::styled("value ", Style::default().fg(p.dim)),
446 Span::styled(
447 "•".repeat(form.value.chars().count()),
448 Style::default().fg(p.text),
449 ),
450 if form.stage == 1 {
451 Span::styled("▏", Style::default().fg(p.warn))
452 } else {
453 Span::raw("")
454 },
455 ]));
456 }
457 f.render_widget(Paragraph::new(lines).block(block), popup);
458}
459
460fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
461 let Some(selected) = app.pane_cfg else {
462 return;
463 };
464 let width = 52.min(area.width.saturating_sub(4));
465 let height = 11.min(area.height);
466 let popup = Rect {
467 x: area.x + (area.width.saturating_sub(width)) / 2,
468 y: area.y + (area.height.saturating_sub(height)) / 2,
469 width,
470 height,
471 };
472 f.render_widget(Clear, popup);
473 let block = Block::default()
474 .title(Line::from(vec![
475 Span::styled(" ▦ ", Style::default().fg(p.key)),
476 Span::styled(
477 "Panes ",
478 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
479 ),
480 ]))
481 .borders(Borders::ALL)
482 .border_type(BorderType::Thick)
483 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
484 .padding(Padding::new(1, 1, 1, 0));
485 let items: Vec<ListItem> = app
486 .pane_order
487 .iter()
488 .map(|&i| {
489 let panel = Panel::ALL[i];
490 let (mark, style) = if app.hidden[i] {
491 ("○ ", Style::default().fg(p.dim))
492 } else {
493 ("● ", Style::default().fg(p.ok))
494 };
495 ListItem::new(Line::from(vec![
496 Span::styled(mark, style),
497 Span::styled(
498 format!("{} ", panel.icon()),
499 Style::default().fg(accent(panel, p)),
500 ),
501 Span::styled(
502 panel.title(),
503 if app.hidden[i] {
504 Style::default().fg(p.dim)
505 } else {
506 Style::default().fg(p.text)
507 },
508 ),
509 ]))
510 })
511 .collect();
512 let list = List::new(items)
513 .block(block)
514 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
515 let mut state = ListState::default().with_selected(Some(selected));
516 f.render_stateful_widget(list, popup, &mut state);
517}
518
519fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
520 let width = 74.min(area.width.saturating_sub(4));
521 let height = area.height.saturating_sub(2);
522 let popup = Rect {
523 x: area.x + (area.width.saturating_sub(width)) / 2,
524 y: area.y + 1,
525 width,
526 height,
527 };
528 f.render_widget(Clear, popup);
529 let block = Block::default()
530 .title(Line::from(vec![
531 Span::styled(" ? ", Style::default().fg(p.key)),
532 Span::styled(
533 "Keys ",
534 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
535 ),
536 ]))
537 .borders(Borders::ALL)
538 .border_type(BorderType::Thick)
539 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
540 .padding(Padding::new(1, 1, 1, 0));
541
542 let sections: &[(&str, &[(&str, &str)])] = &[
543 (
544 "Global",
545 &[
546 ("tab / l, shift+tab / h", "focus next / previous pane"),
547 ("j / k", "select item"),
548 ("enter", "open details / drill down"),
549 ("/", "filter the focused pane (esc clears)"),
550 ("ctrl+p", "command palette: fuzzy-jump anywhere"),
551 ("!", "problems: failures in every workspace, enter jumps"),
552 ("u", "upcoming runs: what fires next, soonest first"),
553 (":", "SQL console (prefilled on a catalog table)"),
554 ("$", "cost view: DBUs, dollars, top spenders"),
555 ("H", "arrange panes: hide and reorder"),
556 ("z", "zoom the focused pane"),
557 ("w", "switch workspace profile"),
558 ("t", "cycle color theme"),
559 ("r", "refresh now"),
560 ("?", "this help"),
561 ("q / ctrl+c", "quit"),
562 ],
563 ),
564 (
565 "Focused pane",
566 &[
567 ("s", "start / stop / run the selected item"),
568 ("S", "jobs: pause / resume the schedule or trigger"),
569 ("g", "access: grants and permissions"),
570 ("o", "open in the workspace web UI"),
571 ],
572 ),
573 (
574 "Unity Catalog pane",
575 &[
576 ("enter / backspace", "drill down / up (into volumes too)"),
577 ("p / P", "preview table rows (P picks the warehouse)"),
578 ("L", "lineage: upstream and downstream tables"),
579 (":", "query the selected table"),
580 ("enter on a file", "peek at its first 200 lines"),
581 ],
582 ),
583 (
584 "Details & runs",
585 &[
586 ("enter", "job/pipeline detail → latest run/update"),
587 ("h / l", "older / newer run"),
588 ("o", "full task output — keeps tailing while live"),
589 ("t", "timeline: per-task Gantt of the run"),
590 ("d", "dag: task dependency tree of the run"),
591 ("g", "grid: task states across recent runs, with trends"),
592 ("r", "repair a failed run — reruns only failed tasks"),
593 ("s", "cancel the shown run"),
594 ("j / k, J", "scroll · toggle raw JSON"),
595 ],
596 ),
597 (
598 "Table previews",
599 &[
600 ("j / k", "scroll rows"),
601 ("← / →", "page columns (wide tables)"),
602 ("/", "filter columns by name"),
603 ("v / enter", "record view: one row, fields stacked"),
604 ("e", "export rows to CSV"),
605 ],
606 ),
607 (
608 "Secret scopes pane",
609 &[
610 ("enter / backspace", "open a scope / back to scopes"),
611 ("a", "create a scope / add a secret (value masked)"),
612 ("x", "delete the selected scope or secret"),
613 ("g", "scope ACLs"),
614 ],
615 ),
616 (
617 "SQL console",
618 &[
619 ("enter", "run the statement"),
620 ("tab", "complete catalog/schema/table/column names"),
621 ("↑ / ↓, ctrl+r", "history · incremental search"),
622 ("ctrl+x", "compose in $EDITOR"),
623 ("ctrl+s", "export results to CSV"),
624 ("pgup / pgdn", "scroll results"),
625 ("shift+← / →", "page result columns"),
626 ("esc", "cancel a running query, else close"),
627 ],
628 ),
629 ];
630 let mut lines: Vec<Line> = Vec::new();
631 for (title, keys) in sections {
632 lines.push(Line::from(Span::styled(
633 *title,
634 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
635 )));
636 for (k, desc) in *keys {
637 lines.push(Line::from(vec![
638 Span::styled(format!(" {k:<24}"), Style::default().fg(p.warn)),
639 Span::styled(*desc, Style::default().fg(p.text)),
640 ]));
641 }
642 lines.push(Line::default());
643 }
644 let total = lines.len();
645 let par = Paragraph::new(lines)
646 .scroll((app.help_scroll, 0))
647 .block(block);
648 f.render_widget(par, popup);
649 scrollbar(
651 f,
652 popup,
653 total,
654 popup.height.saturating_sub(3) as usize,
655 app.help_scroll as usize,
656 p,
657 );
658}
659
660fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
661 let Some(jump) = &app.jump else {
662 return;
663 };
664 let matches = app.jump_matches();
665 let width = 70.min(area.width.saturating_sub(4));
666 let height = (matches.len() as u16 + 5).min(area.height);
667 let popup = Rect {
668 x: area.x + (area.width.saturating_sub(width)) / 2,
669 y: area.y + 2.min(area.height.saturating_sub(height)),
670 width,
671 height,
672 };
673 f.render_widget(Clear, popup);
674 let block = Block::default()
675 .title(Line::from(vec![
676 Span::styled(" ⌕ ", Style::default().fg(p.key)),
677 Span::styled(
678 "Jump to ",
679 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
680 ),
681 ]))
682 .borders(Borders::ALL)
683 .border_type(BorderType::Thick)
684 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
685 .padding(Padding::horizontal(1));
686 let inner = block.inner(popup);
687 f.render_widget(block, popup);
688
689 let parts = Layout::default()
690 .direction(Direction::Vertical)
691 .constraints([Constraint::Length(2), Constraint::Min(0)])
692 .split(inner);
693 f.render_widget(
694 Paragraph::new(Line::from(vec![
695 Span::styled("❯ ", Style::default().fg(p.key)),
696 Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
697 Span::styled("▏", Style::default().fg(p.warn)),
698 ])),
699 parts[0],
700 );
701
702 if matches.is_empty() {
703 f.render_widget(
704 Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
705 parts[1],
706 );
707 return;
708 }
709 let items: Vec<ListItem> = matches
710 .iter()
711 .map(|(panel_idx, name, label)| {
712 let panel = Panel::ALL[*panel_idx];
713 ListItem::new(Line::from(vec![
714 Span::styled(
715 format!("{} ", panel.icon()),
716 Style::default().fg(accent(panel, p)),
717 ),
718 Span::styled(format!("{name} "), Style::default().fg(p.text)),
719 Span::styled(label.clone(), Style::default().fg(p.dim)),
720 ]))
721 })
722 .collect();
723 let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
724 let mut state = ListState::default().with_selected(Some(jump.index));
725 f.render_stateful_widget(list, parts[1], &mut state);
726}
727
728fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
729 let Some(pr) = &app.problems else {
730 return;
731 };
732 let truncate = |s: &str, max: usize| -> String {
733 if s.chars().count() <= max {
734 s.to_string()
735 } else {
736 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
737 format!("{cut}…")
738 }
739 };
740 let prefix_of = |problem: &crate::app::Problem| -> String {
744 problem
745 .profile
746 .as_ref()
747 .map(|pr| format!("{pr} ▸ "))
748 .unwrap_or_default()
749 };
750 let name_w = pr
751 .items
752 .iter()
753 .map(|i| i.name.chars().count() + prefix_of(i).chars().count())
754 .max()
755 .unwrap_or(10)
756 .clamp(10, 40);
757 let note_w = pr
758 .items
759 .iter()
760 .map(|i| i.note.chars().count())
761 .max()
762 .unwrap_or(0);
763 let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
765 let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
766 let note_space = (width as usize).saturating_sub(name_w + 30);
768 let popup = Rect {
769 x: area.x + (area.width.saturating_sub(width)) / 2,
770 y: area.y + (area.height.saturating_sub(height)) / 2,
771 width,
772 height,
773 };
774 f.render_widget(Clear, popup);
775 let mut title_spans = vec![
776 Span::styled(" ✗ ", Style::default().fg(p.err)),
777 Span::styled(
778 format!("Problems · {} ", pr.items.len()),
779 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
780 ),
781 ];
782 if pr.scanning {
783 title_spans.push(Span::styled(
784 format!("· {} scanning workspaces ", app.spinner()),
785 Style::default().fg(p.warn),
786 ));
787 }
788 let block = Block::default()
789 .title(Line::from(title_spans))
790 .borders(Borders::ALL)
791 .border_type(BorderType::Rounded)
792 .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
793 .padding(Padding::new(1, 1, 1, 1));
794
795 if pr.items.is_empty() {
796 let (text, color) = if pr.scanning {
797 (
798 format!(
799 "✓ nothing failing here — {} scanning other workspaces…",
800 app.spinner()
801 ),
802 p.warn,
803 )
804 } else {
805 ("✓ all clear — nothing failing right now".to_string(), p.ok)
806 };
807 let par = Paragraph::new(text)
808 .style(Style::default().fg(color))
809 .block(block);
810 f.render_widget(par, popup);
811 return;
812 }
813
814 let items: Vec<ListItem> = pr
815 .items
816 .iter()
817 .map(|problem| {
818 let prefix = prefix_of(problem);
819 let plen = prefix.chars().count().min(name_w.saturating_sub(4));
820 let mut spans = vec![
821 Span::styled("✗ ", Style::default().fg(p.err)),
822 Span::styled(truncate(&prefix, plen), Style::default().fg(p.dim)),
823 Span::styled(
824 format!(
825 "{:<width$}",
826 truncate(&problem.name, name_w - plen),
827 width = name_w + 2 - plen
828 ),
829 Style::default().fg(p.text),
830 ),
831 ];
832 spans.push(match problem.panel {
833 Some(i) => {
834 let panel = Panel::ALL[i];
835 Span::styled(
836 format!("{} {:<18}", panel.icon(), panel.title()),
837 Style::default().fg(accent(panel, p)),
838 )
839 }
840 None => Span::styled(format!("⌂ {:<18}", "workspace"), Style::default().fg(p.dim)),
841 });
842 spans.push(Span::styled(
843 truncate(&problem.note, note_space),
844 Style::default().fg(p.dim),
845 ));
846 ListItem::new(Line::from(spans))
847 })
848 .collect();
849 let list = List::new(items)
850 .block(block)
851 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
852 let mut state = ListState::default().with_selected(Some(pr.index));
853 f.render_stateful_widget(list, popup, &mut state);
854}
855
856fn draw_upcoming(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
859 let Some(u) = &app.upcoming else {
860 return;
861 };
862 let label_w = u
863 .items
864 .iter()
865 .map(|i| i.next.label.chars().count())
866 .max()
867 .unwrap_or(8)
868 .clamp(8, 18);
869 let name_w = u
870 .items
871 .iter()
872 .map(|i| i.name.chars().count())
873 .max()
874 .unwrap_or(10)
875 .clamp(10, 40);
876 let desc_w = u
877 .items
878 .iter()
879 .map(|i| i.next.desc.chars().count())
880 .max()
881 .unwrap_or(0);
882 let width = ((label_w + name_w + desc_w + 12) as u16).min(area.width.saturating_sub(4));
883 let height = (u.items.len().max(1) as u16 + 4).min(area.height);
884 let desc_space = (width as usize).saturating_sub(label_w + name_w + 10);
885 let popup = Rect {
886 x: area.x + (area.width.saturating_sub(width)) / 2,
887 y: area.y + (area.height.saturating_sub(height)) / 2,
888 width,
889 height,
890 };
891 f.render_widget(Clear, popup);
892 let block = Block::default()
893 .title(Line::from(vec![
894 Span::styled(" ⏱ ", Style::default().fg(p.key)),
895 Span::styled(
896 format!("Upcoming runs · {} ", u.items.len()),
897 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
898 ),
899 ]))
900 .borders(Borders::ALL)
901 .border_type(BorderType::Rounded)
902 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
903 .padding(Padding::new(1, 1, 1, 1));
904
905 if u.loading {
906 let par = Paragraph::new(format!("{} reading job schedules…", app.spinner()))
907 .style(Style::default().fg(p.warn))
908 .block(block);
909 f.render_widget(par, popup);
910 return;
911 }
912 if u.items.is_empty() {
913 let par = Paragraph::new("no scheduled or triggered jobs — everything runs on demand")
914 .style(Style::default().fg(p.dim))
915 .block(block);
916 f.render_widget(par, popup);
917 return;
918 }
919
920 let truncate = |s: &str, max: usize| -> String {
921 if s.chars().count() <= max {
922 s.to_string()
923 } else {
924 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
925 format!("{cut}…")
926 }
927 };
928 let items: Vec<ListItem> = u
929 .items
930 .iter()
931 .map(|it| {
932 let when_color = if it.next.paused {
933 p.dim
934 } else if it.next.at_ms.is_some() {
935 p.ok
936 } else {
937 p.warn
938 };
939 ListItem::new(Line::from(vec![
940 Span::styled(
941 format!(
942 "{:<width$}",
943 truncate(&it.next.label, label_w),
944 width = label_w + 2
945 ),
946 Style::default().fg(when_color),
947 ),
948 Span::styled(
949 format!("{:<width$}", truncate(&it.name, name_w), width = name_w + 2),
950 Style::default().fg(p.text),
951 ),
952 Span::styled(
953 truncate(&it.next.desc, desc_space),
954 Style::default().fg(p.dim),
955 ),
956 ]))
957 })
958 .collect();
959 let list = List::new(items)
960 .block(block)
961 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
962 let mut state = ListState::default().with_selected(Some(u.index));
963 f.render_stateful_widget(list, popup, &mut state);
964}
965
966fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
967 let Some(picker) = &app.wh_picker else {
968 return;
969 };
970 let warehouses = app.warehouses();
971 let name_w = warehouses
972 .iter()
973 .map(|(n, _, _)| n.chars().count())
974 .max()
975 .unwrap_or(12)
976 .max(12);
977 let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
979 let height = (warehouses.len() as u16 + 4).min(area.height);
980 let popup = Rect {
981 x: area.x + (area.width.saturating_sub(width)) / 2,
982 y: area.y + (area.height.saturating_sub(height)) / 2,
983 width,
984 height,
985 };
986 f.render_widget(Clear, popup);
987 let block = Block::default()
988 .title(Line::from(vec![
989 Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
990 Span::styled(
991 "Run query on ",
992 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
993 ),
994 ]))
995 .borders(Borders::ALL)
996 .border_type(BorderType::Rounded)
997 .border_style(
998 Style::default()
999 .fg(p.warehouses)
1000 .add_modifier(Modifier::BOLD),
1001 )
1002 .padding(Padding::new(1, 1, 1, 1));
1003 let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
1004 let items: Vec<ListItem> = warehouses
1005 .iter()
1006 .map(|(name, id, running)| {
1007 let dot = if *running { p.ok } else { p.dim };
1008 let marker = if current_id == Some(id.as_str()) {
1009 "» "
1010 } else {
1011 " "
1012 };
1013 let state = if *running { "running" } else { "idle " };
1014 ListItem::new(Line::from(vec![
1015 Span::styled(marker, Style::default().fg(p.key)),
1016 Span::styled("● ", Style::default().fg(dot)),
1017 Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
1018 Span::styled(
1019 format!(" {state}"),
1020 Style::default().fg(if *running { p.ok } else { p.dim }),
1021 ),
1022 Span::styled(format!(" {id}"), Style::default().fg(p.dim)),
1023 ]))
1024 })
1025 .collect();
1026 let list = List::new(items)
1027 .block(block)
1028 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1029 let mut state = ListState::default().with_selected(Some(picker.index));
1030 f.render_stateful_widget(list, popup, &mut state);
1031}
1032
1033const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
1034const WORDMARK_BOT: &str = "█▄▀ █▀█ █ █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
1035
1036fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1037 let lines_total = 9u16;
1038 let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
1039 let frame = app.spinner_frame();
1040
1041 let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
1043 for (i, row) in bricks.iter().enumerate() {
1044 let line = Line::from(Span::styled(
1045 *row,
1046 Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
1047 ));
1048 let rect = Rect {
1049 x: area.x,
1050 y: top + i as u16,
1051 width: area.width,
1052 height: 1,
1053 };
1054 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1055 }
1056
1057 let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
1059 for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
1060 let spans: Vec<Span> = text
1061 .chars()
1062 .enumerate()
1063 .map(|(i, c)| {
1064 let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
1065 let style = if dist < 3 {
1066 Style::default()
1067 .fg(Color::White)
1068 .add_modifier(Modifier::BOLD)
1069 } else {
1070 Style::default().fg(p.brand)
1071 };
1072 Span::styled(c.to_string(), style)
1073 })
1074 .collect();
1075 let rect = Rect {
1076 x: area.x,
1077 y: top + row,
1078 width: area.width,
1079 height: 1,
1080 };
1081 f.render_widget(
1082 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
1083 rect,
1084 );
1085 }
1086
1087 let tagline = Line::from(vec![
1088 Span::styled("the ", Style::default().fg(p.dim)),
1089 Span::styled(
1090 "Lakehouse",
1091 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1092 ),
1093 Span::styled(" in your terminal", Style::default().fg(p.dim)),
1094 ]);
1095 let version = Line::from(Span::styled(
1096 format!("v{}", env!("CARGO_PKG_VERSION")),
1097 Style::default().fg(p.dim),
1098 ));
1099 for (offset, line) in [(7u16, tagline), (8u16, version)] {
1100 let rect = Rect {
1101 x: area.x,
1102 y: top + offset,
1103 width: area.width,
1104 height: 1,
1105 };
1106 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1107 }
1108}
1109
1110fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1111 let selected = app.picker.unwrap_or(0);
1112 let width = (app
1113 .profiles
1114 .iter()
1115 .map(|s| s.len())
1116 .max()
1117 .unwrap_or(10)
1118 .max(20) as u16
1119 + 6)
1120 .min(area.width);
1121 let height = (app.profiles.len() as u16 + 2).min(area.height);
1122 let popup = Rect {
1123 x: area.x + (area.width.saturating_sub(width)) / 2,
1124 y: area.y + (area.height.saturating_sub(height)) / 2,
1125 width,
1126 height,
1127 };
1128 f.render_widget(Clear, popup);
1129 let block = Block::default()
1130 .title(Line::from(Span::styled(
1131 " ⌂ Workspace ",
1132 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1133 )))
1134 .borders(Borders::ALL)
1135 .border_type(BorderType::Rounded)
1136 .border_style(Style::default().fg(p.key))
1137 .padding(Padding::horizontal(1));
1138 let items: Vec<ListItem> = app
1139 .profiles
1140 .iter()
1141 .map(|name| {
1142 let current = app.profile.as_deref() == Some(name.as_str());
1143 let marker = if current { "● " } else { " " };
1144 ListItem::new(Line::from(vec![
1145 Span::styled(marker, Style::default().fg(p.ok)),
1146 Span::styled(name.as_str(), Style::default().fg(p.text)),
1147 ]))
1148 })
1149 .collect();
1150 let list = List::new(items)
1151 .block(block)
1152 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1153 let mut state = ListState::default().with_selected(Some(selected));
1154 f.render_stateful_widget(list, popup, &mut state);
1155}
1156
1157fn bucket_color(bucket: &str, p: &Palette) -> Color {
1158 match bucket {
1159 "Jobs" => p.jobs,
1160 "SQL" => p.warehouses,
1161 "All-Purpose" => p.clusters,
1162 "DLT" => p.pipelines,
1163 _ => p.dim,
1164 }
1165}
1166
1167fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1168 let cv = app.cost.as_ref().unwrap();
1169 let scope = match &cv.data {
1170 Some(Ok(d)) if d.scoped => " · this workspace",
1171 Some(Ok(_)) => " · all workspaces",
1172 _ => "",
1173 };
1174 let title = Line::from(vec![
1175 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1176 Span::styled(
1177 format!("Usage · last 14 days{scope} "),
1178 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1179 ),
1180 Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1181 ]);
1182 let block = Block::default()
1183 .title(title)
1184 .borders(Borders::ALL)
1185 .border_type(BorderType::Rounded)
1186 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1187 .padding(Padding::new(1, 1, 1, 1));
1188
1189 match &cv.data {
1190 None => {
1191 let par = Paragraph::new(format!(
1192 "{} querying system.billing.usage — the warehouse may need to start…",
1193 app.spinner()
1194 ))
1195 .style(Style::default().fg(p.warn))
1196 .block(block);
1197 f.render_widget(par, area);
1198 }
1199 Some(Err(e)) => {
1200 let par = Paragraph::new(format!(
1201 "✗ {e}\n\nsystem tables need to be enabled and readable \
1202 (grants on the `system` catalog)"
1203 ))
1204 .style(Style::default().fg(p.err))
1205 .wrap(Wrap { trim: false })
1206 .block(block);
1207 f.render_widget(par, area);
1208 }
1209 Some(Ok(data)) if data.days.is_empty() => {
1210 let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1211 .style(Style::default().fg(p.dim))
1212 .block(block);
1213 f.render_widget(par, area);
1214 }
1215 Some(Ok(data)) => {
1216 let mut lines: Vec<Line> = Vec::new();
1217
1218 let mut legend = vec![Span::raw("")];
1220 for (bucket, total, usd) in &data.buckets {
1221 legend.push(Span::styled(
1222 "■ ",
1223 Style::default().fg(bucket_color(bucket, p)),
1224 ));
1225 let amount = if data.priced {
1226 format!("{bucket} {total:.1} (${usd:.0}) ")
1227 } else {
1228 format!("{bucket} {total:.1} ")
1229 };
1230 legend.push(Span::styled(amount, Style::default().fg(p.text)));
1231 }
1232 let sigma = if data.priced {
1233 format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1234 } else {
1235 format!("Σ {:.1} DBU", data.total)
1236 };
1237 legend.push(Span::styled(
1238 sigma,
1239 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1240 ));
1241 lines.push(Line::from(legend));
1242 if data.priced {
1243 lines.push(Line::from(Span::styled(
1244 " list prices before discounts",
1245 Style::default().fg(p.dim),
1246 )));
1247 }
1248 if !data.scoped {
1249 lines.push(Line::from(Span::styled(
1250 " couldn't resolve this workspace's id — showing the whole account",
1251 Style::default().fg(p.warn),
1252 )));
1253 }
1254 lines.push(Line::default());
1255
1256 let max_day = data
1257 .days
1258 .iter()
1259 .map(|d| d.total)
1260 .fold(0.0_f64, f64::max)
1261 .max(f64::EPSILON);
1262 let inner_w = area.width.saturating_sub(4) as usize;
1263 let bar_w = inner_w.saturating_sub(22).max(10);
1264
1265 for day in &data.days {
1266 let mut spans = vec![Span::styled(
1267 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1269 Style::default().fg(p.dim),
1270 )];
1271 for (bucket, value) in &day.by_bucket {
1272 let chars = ((value / max_day) * bar_w as f64)
1273 .round()
1274 .max(if *value > 0.0 { 1.0 } else { 0.0 })
1275 as usize;
1276 spans.push(Span::styled(
1277 "█".repeat(chars),
1278 Style::default().fg(bucket_color(bucket, p)),
1279 ));
1280 }
1281 let day_label = if data.priced {
1282 format!(" {:.1} · ${:.2}", day.total, day.total_usd)
1283 } else {
1284 format!(" {:.1}", day.total)
1285 };
1286 spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1287 lines.push(Line::from(spans));
1288 }
1289
1290 if !data.spenders.is_empty() {
1291 lines.push(Line::default());
1292 lines.push(Line::from(Span::styled(
1293 "TOP SPENDERS · 14 days",
1294 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1295 )));
1296 let name_w = data
1297 .spenders
1298 .iter()
1299 .map(|s| {
1300 app.resource_name(&s.kind, &s.id)
1301 .unwrap_or_else(|| s.id.clone())
1302 .chars()
1303 .count()
1304 })
1305 .max()
1306 .unwrap_or(0)
1307 .max(8);
1308 for (i, s) in data.spenders.iter().enumerate() {
1309 let name = app
1310 .resource_name(&s.kind, &s.id)
1311 .unwrap_or_else(|| s.id.clone());
1312 let kind_color = match s.kind.as_str() {
1313 "job" => p.jobs,
1314 "cluster" => p.clusters,
1315 "warehouse" => p.warehouses,
1316 _ => p.dim,
1317 };
1318 let amount = if data.priced {
1319 format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1320 } else {
1321 format!("{:>8.1} DBU", s.dbus)
1322 };
1323 lines.push(Line::from(vec![
1324 Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1325 Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1326 Span::styled(
1327 format!("{:<width$}", name, width = name_w + 2),
1328 Style::default().fg(p.text),
1329 ),
1330 Span::styled(amount, Style::default().fg(p.text)),
1331 ]));
1332 }
1333 }
1334
1335 let par = Paragraph::new(lines).block(block);
1336 f.render_widget(par, area);
1337 }
1338 }
1339}
1340
1341fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1342 let console = app.sql.as_ref().unwrap();
1343 let parts = Layout::default()
1344 .direction(Direction::Vertical)
1345 .constraints([Constraint::Length(3), Constraint::Min(0)])
1346 .split(area);
1347
1348 let via = if console.warehouse.is_empty() {
1349 String::new()
1350 } else {
1351 format!("via {} ", console.warehouse)
1352 };
1353 let prompt_block = Block::default()
1354 .title(Line::from(vec![
1355 Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1356 Span::styled(
1357 "SQL Console ",
1358 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1359 ),
1360 Span::styled(via, Style::default().fg(p.dim)),
1361 ]))
1362 .borders(Borders::ALL)
1363 .border_type(BorderType::Thick)
1364 .border_style(
1365 Style::default()
1366 .fg(p.warehouses)
1367 .add_modifier(Modifier::BOLD),
1368 )
1369 .padding(Padding::horizontal(1));
1370 if let Some((query, _)) = &app.hist_search {
1371 let matched = app
1373 .hist_search_current()
1374 .map(|s| s.replace('\n', " "))
1375 .unwrap_or_default();
1376 let mut spans = vec![
1377 Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1378 Span::styled(
1379 query.as_str(),
1380 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1381 ),
1382 Span::styled("`: ", Style::default().fg(p.dim)),
1383 ];
1384 spans.extend(sql_spans(&matched, p));
1385 let prompt = Paragraph::new(Line::from(spans)).block(prompt_block);
1386 f.render_widget(prompt, parts[0]);
1387 } else {
1388 let caret_byte = console
1392 .input
1393 .char_indices()
1394 .nth(console.cursor)
1395 .map(|(i, _)| i)
1396 .unwrap_or(console.input.len());
1397 let (before, after) = console.input.split_at(caret_byte);
1398 let inner_w = parts[0].width.saturating_sub(4) as usize; let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1400 let mut spans = vec![Span::styled("❯ ", Style::default().fg(p.key))];
1403 spans.extend(sql_spans(&before.replace('\n', " "), p));
1404 spans.push(Span::styled("▏", Style::default().fg(p.warn)));
1405 spans.extend(sql_spans(&after.replace('\n', " "), p));
1406 let prompt = Paragraph::new(Line::from(spans))
1407 .scroll((0, hscroll))
1408 .block(prompt_block);
1409 f.render_widget(prompt, parts[0]);
1410 }
1411
1412 let row_info = match &console.data {
1413 Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1414 _ => String::new(),
1415 };
1416 let mut results_title = vec![
1417 Span::styled(" Results ", Style::default().fg(p.text)),
1418 Span::styled(row_info, Style::default().fg(p.dim)),
1419 ];
1420 let avail = parts[1].width.saturating_sub(4) as usize;
1422 let sliced = match &console.data {
1423 Some(Ok(t)) if !t.headers.is_empty() => {
1424 let cols: Vec<usize> = (0..t.headers.len()).collect();
1425 let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
1426 if shown.len() < cols.len() {
1427 let first = console.col.min(cols.len() - 1) + 1;
1428 results_title.push(Span::styled(
1429 format!(
1430 "· cols {}–{} of {} (⇧←/→) ",
1431 first,
1432 first + shown.len() - 1,
1433 cols.len()
1434 ),
1435 Style::default().fg(p.dim),
1436 ));
1437 }
1438 Some((shown, constraints))
1439 }
1440 _ => None,
1441 };
1442 let results_block = Block::default()
1443 .title(Line::from(results_title))
1444 .borders(Borders::ALL)
1445 .border_type(BorderType::Rounded)
1446 .border_style(Style::default().fg(p.border))
1447 .padding(Padding::horizontal(1));
1448
1449 if console.running {
1450 let par = Paragraph::new(format!(
1451 "{} running on {} — the warehouse may need a moment to start…",
1452 app.spinner(),
1453 console.warehouse
1454 ))
1455 .style(Style::default().fg(p.warn))
1456 .block(results_block);
1457 f.render_widget(par, parts[1]);
1458 return;
1459 }
1460 match &console.data {
1461 None => {
1462 let par = Paragraph::new(
1463 "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1464 )
1465 .style(Style::default().fg(p.dim))
1466 .block(results_block);
1467 f.render_widget(par, parts[1]);
1468 }
1469 Some(Err(e)) => {
1470 let par = Paragraph::new(format!("✗ {e}"))
1471 .style(Style::default().fg(p.err))
1472 .wrap(Wrap { trim: false })
1473 .block(results_block);
1474 f.render_widget(par, parts[1]);
1475 }
1476 Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1477 let par = Paragraph::new("✓ statement succeeded — no result set")
1478 .style(Style::default().fg(p.ok))
1479 .block(results_block);
1480 f.render_widget(par, parts[1]);
1481 }
1482 Some(Ok(data)) => {
1483 let (shown, constraints) = sliced.unwrap_or_default();
1484 let header_cells: Vec<Cell> = shown
1485 .iter()
1486 .map(|&i| {
1487 Cell::from(data.headers[i].as_str()).style(
1488 Style::default()
1489 .fg(p.warehouses)
1490 .add_modifier(Modifier::BOLD),
1491 )
1492 })
1493 .collect();
1494 let header = Row::new(header_cells);
1495 let rows: Vec<Row> = data
1496 .rows
1497 .iter()
1498 .skip(console.scroll)
1499 .map(|r| {
1500 Row::new(
1501 shown
1502 .iter()
1503 .map(|&i| {
1504 Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
1505 .style(Style::default().fg(p.text))
1506 })
1507 .collect::<Vec<_>>(),
1508 )
1509 })
1510 .collect();
1511 let table = Table::new(rows, constraints)
1512 .header(header)
1513 .column_spacing(1)
1514 .block(results_block);
1515 f.render_widget(table, parts[1]);
1516 scrollbar(
1518 f,
1519 parts[1],
1520 data.rows.len(),
1521 parts[1].height.saturating_sub(3) as usize,
1522 console.scroll,
1523 p,
1524 );
1525 }
1526 }
1527}
1528
1529fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1531 let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
1532 return;
1533 };
1534 let inner_w = area.width.saturating_sub(4) as usize;
1536 let hscroll = (console.cursor + 3).saturating_sub(inner_w);
1537 let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
1538
1539 let lines: Vec<Line> = if comp.loading {
1540 vec![Line::from(Span::styled(
1541 format!("{} fetching names…", app.spinner()),
1542 Style::default().fg(p.warn),
1543 ))]
1544 } else {
1545 let visible = 8.min(comp.items.len());
1547 let skip = comp.index.saturating_sub(visible.saturating_sub(1));
1548 comp.items
1549 .iter()
1550 .enumerate()
1551 .skip(skip)
1552 .take(visible)
1553 .map(|(i, item)| {
1554 let style = if i == comp.index {
1555 Style::default()
1556 .fg(p.key)
1557 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1558 } else {
1559 Style::default().fg(p.text)
1560 };
1561 Line::from(Span::styled(format!(" {item} "), style))
1562 })
1563 .collect()
1564 };
1565 let widest = comp
1566 .items
1567 .iter()
1568 .map(|i| i.chars().count())
1569 .max()
1570 .unwrap_or(16);
1571 let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
1572 let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
1573 let popup = Rect {
1574 x: anchor.min(area.x + area.width.saturating_sub(width)),
1575 y: area.y + 3,
1576 width,
1577 height,
1578 };
1579 let title = if comp.loading {
1580 String::new()
1581 } else {
1582 format!(" {}/{} ", comp.index + 1, comp.items.len())
1583 };
1584 let block = Block::default()
1585 .title(Span::styled(title, Style::default().fg(p.dim)))
1586 .borders(Borders::ALL)
1587 .border_type(BorderType::Rounded)
1588 .border_style(Style::default().fg(p.key));
1589 f.render_widget(Clear, popup);
1590 f.render_widget(Paragraph::new(lines).block(block), popup);
1591}
1592
1593fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
1596 cols.iter()
1597 .map(|&i| {
1598 let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
1599 for row in data.rows.iter().take(50) {
1600 if let Some(c) = row.get(i) {
1601 w = w.max(c.chars().count());
1602 }
1603 }
1604 w.clamp(4, 36) as u16
1605 })
1606 .collect()
1607}
1608
1609fn grid_slice(
1614 data: &TableData,
1615 cols: &[usize],
1616 col: usize,
1617 avail: usize,
1618) -> (Vec<usize>, Vec<Constraint>) {
1619 let widths = grid_widths(data, cols);
1620 let col = col.min(cols.len().saturating_sub(1));
1621 let mut take = 0usize;
1622 let mut used = 0usize;
1623 for w in widths.iter().skip(col) {
1624 let next = used + *w as usize + if take > 0 { 1 } else { 0 };
1625 if take > 0 && next > avail {
1626 break;
1627 }
1628 used = next;
1629 take += 1;
1630 }
1631 let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
1632 let constraints: Vec<Constraint> = widths
1633 .iter()
1634 .skip(col)
1635 .take(take)
1636 .map(|&w| Constraint::Length(w))
1637 .collect();
1638 (shown, constraints)
1639}
1640
1641fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1642 let pv = app.preview.as_ref().unwrap();
1643 let acc = p.catalog;
1644 let mut title_spans: Vec<Span<'static>> = vec![
1645 Span::styled(" ◫ ", Style::default().fg(acc)),
1646 Span::styled(
1647 format!("{} · preview ", pv.name),
1648 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1649 ),
1650 Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1651 ];
1652 let make_block = |spans: Vec<Span<'static>>| {
1653 Block::default()
1654 .title(Line::from(spans))
1655 .borders(Borders::ALL)
1656 .border_type(BorderType::Rounded)
1657 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1658 .padding(Padding::horizontal(1))
1659 };
1660
1661 match &pv.data {
1662 None => {
1663 let par = Paragraph::new(format!(
1664 "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1665 app.spinner()
1666 ))
1667 .style(Style::default().fg(p.warn))
1668 .block(make_block(title_spans));
1669 f.render_widget(par, area);
1670 }
1671 Some(Err(e)) => {
1672 let text = format!(
1673 "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1674 pv.warehouse,
1675 pv.warehouse_id,
1676 app.profile.as_deref().unwrap_or("default"),
1677 app.host.as_deref().unwrap_or("unknown"),
1678 );
1679 let par = Paragraph::new(text)
1680 .style(Style::default().fg(p.err))
1681 .wrap(Wrap { trim: false })
1682 .block(make_block(title_spans));
1683 f.render_widget(par, area);
1684 }
1685 Some(Ok(data)) => {
1686 title_spans.push(Span::styled(
1687 format!("· {} rows ", data.rows.len()),
1688 Style::default().fg(p.dim),
1689 ));
1690 let cols = pv.visible_cols();
1691 if !pv.filter.is_empty() {
1692 title_spans.push(Span::styled(
1693 format!("· cols /{} ", pv.filter),
1694 Style::default().fg(p.warn),
1695 ));
1696 }
1697 if cols.is_empty() {
1698 let par = Paragraph::new(format!(
1699 "no columns match /{} — backspace to widen, esc to clear",
1700 pv.filter
1701 ))
1702 .style(Style::default().fg(p.warn))
1703 .block(make_block(title_spans));
1704 f.render_widget(par, area);
1705 return;
1706 }
1707 if pv.record {
1708 let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
1711 title_spans.push(Span::styled(
1712 format!("· row {}/{} ", row_n + 1, data.rows.len()),
1713 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1714 ));
1715 let name_w = cols
1716 .iter()
1717 .map(|&i| data.headers[i].chars().count())
1718 .max()
1719 .unwrap_or(0)
1720 .min(32);
1721 let empty: Vec<String> = Vec::new();
1722 let row = data.rows.get(row_n).unwrap_or(&empty);
1723 let lines: Vec<Line> = cols
1724 .iter()
1725 .map(|&i| {
1726 let value = row.get(i).map(String::as_str).unwrap_or("");
1727 let vstyle = if value == "␀" {
1728 Style::default().fg(p.dim)
1729 } else {
1730 Style::default().fg(p.text)
1731 };
1732 Line::from(vec![
1733 Span::styled(
1734 format!("{:<name_w$} ", data.headers[i]),
1735 Style::default().fg(acc),
1736 ),
1737 Span::styled(value.to_string(), vstyle),
1738 ])
1739 })
1740 .collect();
1741 let total = lines.len();
1742 let par = Paragraph::new(lines)
1743 .scroll((pv.rscroll, 0))
1744 .block(make_block(title_spans));
1745 f.render_widget(par, area);
1746 scrollbar(
1747 f,
1748 area,
1749 total,
1750 area.height.saturating_sub(2) as usize,
1751 pv.rscroll as usize,
1752 p,
1753 );
1754 return;
1755 }
1756 let avail = area.width.saturating_sub(4) as usize; let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
1758 if shown.len() < cols.len() {
1759 let first = pv.col.min(cols.len() - 1) + 1;
1760 title_spans.push(Span::styled(
1761 format!(
1762 "· cols {}–{} of {} ",
1763 first,
1764 first + shown.len() - 1,
1765 cols.len()
1766 ),
1767 Style::default().fg(p.dim),
1768 ));
1769 }
1770 let header = Row::new(
1771 shown
1772 .iter()
1773 .map(|&i| {
1774 Cell::from(data.headers[i].clone())
1775 .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1776 })
1777 .collect::<Vec<_>>(),
1778 );
1779 let rows: Vec<Row> = data
1780 .rows
1781 .iter()
1782 .skip(pv.scroll)
1783 .map(|r| {
1784 Row::new(
1785 shown
1786 .iter()
1787 .map(|&i| {
1788 Cell::from(r.get(i).cloned().unwrap_or_default())
1789 .style(Style::default().fg(p.text))
1790 })
1791 .collect::<Vec<_>>(),
1792 )
1793 })
1794 .collect();
1795 let table = Table::new(rows, constraints)
1796 .header(header)
1797 .column_spacing(1)
1798 .block(make_block(title_spans));
1799 f.render_widget(table, area);
1800 scrollbar(
1802 f,
1803 area,
1804 data.rows.len(),
1805 area.height.saturating_sub(3) as usize,
1806 pv.scroll,
1807 p,
1808 );
1809 }
1810 }
1811}
1812
1813fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1814 let d = app.detail.as_ref().unwrap();
1815 let acc = accent(d.panel, p);
1816 let title = Line::from(vec![
1817 Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1818 Span::styled(
1819 format!("{} · {} ", d.panel.title(), d.name),
1820 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1821 ),
1822 ]);
1823 let block = Block::default()
1824 .title(title)
1825 .borders(Borders::ALL)
1826 .border_type(BorderType::Rounded)
1827 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1828 .padding(Padding::horizontal(1));
1829
1830 let Some(data) = &d.data else {
1831 let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1832 .style(Style::default().fg(p.warn))
1833 .block(block);
1834 f.render_widget(par, area);
1835 return;
1836 };
1837
1838 if d.show_raw || data.summary.is_empty() {
1840 let par = Paragraph::new(data.raw.as_str())
1841 .style(Style::default().fg(p.text))
1842 .wrap(Wrap { trim: false })
1843 .scroll((d.scroll, 0))
1844 .block(block);
1845 f.render_widget(par, area);
1846 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
1847 scrollbar(
1848 f,
1849 area,
1850 total,
1851 area.height.saturating_sub(2) as usize,
1852 d.scroll as usize,
1853 p,
1854 );
1855 return;
1856 }
1857
1858 let mut lines: Vec<Line> = data
1859 .summary
1860 .iter()
1861 .map(|(k, v)| {
1862 Line::from(vec![
1863 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1864 Span::styled(v.as_str(), Style::default().fg(p.text)),
1865 ])
1866 })
1867 .collect();
1868 if !data.activity.is_empty() {
1869 lines.push(Line::default());
1870 lines.push(Line::from(Span::styled(
1871 d.section,
1872 Style::default().fg(acc).add_modifier(Modifier::BOLD),
1873 )));
1874 for (status, text) in &data.activity {
1875 let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
1877 if treeish {
1878 lines.push(Line::from(Span::styled(
1879 text.as_str(),
1880 Style::default().fg(status_color(status, p)),
1881 )));
1882 } else {
1883 lines.push(Line::from(vec![
1884 Span::styled("● ", Style::default().fg(status_color(status, p))),
1885 Span::styled(text.as_str(), Style::default().fg(p.text)),
1886 ]));
1887 }
1888 }
1889 }
1890 let total = lines.len();
1891 let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1892 f.render_widget(par, area);
1893 scrollbar(
1894 f,
1895 area,
1896 total,
1897 area.height.saturating_sub(2) as usize,
1898 d.scroll as usize,
1899 p,
1900 );
1901}
1902
1903fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1904 let rv = app.run_view.as_ref().unwrap();
1905 let acc = accent(rv.panel, p);
1906 let noun = if rv.panel == Panel::Jobs {
1907 "run"
1908 } else {
1909 "update"
1910 };
1911 let mut title_spans = vec![
1912 Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1913 Span::styled(
1914 format!("{} · {noun} ", rv.owner_name),
1915 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1916 ),
1917 ];
1918 if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1919 title_spans.push(Span::styled(
1920 format!("{run_id} "),
1921 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1922 ));
1923 title_spans.push(Span::styled(
1924 format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1925 Style::default().fg(p.dim),
1926 ));
1927 }
1928 if rv.live {
1929 title_spans.push(Span::styled(
1930 format!("{} live ", app.spinner()),
1931 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1932 ));
1933 }
1934 if rv.show_output {
1935 title_spans.push(Span::styled(
1936 if rv.live {
1937 "· output (tailing) "
1938 } else {
1939 "· output "
1940 },
1941 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1942 ));
1943 }
1944 if rv.show_timeline {
1945 title_spans.push(Span::styled(
1946 "· timeline ",
1947 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1948 ));
1949 }
1950 if rv.show_dag {
1951 title_spans.push(Span::styled(
1952 "· dag ",
1953 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1954 ));
1955 }
1956 if rv.show_grid {
1957 title_spans.push(Span::styled(
1958 "· history grid ",
1959 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1960 ));
1961 }
1962 let block = Block::default()
1963 .title(Line::from(title_spans))
1964 .borders(Borders::ALL)
1965 .border_type(BorderType::Rounded)
1966 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1967 .padding(Padding::horizontal(1));
1968
1969 if rv.show_output {
1971 let par = match &rv.output {
1972 Some(text) => {
1973 let lines: Vec<Line> = text.lines().map(|l| output_line(l, p)).collect();
1974 Paragraph::new(lines)
1975 .wrap(Wrap { trim: false })
1976 .scroll((rv.scroll, 0))
1977 .block(block)
1978 }
1979 None => Paragraph::new(format!(
1980 "{} fetching task output — one call per task…",
1981 app.spinner()
1982 ))
1983 .style(Style::default().fg(p.warn))
1984 .block(block),
1985 };
1986 f.render_widget(par, area);
1987 if let Some(text) = &rv.output {
1988 let total = wrapped_height(text, area.width.saturating_sub(4) as usize);
1989 scrollbar(
1990 f,
1991 area,
1992 total,
1993 area.height.saturating_sub(2) as usize,
1994 rv.scroll as usize,
1995 p,
1996 );
1997 }
1998 return;
1999 }
2000
2001 if rv.show_grid {
2002 draw_run_grid(f, area, rv, app, block, p);
2003 return;
2004 }
2005
2006 let Some(data) = &rv.data else {
2007 let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
2008 .style(Style::default().fg(p.warn))
2009 .block(block);
2010 f.render_widget(par, area);
2011 return;
2012 };
2013
2014 if rv.show_timeline {
2015 draw_run_timeline(f, area, rv.scroll, data, block, p);
2016 return;
2017 }
2018
2019 if rv.show_dag {
2020 draw_run_dag(f, area, rv.scroll, data, block, p);
2021 return;
2022 }
2023
2024 if rv.show_raw || data.summary.is_empty() {
2025 let par = Paragraph::new(data.raw.as_str())
2026 .style(Style::default().fg(p.text))
2027 .wrap(Wrap { trim: false })
2028 .scroll((rv.scroll, 0))
2029 .block(block);
2030 f.render_widget(par, area);
2031 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2032 scrollbar(
2033 f,
2034 area,
2035 total,
2036 area.height.saturating_sub(2) as usize,
2037 rv.scroll as usize,
2038 p,
2039 );
2040 return;
2041 }
2042
2043 let mut lines: Vec<Line> = data
2044 .summary
2045 .iter()
2046 .map(|(k, v)| {
2047 Line::from(vec![
2048 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2049 Span::styled(v.as_str(), Style::default().fg(p.text)),
2050 ])
2051 })
2052 .collect();
2053 if !data.activity.is_empty() {
2054 lines.push(Line::default());
2055 lines.push(Line::from(Span::styled(
2056 if rv.panel == Panel::Jobs {
2057 "Tasks"
2058 } else {
2059 "Event log"
2060 },
2061 Style::default().fg(acc).add_modifier(Modifier::BOLD),
2062 )));
2063 for (status, text) in &data.activity {
2064 if text.starts_with(" ↳") {
2066 lines.push(Line::from(Span::styled(
2067 text.as_str(),
2068 Style::default().fg(p.err),
2069 )));
2070 } else {
2071 lines.push(Line::from(vec![
2072 Span::styled("● ", Style::default().fg(status_color(status, p))),
2073 Span::styled(text.as_str(), Style::default().fg(p.text)),
2074 ]));
2075 }
2076 }
2077 }
2078 let total = lines.len();
2079 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2080 f.render_widget(par, area);
2081 scrollbar(
2082 f,
2083 area,
2084 total,
2085 area.height.saturating_sub(2) as usize,
2086 rv.scroll as usize,
2087 p,
2088 );
2089}
2090
2091fn draw_run_timeline(
2094 f: &mut Frame,
2095 area: Rect,
2096 scroll: u16,
2097 data: &DetailData,
2098 block: Block,
2099 p: &Palette,
2100) {
2101 let tasks = crate::fetchers::runs::timeline(&data.raw);
2102 let now = std::time::SystemTime::now()
2103 .duration_since(std::time::UNIX_EPOCH)
2104 .map(|d| d.as_millis() as u64)
2105 .unwrap_or(0);
2106 let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
2107 let Some(t0) = t0 else {
2108 let par = Paragraph::new(
2109 "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
2110 )
2111 .style(Style::default().fg(p.dim))
2112 .wrap(Wrap { trim: false })
2113 .block(block);
2114 f.render_widget(par, area);
2115 return;
2116 };
2117 let t1 = tasks
2118 .iter()
2119 .filter(|t| t.start > 0)
2120 .map(|t| t.end.unwrap_or(now).max(t.start))
2121 .max()
2122 .unwrap_or(t0);
2123 let span = (t1 - t0).max(1);
2124
2125 let name_w = tasks
2126 .iter()
2127 .map(|t| t.key.chars().count())
2128 .max()
2129 .unwrap_or(4)
2130 .clamp(4, 24);
2131 let inner_w = area.width.saturating_sub(4) as usize;
2132 let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
2134
2135 let mut lines: Vec<Line> = vec![
2136 Line::from(Span::styled(
2137 format!(
2138 "◷ {} task{} · span {}",
2139 tasks.len(),
2140 if tasks.len() == 1 { "" } else { "s" },
2141 crate::shape::fmt_duration_ms(span)
2142 ),
2143 Style::default().fg(p.dim),
2144 )),
2145 Line::default(),
2146 ];
2147 for t in &tasks {
2148 let mut key: String = t.key.chars().take(name_w).collect();
2149 if key.len() < t.key.len() {
2150 key.pop();
2151 key.push('…');
2152 }
2153 let name = Span::styled(format!("{key:<name_w$} "), Style::default().fg(p.text));
2154 if t.start == 0 {
2155 lines.push(Line::from(vec![
2156 name,
2157 Span::styled(
2158 format!("– {}", t.status.label().to_lowercase()),
2159 Style::default().fg(p.dim),
2160 ),
2161 ]));
2162 continue;
2163 }
2164 let end = t.end.unwrap_or(now).max(t.start);
2165 let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
2166 let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
2167 let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
2168 let dur = format!(
2169 " {}{}",
2170 crate::shape::fmt_duration_ms(end - t.start),
2171 if t.end.is_none() { "…" } else { "" }
2172 );
2173 lines.push(Line::from(vec![
2174 name,
2175 Span::styled(" ".repeat(off), Style::default()),
2176 Span::styled(
2177 "█".repeat(len),
2178 Style::default().fg(status_color(&t.status, p)),
2179 ),
2180 Span::styled(dur, Style::default().fg(p.dim)),
2181 ]));
2182 }
2183 let total = lines.len();
2184 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2185 f.render_widget(par, area);
2186 scrollbar(
2187 f,
2188 area,
2189 total,
2190 area.height.saturating_sub(2) as usize,
2191 scroll as usize,
2192 p,
2193 );
2194}
2195
2196fn draw_run_dag(
2199 f: &mut Frame,
2200 area: Rect,
2201 scroll: u16,
2202 data: &DetailData,
2203 block: Block,
2204 p: &Palette,
2205) {
2206 let rows = crate::fetchers::runs::dag(&data.raw);
2207 if rows.is_empty() {
2208 let par = Paragraph::new(
2209 "no task graph recorded — legacy single-task runs carry none (J shows the raw JSON)",
2210 )
2211 .style(Style::default().fg(p.dim))
2212 .wrap(Wrap { trim: false })
2213 .block(block);
2214 f.render_widget(par, area);
2215 return;
2216 }
2217 let mut lines: Vec<Line> = vec![
2218 Line::from(Span::styled(
2219 format!(
2220 "⧉ {} task{} · order of execution, top to bottom",
2221 rows.len(),
2222 if rows.len() == 1 { "" } else { "s" },
2223 ),
2224 Style::default().fg(p.dim),
2225 )),
2226 Line::default(),
2227 ];
2228 for r in &rows {
2229 let mut spans = vec![
2230 Span::styled(r.prefix.clone(), Style::default().fg(p.dim)),
2231 Span::styled("● ", Style::default().fg(status_color(&r.status, p))),
2232 Span::styled(r.key.clone(), Style::default().fg(p.text)),
2233 Span::styled(
2234 format!(" {}", r.status.label().to_lowercase()),
2235 Style::default().fg(status_color(&r.status, p)),
2236 ),
2237 ];
2238 if let Some(d) = r.duration {
2239 spans.push(Span::styled(
2240 format!(" · {}", crate::shape::fmt_duration_ms(d)),
2241 Style::default().fg(p.dim),
2242 ));
2243 }
2244 if !r.also_after.is_empty() {
2245 spans.push(Span::styled(
2246 format!(" (also after {})", r.also_after.join(", ")),
2247 Style::default().fg(p.dim),
2248 ));
2249 }
2250 lines.push(Line::from(spans));
2251 }
2252 let total = lines.len();
2253 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2254 f.render_widget(par, area);
2255 scrollbar(
2256 f,
2257 area,
2258 total,
2259 area.height.saturating_sub(2) as usize,
2260 scroll as usize,
2261 p,
2262 );
2263}
2264
2265fn spark(vals: &[u64]) -> String {
2268 const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2269 let (min, max) = vals
2270 .iter()
2271 .fold((u64::MAX, 0), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
2272 vals.iter()
2273 .map(|v| {
2274 if max == min {
2275 '▄'
2276 } else {
2277 BARS[((v - min) * 7 / (max - min)) as usize]
2278 }
2279 })
2280 .collect()
2281}
2282
2283fn draw_run_grid(
2289 f: &mut Frame,
2290 area: Rect,
2291 rv: &crate::app::RunView,
2292 app: &App,
2293 block: Block,
2294 p: &Palette,
2295) {
2296 let g = match &rv.grid {
2297 None => {
2298 let par = Paragraph::new(format!("{} loading run history…", app.spinner()))
2299 .style(Style::default().fg(p.warn))
2300 .block(block);
2301 f.render_widget(par, area);
2302 return;
2303 }
2304 Some(Err(e)) => {
2305 let par = Paragraph::new(format!("✗ {e}"))
2306 .style(Style::default().fg(p.err))
2307 .wrap(Wrap { trim: false })
2308 .block(block);
2309 f.render_widget(par, area);
2310 return;
2311 }
2312 Some(Ok(g)) => g,
2313 };
2314 if g.tasks.is_empty() {
2315 let par = Paragraph::new(
2316 "no per-task history recorded — legacy single-task runs carry none (J shows the raw JSON)",
2317 )
2318 .style(Style::default().fg(p.dim))
2319 .wrap(Wrap { trim: false })
2320 .block(block);
2321 f.render_widget(par, area);
2322 return;
2323 }
2324
2325 let name_w = g
2326 .tasks
2327 .iter()
2328 .map(|t| t.key.chars().count())
2329 .max()
2330 .unwrap_or(4)
2331 .clamp(4, 24);
2332 let inner_w = area.width.saturating_sub(4) as usize;
2333 let trend_w = 28;
2335 let cols_fit = (inner_w.saturating_sub(name_w + 2 + trend_w) / 2).max(1);
2337 let skip = g.runs.len().saturating_sub(cols_fit);
2338 let cols = &g.runs[skip..];
2339
2340 let mut lines: Vec<Line> = vec![
2341 Line::from(Span::styled(
2342 format!(
2343 "⊞ {} task{} × last {} run{} · oldest → newest",
2344 g.tasks.len(),
2345 if g.tasks.len() == 1 { "" } else { "s" },
2346 cols.len(),
2347 if cols.len() == 1 { "" } else { "s" },
2348 ),
2349 Style::default().fg(p.dim),
2350 )),
2351 Line::default(),
2352 ];
2353
2354 let sel = rv.runs.get(rv.idx).map(|(id, _, _)| id.as_str());
2356 if let Some(ci) = sel.and_then(|id| cols.iter().position(|r| r.run_id == id)) {
2357 let acc = accent(Panel::Jobs, p);
2358 lines.push(Line::from(vec![
2359 Span::raw(" ".repeat(name_w + 2 + 2 * ci)),
2360 Span::styled("▾", Style::default().fg(acc).add_modifier(Modifier::BOLD)),
2361 ]));
2362 }
2363
2364 let mut run_row = vec![Span::styled(
2365 format!("{:<name_w$} ", "run"),
2366 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
2367 )];
2368 for r in cols {
2369 run_row.push(Span::styled(
2370 format!("{} ", history_glyph(&r.status)),
2371 Style::default().fg(status_color(&r.status, p)),
2372 ));
2373 }
2374 if let Some(newest) = cols.last() {
2375 run_row.push(Span::styled(
2376 format!(" newest {}", newest.age),
2377 Style::default().fg(p.dim),
2378 ));
2379 }
2380 lines.push(Line::from(run_row));
2381 lines.push(Line::default());
2382
2383 for t in &g.tasks {
2384 let mut key: String = t.key.chars().take(name_w).collect();
2385 if key.chars().count() < t.key.chars().count() {
2386 key.pop();
2387 key.push('…');
2388 }
2389 let mut spans = vec![Span::styled(
2390 format!("{key:<name_w$} "),
2391 Style::default().fg(p.text),
2392 )];
2393 for cell in &t.cells[skip..] {
2394 spans.push(match cell {
2395 Some(s) => Span::styled(
2396 format!("{} ", history_glyph(s)),
2397 Style::default().fg(status_color(s, p)),
2398 ),
2399 None => Span::styled("· ", Style::default().fg(p.dim)),
2400 });
2401 }
2402 let ds = t.success_durations();
2403 if let Some(latest) = ds.last() {
2404 let window = &ds[ds.len().saturating_sub(10)..];
2405 spans.push(Span::styled(
2406 format!(" {}", spark(window)),
2407 Style::default().fg(p.dim),
2408 ));
2409 spans.push(Span::styled(
2410 format!(" {}", crate::shape::fmt_duration_ms(*latest)),
2411 Style::default().fg(p.text),
2412 ));
2413 }
2414 if let Some((latest, median)) = t.slowdown() {
2415 let ratio = latest as f64 / median as f64;
2416 let color = if ratio >= 2.0 { p.err } else { p.warn };
2417 spans.push(Span::styled(
2418 format!(" ▲{ratio:.1}×"),
2419 Style::default().fg(color).add_modifier(Modifier::BOLD),
2420 ));
2421 }
2422 lines.push(Line::from(spans));
2423 }
2424
2425 let total = lines.len();
2426 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2427 f.render_widget(par, area);
2428 scrollbar(
2429 f,
2430 area,
2431 total,
2432 area.height.saturating_sub(2) as usize,
2433 rv.scroll as usize,
2434 p,
2435 );
2436}
2437
2438fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2439 let block = Block::default()
2440 .borders(Borders::ALL)
2441 .border_type(BorderType::Rounded)
2442 .border_style(Style::default().fg(p.border));
2443 let inner = block.inner(area);
2444 f.render_widget(block, area);
2445
2446 let (from, to) = (p.grad_from, p.grad_to);
2450 let grad = |t: f32| {
2451 Color::Rgb(
2452 (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
2453 (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
2454 (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
2455 )
2456 };
2457 let mut left = vec![
2458 Span::styled(
2459 " ◢",
2460 Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
2461 ),
2462 Span::styled(
2463 "◤ ",
2464 Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
2465 ),
2466 ];
2467 let word: Vec<char> = "DATABRICKS".chars().collect();
2468 let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
2469 for (i, ch) in word.iter().enumerate() {
2470 let t = i as f32 / (word.len() - 1) as f32;
2471 let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
2472 Style::default().fg(p.text).add_modifier(Modifier::BOLD)
2473 } else {
2474 Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
2475 };
2476 left.push(Span::styled(ch.to_string(), style));
2477 }
2478 left.push(Span::raw(" "));
2479 for ch in "LAKEHOUSE".chars() {
2480 left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
2481 }
2482 left.push(Span::styled(
2483 format!("· v{}", env!("CARGO_PKG_VERSION")),
2484 Style::default().fg(p.dim),
2485 ));
2486 if let Some(profile) = &app.profile {
2487 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2488 left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
2489 left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
2490 }
2491 if let Some(Shape::Badge(b)) = &app.user_badge {
2492 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2493 left.push(Span::styled(
2494 format!("{} {}", b.label, b.value),
2495 Style::default().fg(p.key),
2496 ));
2497 }
2498 if app.zoomed {
2499 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2500 left.push(Span::styled(
2501 format!("⛶ {}", app.focus.title()),
2502 Style::default()
2503 .fg(accent(app.focus, p))
2504 .add_modifier(Modifier::BOLD),
2505 ));
2506 }
2507
2508 let (ok, pending, failed, idle) = app.status_counts();
2510 for (count, glyph, color) in [
2511 (ok, "●", p.ok),
2512 (pending, "◐", p.warn),
2513 (failed, "✗", p.err),
2514 (idle, "○", p.dim),
2515 ] {
2516 if count > 0 {
2517 left.push(Span::styled(" ", Style::default()));
2518 left.push(Span::styled(
2519 format!("{glyph} {count}"),
2520 Style::default().fg(color),
2521 ));
2522 }
2523 }
2524 f.render_widget(Paragraph::new(Line::from(left)), inner);
2525
2526 let right = if app.loading {
2527 Line::from(Span::styled(
2528 format!("{} refreshing ", app.spinner()),
2529 Style::default().fg(p.warn),
2530 ))
2531 } else {
2532 Line::from(Span::styled(
2533 format!("updated {}s ago ", app.last_refresh_age().as_secs()),
2534 Style::default().fg(p.dim),
2535 ))
2536 };
2537 f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
2538}
2539
2540fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2541 let key =
2542 |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
2543 let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
2544
2545 if let Some(confirm) = &app.confirm {
2546 let line = Line::from(vec![
2547 Span::styled(
2548 format!(" ⚠ {} ", confirm.message),
2549 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2550 ),
2551 key("y"),
2552 dim(" confirm · any other key cancels"),
2553 ]);
2554 f.render_widget(Paragraph::new(line), area);
2555 return;
2556 }
2557
2558 if app.filter_entry {
2559 let line = Line::from(vec![
2560 Span::styled(
2561 format!(" /{}", app.active_filter()),
2562 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2563 ),
2564 Span::styled("▏", Style::default().fg(p.warn)),
2565 dim(" type to filter "),
2566 key("enter"),
2567 dim(" apply "),
2568 key("esc"),
2569 dim(" clear"),
2570 ]);
2571 f.render_widget(Paragraph::new(line), area);
2572 return;
2573 }
2574
2575 if let Some(pv) = &app.preview {
2576 if pv.filter_entry {
2577 let line = Line::from(vec![
2578 Span::styled(
2579 format!(" cols /{}", pv.filter),
2580 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2581 ),
2582 Span::styled("▏", Style::default().fg(p.warn)),
2583 dim(" type to filter columns "),
2584 key("enter"),
2585 dim(" apply "),
2586 key("esc"),
2587 dim(" clear"),
2588 ]);
2589 f.render_widget(Paragraph::new(line), area);
2590 return;
2591 }
2592 }
2593
2594 let mut spans = if app.secret_form.is_some() {
2595 vec![
2596 dim(" type "),
2597 key("enter"),
2598 dim(" next / save "),
2599 key("esc"),
2600 dim(" cancel"),
2601 ]
2602 } else if app.help {
2603 vec![
2604 dim(" "),
2605 key("j"),
2606 dim("/"),
2607 key("k"),
2608 dim(" scroll any other key closes"),
2609 ]
2610 } else if app.pane_cfg.is_some() {
2611 vec![
2612 dim(" "),
2613 key("j"),
2614 dim("/"),
2615 key("k"),
2616 dim(" select "),
2617 key("space"),
2618 dim(" show/hide "),
2619 key("J"),
2620 dim("/"),
2621 key("K"),
2622 dim(" move "),
2623 key("esc"),
2624 dim(" done"),
2625 ]
2626 } else if app.jump.is_some() {
2627 vec![
2628 dim(" type to search everything "),
2629 key("\u{2191}"),
2630 dim("/"),
2631 key("\u{2193}"),
2632 dim(" select "),
2633 key("enter"),
2634 dim(" jump "),
2635 key("esc"),
2636 dim(" close"),
2637 ]
2638 } else if app.problems.is_some() {
2639 vec![
2640 dim(" "),
2641 key("j"),
2642 dim("/"),
2643 key("k"),
2644 dim(" select "),
2645 key("enter"),
2646 dim(" jump to item / switch workspace "),
2647 key("esc"),
2648 dim(" close"),
2649 ]
2650 } else if app.upcoming.is_some() {
2651 vec![
2652 dim(" "),
2653 key("j"),
2654 dim("/"),
2655 key("k"),
2656 dim(" select "),
2657 key("enter"),
2658 dim(" jump to job "),
2659 key("esc"),
2660 dim(" close"),
2661 ]
2662 } else if app.cost.is_some() {
2663 vec![
2664 dim(" "),
2665 key("esc"),
2666 dim(" back "),
2667 key("q"),
2668 dim(" quit"),
2669 ]
2670 } else if app.wh_picker.is_some() {
2671 vec![
2672 dim(" "),
2673 key("j"),
2674 dim("/"),
2675 key("k"),
2676 dim(" select "),
2677 key("enter"),
2678 dim(" run preview "),
2679 key("esc"),
2680 dim(" cancel"),
2681 ]
2682 } else if app.sql.is_some() {
2683 if app.hist_search.is_some() {
2684 vec![
2685 dim(" type to search history "),
2686 key("^r"),
2687 dim(" older match "),
2688 key("enter"),
2689 dim(" accept "),
2690 key("esc"),
2691 dim(" cancel"),
2692 ]
2693 } else {
2694 vec![
2695 dim(" "),
2696 key("enter"),
2697 dim(" run "),
2698 key("tab"),
2699 dim(" complete "),
2700 key("↑"),
2701 dim("/"),
2702 key("↓"),
2703 dim(" history "),
2704 key("^r"),
2705 dim(" search "),
2706 key("^x"),
2707 dim(" editor "),
2708 key("pgup"),
2709 dim("/"),
2710 key("pgdn"),
2711 dim(" scroll "),
2712 key("⇧←→"),
2713 dim(" cols "),
2714 key("^s"),
2715 dim(" export "),
2716 key("esc"),
2717 dim(if app.sql.as_ref().is_some_and(|c| c.running) {
2718 " cancel query"
2719 } else {
2720 " close"
2721 }),
2722 ]
2723 }
2724 } else if let Some(pv) = &app.preview {
2725 if pv.record {
2726 vec![
2727 dim(" "),
2728 key("j"),
2729 dim("/"),
2730 key("k"),
2731 dim(" fields "),
2732 key("h"),
2733 dim("/"),
2734 key("l"),
2735 dim(" prev/next row "),
2736 key("v"),
2737 dim(" grid "),
2738 key("/"),
2739 dim(" filter cols "),
2740 key("esc"),
2741 dim(" back"),
2742 ]
2743 } else {
2744 vec![
2745 dim(" "),
2746 key("esc"),
2747 dim(" back "),
2748 key("j"),
2749 dim("/"),
2750 key("k"),
2751 dim(" rows "),
2752 key("←"),
2753 dim("/"),
2754 key("→"),
2755 dim(" columns "),
2756 key("v"),
2757 dim(" row view "),
2758 key("/"),
2759 dim(" filter cols "),
2760 key("e"),
2761 dim(" export csv "),
2762 key("q"),
2763 dim(" quit"),
2764 ]
2765 }
2766 } else if app.picker.is_some() {
2767 vec![
2768 dim(" "),
2769 key("j"),
2770 dim("/"),
2771 key("k"),
2772 dim(" select "),
2773 key("enter"),
2774 dim(" switch workspace "),
2775 key("esc"),
2776 dim(" cancel"),
2777 ]
2778 } else if let Some(rv) = &app.run_view {
2779 if rv.show_output {
2780 vec![
2781 dim(" "),
2782 key("esc"),
2783 dim(" back to run "),
2784 key("j"),
2785 dim("/"),
2786 key("k"),
2787 dim(" scroll "),
2788 key("q"),
2789 dim(" quit"),
2790 ]
2791 } else {
2792 let mut spans = vec![
2793 dim(" "),
2794 key("esc"),
2795 dim(" back "),
2796 key("h"),
2797 dim("/"),
2798 key("l"),
2799 dim(" older/newer "),
2800 ];
2801 if rv.panel == Panel::Jobs {
2802 spans.push(key("o"));
2803 spans.push(dim(" output "));
2804 spans.push(key("t"));
2805 spans.push(dim(" timeline "));
2806 spans.push(key("d"));
2807 spans.push(dim(" dag "));
2808 spans.push(key("g"));
2809 spans.push(dim(" grid "));
2810 spans.push(key("r"));
2811 spans.push(dim(" repair "));
2812 }
2813 spans.extend([
2814 key("s"),
2815 dim(" cancel "),
2816 key("j"),
2817 dim("/"),
2818 key("k"),
2819 dim(" scroll "),
2820 key("J"),
2821 dim(" raw "),
2822 key("q"),
2823 dim(" quit"),
2824 ]);
2825 spans
2826 }
2827 } else if app.detail.is_some() {
2828 let mut spans = vec![dim(" "), key("esc"), dim(" back ")];
2829 match app.detail.as_ref() {
2830 Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
2831 spans.push(key("enter"));
2832 spans.push(dim(" latest run "));
2833 }
2834 Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
2835 spans.push(key("enter"));
2836 spans.push(dim(" latest update "));
2837 }
2838 _ => {}
2839 }
2840 spans.extend([
2841 key("j"),
2842 dim("/"),
2843 key("k"),
2844 dim(" scroll "),
2845 key("J"),
2846 dim(" raw "),
2847 key("o"),
2848 dim(" open "),
2849 key("q"),
2850 dim(" quit"),
2851 ]);
2852 spans
2853 } else {
2854 let mut spans = vec![
2855 dim(" "),
2856 key("tab"),
2857 dim("/"),
2858 key("h"),
2859 dim("/"),
2860 key("l"),
2861 dim(" switch "),
2862 key("j"),
2863 dim("/"),
2864 key("k"),
2865 dim(" select "),
2866 key("/"),
2867 dim(if app.active_filter().is_empty() {
2868 " filter "
2869 } else {
2870 " filter · esc clears "
2871 }),
2872 ];
2873 let enter_label = match app.focus {
2875 Panel::Secrets if app.secret_scope.is_some() => None,
2876 Panel::Secrets => Some(" open scope "),
2877 Panel::Catalog if app.uc_path.len() < 2 => Some(" open "),
2878 _ => Some(" details "),
2879 };
2880 if let Some(label) = enter_label {
2881 spans.push(key("enter"));
2882 spans.push(dim(label));
2883 }
2884 match app.focus {
2886 Panel::Catalog => {
2887 if !app.uc_path.is_empty() {
2888 spans.push(key("bksp"));
2889 spans.push(dim(" up "));
2890 }
2891 if app.uc_path.len() == 2 {
2892 spans.push(key("p"));
2893 spans.push(dim(" preview "));
2894 spans.push(key("L"));
2895 spans.push(dim(" lineage "));
2896 spans.push(key(":"));
2897 spans.push(dim(" query table "));
2898 }
2899 }
2900 Panel::Dashboards => {}
2901 Panel::Secrets => {
2902 if app.secret_scope.is_some() {
2903 spans.push(key("bksp"));
2904 spans.push(dim(" up "));
2905 }
2906 spans.push(key("a"));
2907 spans.push(dim(if app.secret_scope.is_some() {
2908 " add secret "
2909 } else {
2910 " new scope "
2911 }));
2912 spans.push(key("x"));
2913 spans.push(dim(" delete "));
2914 }
2915 _ => {
2916 spans.push(key("s"));
2917 spans.push(dim(" action "));
2918 if app.focus == Panel::Jobs {
2919 spans.push(key("S"));
2920 spans.push(dim(" pause "));
2921 }
2922 }
2923 }
2924 spans.extend([
2925 key("g"),
2926 dim(" access "),
2927 key("$"),
2928 dim(" cost "),
2929 key("!"),
2930 dim(" problems "),
2931 key("u"),
2932 dim(" upcoming "),
2933 ]);
2934 if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
2936 spans.push(key(":"));
2937 spans.push(dim(" sql "));
2938 }
2939 spans.extend([
2940 key("o"),
2941 dim(" open "),
2942 key("z"),
2943 dim(if app.zoomed { " unzoom " } else { " zoom " }),
2944 key("w"),
2945 dim(" workspace "),
2946 key("t"),
2947 dim(" theme "),
2948 key("r"),
2949 dim(" refresh "),
2950 key("?"),
2951 dim(" help "),
2952 key("q"),
2953 dim(" quit"),
2954 ]);
2955 spans
2956 };
2957 if let Some((msg, _)) = &app.flash {
2959 let color = if msg.starts_with('✗') { p.err } else { p.ok };
2960 let mut prefixed = vec![
2961 Span::styled(
2962 format!(" {msg}"),
2963 Style::default().fg(color).add_modifier(Modifier::BOLD),
2964 ),
2965 dim(" · "),
2966 ];
2967 prefixed.append(&mut spans);
2968 spans = prefixed;
2969 }
2970 f.render_widget(Paragraph::new(Line::from(spans)), area);
2971}
2972
2973#[allow(clippy::too_many_arguments)]
2974fn draw_panel(
2975 f: &mut Frame,
2976 area: Rect,
2977 panel: Panel,
2978 shape: Option<&Shape>,
2979 focused: bool,
2980 selected: Option<usize>,
2981 fresh: bool,
2982 breadcrumb: &str,
2983 spinner: &str,
2984 filter: &str,
2985 entering: bool,
2986 p: &Palette,
2987) {
2988 let accent = accent(panel, p);
2989 let dimmed = |s: Style| {
2992 if focused {
2993 s
2994 } else {
2995 s.add_modifier(Modifier::DIM)
2996 }
2997 };
2998 let (border_style, title_style) = if focused {
2999 (
3000 Style::default().fg(accent).add_modifier(Modifier::BOLD),
3001 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
3002 )
3003 } else {
3004 (Style::default().fg(p.border), Style::default().fg(p.dim))
3005 };
3006 let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
3007 Some(Shape::List(items)) => Some(
3008 items
3009 .iter()
3010 .filter(|it| crate::shape::item_matches(it, filter))
3011 .collect(),
3012 ),
3013 _ => None,
3014 };
3015 let count = match (&visible, shape) {
3016 (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
3018 format!(" · {}/{}", v.len(), items.len())
3019 }
3020 (Some(v), _) => format!(" · {}", v.len()),
3021 (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
3022 _ => String::new(),
3023 };
3024 let crumb = if !breadcrumb.is_empty() {
3025 format!(" ▸ {breadcrumb}")
3026 } else {
3027 String::new()
3028 };
3029 let title_style = if fresh {
3031 Style::default()
3032 .bg(accent)
3033 .fg(Color::Black)
3034 .add_modifier(Modifier::BOLD)
3035 } else {
3036 title_style
3037 };
3038 let mut title_spans = vec![
3039 Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
3040 Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
3041 ];
3042 if entering || !filter.is_empty() {
3043 title_spans.push(Span::styled(
3044 format!("/{}{} ", filter, if entering { "▏" } else { "" }),
3045 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3046 ));
3047 }
3048 let title = Line::from(title_spans);
3049 let block = Block::default()
3050 .title(title)
3051 .borders(Borders::ALL)
3052 .border_type(if focused {
3053 BorderType::Thick
3054 } else {
3055 BorderType::Rounded
3056 })
3057 .border_style(border_style)
3058 .padding(Padding::horizontal(1));
3059
3060 match shape {
3061 None => {
3062 let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
3063 .style(Style::default().fg(p.warn))
3064 .block(block);
3065 f.render_widget(par, area);
3066 }
3067 Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
3068 let empty = if !items.is_empty() {
3069 format!("∅ nothing matches /{filter}")
3070 } else {
3071 let msg = match panel {
3072 Panel::Clusters => "no compute running",
3073 Panel::Jobs => "no jobs defined",
3074 Panel::Pipelines => "no pipelines",
3075 Panel::Warehouses => "no warehouses",
3076 Panel::Dashboards => "no dashboards yet",
3077 Panel::Catalog => "nothing at this level",
3078 Panel::Secrets => "no secret scopes",
3079 };
3080 format!("∅ {msg}")
3081 };
3082 let par = Paragraph::new(empty)
3083 .style(Style::default().fg(p.dim))
3084 .block(block);
3085 f.render_widget(par, area);
3086 }
3087 Some(Shape::List(_)) => {
3088 let list_items: Vec<ListItem> = visible
3089 .unwrap_or_default()
3090 .iter()
3091 .map(|item| {
3092 let color = status_color(&item.status, p);
3093 let chip = match item.status {
3094 Status::Stopped | Status::Unknown(_) => Span::styled(
3095 format!(" {} ", item.status.label()),
3096 Style::default().fg(color).add_modifier(Modifier::DIM),
3097 ),
3098 _ => Span::styled(
3099 format!(" {} ", item.status.label()),
3100 Style::default()
3101 .bg(color)
3102 .fg(Color::Black)
3103 .add_modifier(Modifier::BOLD),
3104 ),
3105 };
3106 let mut spans = vec![
3107 Span::styled("● ", Style::default().fg(color)),
3108 Span::styled(item.name.as_str(), dimmed(Style::default().fg(p.text))),
3109 Span::raw(" "),
3110 chip,
3111 ];
3112 if !item.history.is_empty() {
3113 spans.push(Span::raw(" "));
3114 for run in &item.history {
3115 spans.push(Span::styled(
3116 history_glyph(run),
3117 Style::default().fg(status_color(run, p)),
3118 ));
3119 }
3120 }
3121 if let Some(detail) = &item.detail {
3122 spans.push(Span::styled(
3123 format!(" {}", detail),
3124 dimmed(Style::default().fg(p.dim)),
3125 ));
3126 }
3127 ListItem::new(Line::from(spans))
3128 })
3129 .collect();
3130 let list = List::new(list_items)
3131 .block(block)
3132 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
3133 match selected {
3134 Some(sel) => {
3135 let mut state = ListState::default().with_selected(Some(sel));
3136 f.render_stateful_widget(list, area, &mut state);
3137 }
3138 None => f.render_widget(list, area),
3139 }
3140 }
3141 Some(Shape::Table(data)) => {
3142 let header_cells: Vec<Cell> = data
3143 .headers
3144 .iter()
3145 .map(|h| {
3146 Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
3147 })
3148 .collect();
3149 let header = Row::new(header_cells).style(Style::default().fg(accent));
3150 let rows: Vec<Row> = data
3151 .rows
3152 .iter()
3153 .map(|r| {
3154 Row::new(
3155 r.iter()
3156 .map(|c| Cell::from(c.as_str()).style(dimmed(Style::default())))
3157 .collect::<Vec<_>>(),
3158 )
3159 })
3160 .collect();
3161 let widths: Vec<Constraint> = data
3162 .headers
3163 .iter()
3164 .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
3165 .collect();
3166 let table = Table::new(rows, widths).header(header).block(block);
3167 f.render_widget(table, area);
3168 }
3169 Some(Shape::Badge(b)) => {
3170 let text = format!("{}: {}", b.label, b.value);
3171 let par = Paragraph::new(text)
3172 .style(dimmed(Style::default().fg(p.text)))
3173 .block(block);
3174 f.render_widget(par, area);
3175 }
3176 Some(Shape::Text(t)) => {
3177 let color = if t.starts_with('✗') { p.err } else { p.text };
3178 let par = Paragraph::new(t.as_str())
3179 .style(dimmed(Style::default().fg(color)))
3180 .wrap(Wrap { trim: false })
3181 .block(block);
3182 f.render_widget(par, area);
3183 }
3184 }
3185}
3186
3187fn scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, pos: usize, p: &Palette) {
3191 if viewport == 0 || total <= viewport {
3192 return;
3193 }
3194 let max = total - viewport;
3195 let mut state = ScrollbarState::new(max).position(pos.min(max));
3196 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
3197 .begin_symbol(None)
3198 .end_symbol(None)
3199 .track_symbol(Some("│"))
3200 .track_style(Style::default().fg(p.border))
3201 .thumb_symbol("┃")
3202 .thumb_style(Style::default().fg(p.text));
3203 f.render_stateful_widget(
3204 sb,
3205 area.inner(Margin {
3206 horizontal: 0,
3207 vertical: 1,
3208 }),
3209 &mut state,
3210 );
3211}
3212
3213fn wrapped_height(text: &str, width: usize) -> usize {
3215 let w = width.max(1);
3216 text.lines()
3217 .map(|l| l.chars().count().div_ceil(w).max(1))
3218 .sum()
3219}
3220
3221const SQL_EXTRA_KEYWORDS: &[&str] = &[
3224 "TABLE", "VIEW", "IF", "EXISTS", "USE", "TRUE", "FALSE", "ASC", "DESC", "INTERVAL",
3225];
3226
3227fn is_sql_keyword(word: &str) -> bool {
3228 let up = word.to_ascii_uppercase();
3229 crate::app::SQL_KEYWORDS
3230 .iter()
3231 .any(|k| k.split_whitespace().any(|w| w == up))
3232 || SQL_EXTRA_KEYWORDS.contains(&up.as_str())
3233}
3234
3235fn sql_spans(sql: &str, p: &Palette) -> Vec<Span<'static>> {
3239 let chars: Vec<char> = sql.chars().collect();
3240 let mut spans = Vec::new();
3241 let mut i = 0;
3242 let push = |spans: &mut Vec<Span<'static>>, text: String, style: Style| {
3243 if !text.is_empty() {
3244 spans.push(Span::styled(text, style));
3245 }
3246 };
3247 while i < chars.len() {
3248 let c = chars[i];
3249 if c == '\'' || c == '`' || c == '"' {
3250 let start = i;
3253 i += 1;
3254 while i < chars.len() && chars[i] != c {
3255 i += 1;
3256 }
3257 if i < chars.len() {
3258 i += 1;
3259 }
3260 let color = if c == '\'' { p.ok } else { p.catalog };
3261 push(
3262 &mut spans,
3263 chars[start..i].iter().collect(),
3264 Style::default().fg(color),
3265 );
3266 } else if c == '-' && chars.get(i + 1) == Some(&'-') {
3267 push(
3268 &mut spans,
3269 chars[i..].iter().collect(),
3270 Style::default().fg(p.dim),
3271 );
3272 break;
3273 } else if c.is_ascii_digit() {
3274 let start = i;
3275 while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
3276 i += 1;
3277 }
3278 push(
3279 &mut spans,
3280 chars[start..i].iter().collect(),
3281 Style::default().fg(p.warn),
3282 );
3283 } else if c.is_alphanumeric() || c == '_' {
3284 let start = i;
3285 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
3286 i += 1;
3287 }
3288 let word: String = chars[start..i].iter().collect();
3289 let style = if is_sql_keyword(&word) {
3290 Style::default().fg(p.key).add_modifier(Modifier::BOLD)
3291 } else {
3292 Style::default().fg(p.text)
3293 };
3294 push(&mut spans, word, style);
3295 } else {
3296 let start = i;
3297 i += 1;
3298 while i < chars.len() {
3299 let c = chars[i];
3300 if c.is_alphanumeric() || matches!(c, '_' | '\'' | '`' | '"' | '-') {
3301 break;
3302 }
3303 i += 1;
3304 }
3305 push(
3306 &mut spans,
3307 chars[start..i].iter().collect(),
3308 Style::default().fg(p.text),
3309 );
3310 }
3311 }
3312 spans
3313}
3314
3315fn output_line<'a>(line: &'a str, p: &Palette) -> Line<'a> {
3319 if let Some(rest) = line.strip_prefix("── ") {
3320 let color = if rest.contains("FAILED") {
3321 p.err
3322 } else if rest.contains("RUNNING") || rest.contains("PENDING") {
3323 p.warn
3324 } else if rest.contains("SUCCESS") {
3325 p.ok
3326 } else {
3327 p.text
3328 };
3329 return Line::from(Span::styled(
3330 line,
3331 Style::default().fg(color).add_modifier(Modifier::BOLD),
3332 ));
3333 }
3334 let trimmed = line.trim_start();
3335 let indented = line.starts_with(char::is_whitespace);
3336 let body = if line.contains("ERROR")
3337 || line.contains("FATAL")
3338 || line.contains("Exception")
3339 || trimmed.starts_with("Traceback")
3340 || trimmed.starts_with("Caused by")
3341 || (indented && (trimmed.starts_with("at ") || trimmed.starts_with("File \"")))
3342 {
3343 Style::default().fg(p.err)
3344 } else if line.contains("WARN") {
3345 Style::default().fg(p.warn)
3346 } else if line == "logs (tail):" || trimmed.starts_with("(no output") {
3347 Style::default().fg(p.dim)
3348 } else {
3349 Style::default().fg(p.text)
3350 };
3351 let ts = timestamp_prefix(line);
3352 if ts > 0 {
3353 Line::from(vec![
3354 Span::styled(&line[..ts], Style::default().fg(p.dim)),
3355 Span::styled(&line[ts..], body),
3356 ])
3357 } else {
3358 Line::from(Span::styled(line, body))
3359 }
3360}
3361
3362fn timestamp_prefix(line: &str) -> usize {
3366 let mut len = 0;
3367 let mut digits = 0;
3368 for (i, c) in line.char_indices() {
3369 if c.is_ascii_digit() {
3370 digits += 1;
3371 } else if !matches!(c, '/' | '-' | ':' | '.' | ',' | 'T' | 'Z' | ' ') {
3372 break;
3373 }
3374 len = i + c.len_utf8();
3375 if len >= 30 {
3376 break;
3377 }
3378 }
3379 if digits >= 6 && len >= 8 {
3380 len
3381 } else {
3382 0
3383 }
3384}
3385
3386fn status_color(status: &Status, p: &Palette) -> Color {
3387 match status {
3388 Status::Running | Status::Success => p.ok,
3389 Status::Stopped => p.dim,
3390 Status::Pending => p.warn,
3391 Status::Failed => p.err,
3392 Status::Unknown(_) => p.text,
3393 }
3394}
3395
3396fn history_glyph(status: &Status) -> &'static str {
3397 match status {
3398 Status::Success => "✓",
3399 Status::Failed => "✗",
3400 Status::Running => "●",
3401 Status::Pending => "◐",
3402 Status::Stopped => "○",
3403 Status::Unknown(_) => "·",
3404 }
3405}
3406
3407#[cfg(test)]
3408mod tests {
3409 use super::*;
3410
3411 fn find<'a>(spans: &'a [Span], text: &str) -> &'a Span<'a> {
3412 spans
3413 .iter()
3414 .find(|s| s.content == text)
3415 .unwrap_or_else(|| panic!("no span {text:?} in {spans:?}"))
3416 }
3417
3418 #[test]
3419 fn sql_spans_color_keywords_strings_numbers_comments() {
3420 let p = palette(ThemeMode::Dark);
3421 let spans = sql_spans("SELECT count(x) FROM t WHERE s = 'bob' LIMIT 10 -- hi", &p);
3422 assert_eq!(find(&spans, "SELECT").style.fg, Some(p.key));
3423 assert!(find(&spans, "SELECT")
3424 .style
3425 .add_modifier
3426 .contains(Modifier::BOLD));
3427 assert_eq!(find(&spans, "count").style.fg, Some(p.key));
3428 assert_eq!(find(&spans, "t").style.fg, Some(p.text));
3429 assert_eq!(find(&spans, "'bob'").style.fg, Some(p.ok));
3430 assert_eq!(find(&spans, "10").style.fg, Some(p.warn));
3431 assert_eq!(find(&spans, "-- hi").style.fg, Some(p.dim));
3432 }
3433
3434 #[test]
3435 fn sql_spans_preserve_every_char() {
3436 let p = palette(ThemeMode::Dark);
3437 for sql in [
3438 "SELECT `col a`, b - 1 FROM main.sales.orders WHERE s = 'unclosed",
3439 "-- only a comment",
3440 " \"quoted id\" <> 3.5e2 ",
3441 ] {
3442 let joined: String = sql_spans(sql, &p)
3443 .iter()
3444 .map(|s| s.content.as_ref())
3445 .collect();
3446 assert_eq!(joined, sql);
3447 }
3448 }
3449
3450 #[test]
3451 fn spark_scales_min_to_max() {
3452 assert_eq!(spark(&[1, 1, 1]), "▄▄▄");
3453 assert_eq!(spark(&[0, 7]), "▁█");
3454 assert_eq!(spark(&[100, 150, 200]), "▁▄█");
3455 assert_eq!(spark(&[]), "");
3456 }
3457}