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