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