1use crate::app::{App, Panel, ThemeMode};
2use crate::fetchers;
3use crate::shape::{DetailData, Shape, Status, TableData};
4use ratatui::{
5 layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{
9 Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph,
10 Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, Wrap,
11 },
12 Frame,
13};
14use std::time::Duration;
15
16struct Palette {
17 text: Color,
18 dim: Color,
19 border: Color,
20 warn: Color,
21 ok: Color,
22 err: Color,
23 key: Color,
24 brand: Color,
25 clusters: Color,
26 jobs: Color,
27 pipelines: Color,
28 warehouses: Color,
29 catalog: Color,
30 grad_from: (u8, u8, u8),
32 grad_to: (u8, u8, u8),
33}
34
35const fn rgb(hex: u32) -> Color {
36 Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
37}
38
39const fn rgb3(hex: u32) -> (u8, u8, u8) {
40 ((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
41}
42
43fn palette(mode: ThemeMode) -> Palette {
44 match mode {
45 ThemeMode::Dark => Palette {
47 text: Color::White,
48 dim: Color::DarkGray,
49 border: Color::DarkGray,
50 warn: Color::Yellow,
51 ok: Color::Green,
52 err: Color::Red,
53 key: Color::Cyan,
54 brand: Color::Red,
55 clusters: Color::Cyan,
56 jobs: Color::Magenta,
57 pipelines: Color::Green,
58 warehouses: Color::Blue,
59 catalog: rgb(0xFF8C42),
60 grad_from: rgb3(0xFF3621),
61 grad_to: rgb3(0xFFA046),
62 },
63 ThemeMode::Light => Palette {
65 text: Color::Black,
66 dim: rgb(0x6B7280),
67 border: rgb(0x9CA3AF),
68 warn: rgb(0xB45309),
69 ok: rgb(0x15803D),
70 err: rgb(0xB91C1C),
71 key: rgb(0x0891B2),
72 brand: rgb(0xDC2626),
73 clusters: rgb(0x0891B2),
74 jobs: rgb(0xA21CAF),
75 pipelines: rgb(0x15803D),
76 warehouses: rgb(0x1D4ED8),
77 catalog: rgb(0xC2410C),
78 grad_from: rgb3(0xB91C1C),
79 grad_to: rgb3(0xC2410C),
80 },
81 ThemeMode::CatppuccinMocha => Palette {
82 text: rgb(0xCDD6F4),
83 dim: rgb(0x6C7086),
84 border: rgb(0x585B70),
85 warn: rgb(0xF9E2AF),
86 ok: rgb(0xA6E3A1),
87 err: rgb(0xF38BA8),
88 key: rgb(0x89DCEB),
89 brand: rgb(0xF38BA8),
90 clusters: rgb(0x89DCEB),
91 jobs: rgb(0xCBA6F7),
92 pipelines: rgb(0xA6E3A1),
93 warehouses: rgb(0x89B4FA),
94 catalog: rgb(0xFAB387),
95 grad_from: rgb3(0xF38BA8),
96 grad_to: rgb3(0xFAB387),
97 },
98 ThemeMode::CatppuccinMacchiato => Palette {
99 text: rgb(0xCAD3F5),
100 dim: rgb(0x6E738D),
101 border: rgb(0x5B6078),
102 warn: rgb(0xEED49F),
103 ok: rgb(0xA6DA95),
104 err: rgb(0xED8796),
105 key: rgb(0x91D7E3),
106 brand: rgb(0xED8796),
107 clusters: rgb(0x91D7E3),
108 jobs: rgb(0xC6A0F6),
109 pipelines: rgb(0xA6DA95),
110 warehouses: rgb(0x8AADF4),
111 catalog: rgb(0xF5A97F),
112 grad_from: rgb3(0xED8796),
113 grad_to: rgb3(0xF5A97F),
114 },
115 ThemeMode::CatppuccinFrappe => Palette {
116 text: rgb(0xC6D0F5),
117 dim: rgb(0x737994),
118 border: rgb(0x626880),
119 warn: rgb(0xE5C890),
120 ok: rgb(0xA6D189),
121 err: rgb(0xE78284),
122 key: rgb(0x99D1DB),
123 brand: rgb(0xE78284),
124 clusters: rgb(0x99D1DB),
125 jobs: rgb(0xCA9EE6),
126 pipelines: rgb(0xA6D189),
127 warehouses: rgb(0x8CAAEE),
128 catalog: rgb(0xEF9F76),
129 grad_from: rgb3(0xE78284),
130 grad_to: rgb3(0xEF9F76),
131 },
132 ThemeMode::CatppuccinLatte => Palette {
133 text: rgb(0x4C4F69),
134 dim: rgb(0x8C8FA1),
135 border: rgb(0xACB0BE),
136 warn: rgb(0xDF8E1D),
137 ok: rgb(0x40A02B),
138 err: rgb(0xD20F39),
139 key: rgb(0x04A5E5),
140 brand: rgb(0xD20F39),
141 clusters: rgb(0x04A5E5),
142 jobs: rgb(0x8839EF),
143 pipelines: rgb(0x40A02B),
144 warehouses: rgb(0x1E66F5),
145 catalog: rgb(0xFE640B),
146 grad_from: rgb3(0xD20F39),
147 grad_to: rgb3(0xFE640B),
148 },
149 ThemeMode::GruvboxDark => Palette {
150 text: rgb(0xEBDBB2),
151 dim: rgb(0x928374),
152 border: rgb(0x665C54),
153 warn: rgb(0xFABD2F),
154 ok: rgb(0xB8BB26),
155 err: rgb(0xFB4934),
156 key: rgb(0x8EC07C),
157 brand: rgb(0xFB4934),
158 clusters: rgb(0x8EC07C),
159 jobs: rgb(0xD3869B),
160 pipelines: rgb(0xB8BB26),
161 warehouses: rgb(0x83A598),
162 catalog: rgb(0xFE8019),
163 grad_from: rgb3(0xFB4934),
164 grad_to: rgb3(0xFE8019),
165 },
166 ThemeMode::GruvboxLight => Palette {
167 text: rgb(0x3C3836),
168 dim: rgb(0x928374),
169 border: rgb(0xBDAE93),
170 warn: rgb(0xB57614),
171 ok: rgb(0x79740E),
172 err: rgb(0x9D0006),
173 key: rgb(0x427B58),
174 brand: rgb(0x9D0006),
175 clusters: rgb(0x427B58),
176 jobs: rgb(0x8F3F71),
177 pipelines: rgb(0x79740E),
178 warehouses: rgb(0x076678),
179 catalog: rgb(0xAF3A03),
180 grad_from: rgb3(0x9D0006),
181 grad_to: rgb3(0xAF3A03),
182 },
183 ThemeMode::Dracula => Palette {
184 text: rgb(0xF8F8F2),
185 dim: rgb(0x6272A4),
186 border: rgb(0x44475A),
187 warn: rgb(0xF1FA8C),
188 ok: rgb(0x50FA7B),
189 err: rgb(0xFF5555),
190 key: rgb(0x8BE9FD),
191 brand: rgb(0xFF5555),
192 clusters: rgb(0x8BE9FD),
193 jobs: rgb(0xFF79C6),
194 pipelines: rgb(0x50FA7B),
195 warehouses: rgb(0xBD93F9),
196 catalog: rgb(0xFFB86C),
197 grad_from: rgb3(0xFF5555),
198 grad_to: rgb3(0xFFB86C),
199 },
200 ThemeMode::Nord => Palette {
201 text: rgb(0xD8DEE9),
202 dim: rgb(0x4C566A),
203 border: rgb(0x434C5E),
204 warn: rgb(0xEBCB8B),
205 ok: rgb(0xA3BE8C),
206 err: rgb(0xBF616A),
207 key: rgb(0x88C0D0),
208 brand: rgb(0xBF616A),
209 clusters: rgb(0x88C0D0),
210 jobs: rgb(0xB48EAD),
211 pipelines: rgb(0xA3BE8C),
212 warehouses: rgb(0x81A1C1),
213 catalog: rgb(0xD08770),
214 grad_from: rgb3(0xBF616A),
215 grad_to: rgb3(0xD08770),
216 },
217 ThemeMode::TokyoNight => Palette {
218 text: rgb(0xC0CAF5),
219 dim: rgb(0x565F89),
220 border: rgb(0x3B4261),
221 warn: rgb(0xE0AF68),
222 ok: rgb(0x9ECE6A),
223 err: rgb(0xF7768E),
224 key: rgb(0x7DCFFF),
225 brand: rgb(0xF7768E),
226 clusters: rgb(0x7DCFFF),
227 jobs: rgb(0xBB9AF7),
228 pipelines: rgb(0x9ECE6A),
229 warehouses: rgb(0x7AA2F7),
230 catalog: rgb(0xFF9E64),
231 grad_from: rgb3(0xF7768E),
232 grad_to: rgb3(0xFF9E64),
233 },
234 ThemeMode::RosePine => Palette {
235 text: rgb(0xE0DEF4),
236 dim: rgb(0x6E6A86),
237 border: rgb(0x403D52),
238 warn: rgb(0xF6C177),
239 ok: rgb(0x31748F),
240 err: rgb(0xEB6F92),
241 key: rgb(0x9CCFD8),
242 brand: rgb(0xEB6F92),
243 clusters: rgb(0x9CCFD8),
244 jobs: rgb(0xC4A7E7),
245 pipelines: rgb(0x31748F),
246 warehouses: rgb(0x3E8FB0),
247 catalog: rgb(0xEBBCBA),
248 grad_from: rgb3(0xEB6F92),
249 grad_to: rgb3(0xEBBCBA),
250 },
251 ThemeMode::Everforest => Palette {
252 text: rgb(0xD3C6AA),
253 dim: rgb(0x859289),
254 border: rgb(0x475258),
255 warn: rgb(0xDBBC7F),
256 ok: rgb(0xA7C080),
257 err: rgb(0xE67E80),
258 key: rgb(0x83C092),
259 brand: rgb(0xE67E80),
260 clusters: rgb(0x83C092),
261 jobs: rgb(0xD699B6),
262 pipelines: rgb(0xA7C080),
263 warehouses: rgb(0x7FBBB3),
264 catalog: rgb(0xE69875),
265 grad_from: rgb3(0xE67E80),
266 grad_to: rgb3(0xE69875),
267 },
268 ThemeMode::Kanagawa => Palette {
269 text: rgb(0xDCD7BA),
270 dim: rgb(0x727169),
271 border: rgb(0x54546D),
272 warn: rgb(0xE6C384),
273 ok: rgb(0x98BB6C),
274 err: rgb(0xC34043),
275 key: rgb(0x7FB4CA),
276 brand: rgb(0xC34043),
277 clusters: rgb(0x7FB4CA),
278 jobs: rgb(0x957FB8),
279 pipelines: rgb(0x98BB6C),
280 warehouses: rgb(0x7E9CD8),
281 catalog: rgb(0xFFA066),
282 grad_from: rgb3(0xC34043),
283 grad_to: rgb3(0xFFA066),
284 },
285 ThemeMode::SolarizedDark => Palette {
286 text: rgb(0x93A1A1),
287 dim: rgb(0x586E75),
288 border: rgb(0x073642),
289 warn: rgb(0xB58900),
290 ok: rgb(0x859900),
291 err: rgb(0xDC322F),
292 key: rgb(0x2AA198),
293 brand: rgb(0xDC322F),
294 clusters: rgb(0x2AA198),
295 jobs: rgb(0xD33682),
296 pipelines: rgb(0x859900),
297 warehouses: rgb(0x268BD2),
298 catalog: rgb(0xCB4B16),
299 grad_from: rgb3(0xDC322F),
300 grad_to: rgb3(0xCB4B16),
301 },
302 ThemeMode::OneDark => Palette {
303 text: rgb(0xABB2BF),
304 dim: rgb(0x5C6370),
305 border: rgb(0x3E4451),
306 warn: rgb(0xE5C07B),
307 ok: rgb(0x98C379),
308 err: rgb(0xE06C75),
309 key: rgb(0x56B6C2),
310 brand: rgb(0xE06C75),
311 clusters: rgb(0x56B6C2),
312 jobs: rgb(0xC678DD),
313 pipelines: rgb(0x98C379),
314 warehouses: rgb(0x61AFEF),
315 catalog: rgb(0xD19A66),
316 grad_from: rgb3(0xE06C75),
317 grad_to: rgb3(0xD19A66),
318 },
319 }
320}
321
322fn accent(panel: Panel, p: &Palette) -> Color {
323 match panel {
324 Panel::Clusters => p.clusters,
325 Panel::Jobs => p.jobs,
326 Panel::Pipelines => p.pipelines,
327 Panel::Warehouses => p.warehouses,
328 Panel::Dashboards => p.key,
329 Panel::Catalog => p.catalog,
330 Panel::Secrets => p.warn,
331 }
332}
333
334fn pane_breadcrumb(app: &App, panel: Panel) -> String {
335 match panel {
336 Panel::Catalog => app.uc_path.join("."),
337 Panel::Secrets => app.secret_scope.clone().unwrap_or_default(),
338 _ => String::new(),
339 }
340}
341
342pub fn draw(f: &mut Frame, app: &App) {
343 let p = palette(app.theme);
344
345 if app.splash_active() {
346 draw_splash(f, f.area(), app, &p);
347 return;
348 }
349
350 let root = Layout::default()
351 .direction(Direction::Vertical)
352 .constraints([
353 Constraint::Length(3),
354 Constraint::Min(0),
355 Constraint::Length(1),
356 ])
357 .split(f.area());
358
359 draw_header(f, root[0], app, &p);
360 draw_footer(f, root[2], app, &p);
361
362 if app.sql.is_some() {
363 draw_sql(f, root[1], app, &p);
364 if app.sql_complete.is_some() {
365 draw_sql_complete(f, root[1], app, &p);
366 }
367 if app.wh_picker.is_some() {
369 draw_wh_picker(f, root[1], app, &p);
370 }
371 return;
372 }
373
374 if app.cost.is_some() {
375 draw_cost(f, root[1], app, &p);
376 return;
377 }
378
379 if app.item_cost.is_some() {
380 draw_item_cost(f, root[1], app, &p);
381 return;
382 }
383
384 if app.preview.is_some() {
385 draw_preview(f, root[1], app, &p);
386 return;
387 }
388
389 if app.run_view.is_some() {
390 draw_run(f, root[1], app, &p);
391 return;
392 }
393
394 if app.detail.is_some() {
395 draw_detail(f, root[1], app, &p);
396 return;
397 }
398
399 if app.zoomed {
400 let idx = Panel::ALL
401 .iter()
402 .position(|pn| pn == &app.focus)
403 .unwrap_or(0);
404 draw_panel(
405 f,
406 root[1],
407 app.focus,
408 app.shapes[idx].as_ref(),
409 true,
410 Some(app.selection(idx)),
411 false,
412 &pane_breadcrumb(app, app.focus),
413 app.spinner(),
414 &app.filters[idx],
415 app.filter_entry,
416 &app.pane_fav_set(idx),
417 app.fav_filter_active(idx),
418 &p,
419 );
420 if app.picker.is_some() {
422 draw_picker(f, root[1], app, &p);
423 }
424 if app.wh_picker.is_some() {
425 draw_wh_picker(f, root[1], app, &p);
426 }
427 if app.problems.is_some() {
428 draw_problems(f, root[1], app, &p);
429 }
430 if app.upcoming.is_some() {
431 draw_upcoming(f, root[1], app, &p);
432 }
433 if app.jump.is_some() {
434 draw_jump(f, root[1], app, &p);
435 }
436 if app.pane_cfg.is_some() {
437 draw_pane_cfg(f, root[1], app, &p);
438 }
439 if app.secret_form.is_some() {
440 draw_secret_form(f, root[1], app, &p);
441 }
442 if app.param_form.is_some() {
443 draw_param_form(f, root[1], app, &p);
444 }
445 if app.help {
446 draw_help(f, root[1], app, &p);
447 }
448 return;
449 }
450
451 let visible = app.visible_panes();
454 if visible.is_empty() {
455 let par = Paragraph::new("all panes hidden — press H to bring them back")
456 .style(Style::default().fg(p.dim))
457 .alignment(Alignment::Center);
458 f.render_widget(par, root[1]);
459 } else {
460 let rows_of = |n: usize| -> Vec<Constraint> {
461 (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
462 };
463 let left_count = visible.len().div_ceil(2);
464 let right_count = visible.len() - left_count;
465 let mut areas: Vec<Rect> = Vec::new();
466 if right_count == 0 {
467 areas.extend(
468 Layout::default()
469 .direction(Direction::Vertical)
470 .constraints(rows_of(left_count))
471 .split(root[1])
472 .iter(),
473 );
474 } else {
475 let body = Layout::default()
476 .direction(Direction::Horizontal)
477 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
478 .split(root[1]);
479 areas.extend(
480 Layout::default()
481 .direction(Direction::Vertical)
482 .constraints(rows_of(left_count))
483 .split(body[0])
484 .iter(),
485 );
486 areas.extend(
487 Layout::default()
488 .direction(Direction::Vertical)
489 .constraints(rows_of(right_count))
490 .split(body[1])
491 .iter(),
492 );
493 }
494
495 for (slot, &i) in visible.iter().enumerate() {
496 let panel = Panel::ALL[i];
497 let focused = app.focus == panel;
498 let shape = app.shapes[i].as_ref();
499 let selected = focused.then(|| app.selection(i));
500 let fresh = app.updated_at[i]
501 .map(|t| t.elapsed() < Duration::from_millis(1200))
502 .unwrap_or(false);
503 draw_panel(
504 f,
505 areas[slot],
506 panel,
507 shape,
508 focused,
509 selected,
510 fresh,
511 &pane_breadcrumb(app, panel),
512 app.spinner(),
513 &app.filters[i],
514 app.filter_entry && focused,
515 &app.pane_fav_set(i),
516 app.fav_filter_active(i),
517 &p,
518 );
519 }
520 }
521
522 if app.picker.is_some() {
523 draw_picker(f, root[1], app, &p);
524 }
525 if app.wh_picker.is_some() {
526 draw_wh_picker(f, root[1], app, &p);
527 }
528 if app.problems.is_some() {
529 draw_problems(f, root[1], app, &p);
530 }
531 if app.upcoming.is_some() {
532 draw_upcoming(f, root[1], app, &p);
533 }
534 if app.jump.is_some() {
535 draw_jump(f, root[1], app, &p);
536 }
537 if app.pane_cfg.is_some() {
538 draw_pane_cfg(f, root[1], app, &p);
539 }
540 if app.secret_form.is_some() {
541 draw_secret_form(f, root[1], app, &p);
542 }
543 if app.param_form.is_some() {
544 draw_param_form(f, root[1], app, &p);
545 }
546 if app.help {
547 draw_help(f, root[1], app, &p);
548 }
549}
550
551fn draw_secret_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
552 let Some(form) = &app.secret_form else {
553 return;
554 };
555 let width = 60.min(area.width.saturating_sub(4));
556 let height = 6.min(area.height);
557 let popup = Rect {
558 x: area.x + (area.width.saturating_sub(width)) / 2,
559 y: area.y + (area.height.saturating_sub(height)) / 2,
560 width,
561 height,
562 };
563 f.render_widget(Clear, popup);
564 let title = match &form.scope {
565 None => "New secret scope ".to_string(),
566 Some(s) => format!("New secret in {s} "),
567 };
568 let block = Block::default()
569 .title(Line::from(vec![
570 Span::styled(" ◈ ", Style::default().fg(p.warn)),
571 Span::styled(
572 title,
573 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
574 ),
575 ]))
576 .borders(Borders::ALL)
577 .border_type(BorderType::Thick)
578 .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
579 .padding(Padding::new(1, 1, 1, 0));
580 let label0 = if form.scope.is_none() {
581 "name "
582 } else {
583 "key "
584 };
585 let mut lines = vec![Line::from(vec![
586 Span::styled(label0, Style::default().fg(p.dim)),
587 Span::styled(form.key.as_str(), Style::default().fg(p.text)),
588 if form.stage == 0 {
589 Span::styled("▏", Style::default().fg(p.warn))
590 } else {
591 Span::raw("")
592 },
593 ])];
594 if form.scope.is_some() {
595 lines.push(Line::from(vec![
597 Span::styled("value ", Style::default().fg(p.dim)),
598 Span::styled(
599 "•".repeat(form.value.chars().count()),
600 Style::default().fg(p.text),
601 ),
602 if form.stage == 1 {
603 Span::styled("▏", Style::default().fg(p.warn))
604 } else {
605 Span::raw("")
606 },
607 ]));
608 }
609 f.render_widget(Paragraph::new(lines).block(block), popup);
610}
611
612fn draw_param_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
613 let Some(form) = &app.param_form else {
614 return;
615 };
616 let width = 70.min(area.width.saturating_sub(4));
617 let height = 6.min(area.height);
618 let popup = Rect {
619 x: area.x + (area.width.saturating_sub(width)) / 2,
620 y: area.y + (area.height.saturating_sub(height)) / 2,
621 width,
622 height,
623 };
624 f.render_widget(Clear, popup);
625 let block = Block::default()
626 .title(Line::from(vec![
627 Span::styled(" ▸ ", Style::default().fg(p.warn)),
628 Span::styled(
629 format!("Run “{}” with parameters ", form.job),
630 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
631 ),
632 ]))
633 .borders(Borders::ALL)
634 .border_type(BorderType::Thick)
635 .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
636 .padding(Padding::new(1, 1, 1, 0));
637 let lines = if form.loading {
638 vec![Line::from(Span::styled(
639 format!("{} loading current parameters…", app.spinner()),
640 Style::default().fg(p.warn),
641 ))]
642 } else {
643 let at = form
645 .input
646 .char_indices()
647 .nth(form.cursor)
648 .map(|(i, _)| i)
649 .unwrap_or(form.input.len());
650 let kind = match form.kind {
651 crate::fetchers::jobs::ParamKind::Job => "job parameters",
652 crate::fetchers::jobs::ParamKind::Notebook => "notebook parameters",
653 };
654 vec![
655 Line::from(vec![
656 Span::styled("❯ ", Style::default().fg(p.warn)),
657 Span::styled(&form.input[..at], Style::default().fg(p.text)),
658 Span::styled("▏", Style::default().fg(p.warn)),
659 Span::styled(&form.input[at..], Style::default().fg(p.text)),
660 ]),
661 Line::default(),
662 Line::from(Span::styled(
663 format!("key=value, comma-separated · sent as {kind} · empty runs as-is"),
664 Style::default().fg(p.dim),
665 )),
666 ]
667 };
668 f.render_widget(Paragraph::new(lines).block(block), popup);
669}
670
671fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
672 let Some(selected) = app.pane_cfg else {
673 return;
674 };
675 let width = 52.min(area.width.saturating_sub(4));
676 let height = 11.min(area.height);
677 let popup = Rect {
678 x: area.x + (area.width.saturating_sub(width)) / 2,
679 y: area.y + (area.height.saturating_sub(height)) / 2,
680 width,
681 height,
682 };
683 f.render_widget(Clear, popup);
684 let block = Block::default()
685 .title(Line::from(vec![
686 Span::styled(" ▦ ", Style::default().fg(p.key)),
687 Span::styled(
688 "Panes ",
689 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
690 ),
691 ]))
692 .borders(Borders::ALL)
693 .border_type(BorderType::Thick)
694 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
695 .padding(Padding::new(1, 1, 1, 0));
696 let items: Vec<ListItem> = app
697 .pane_order
698 .iter()
699 .map(|&i| {
700 let panel = Panel::ALL[i];
701 let (mark, style) = if app.hidden[i] {
702 ("○ ", Style::default().fg(p.dim))
703 } else {
704 ("● ", Style::default().fg(p.ok))
705 };
706 ListItem::new(Line::from(vec![
707 Span::styled(mark, style),
708 Span::styled(
709 format!("{} ", panel.icon()),
710 Style::default().fg(accent(panel, p)),
711 ),
712 Span::styled(
713 panel.title(),
714 if app.hidden[i] {
715 Style::default().fg(p.dim)
716 } else {
717 Style::default().fg(p.text)
718 },
719 ),
720 ]))
721 })
722 .collect();
723 let list = List::new(items)
724 .block(block)
725 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
726 let mut state = ListState::default().with_selected(Some(selected));
727 f.render_stateful_widget(list, popup, &mut state);
728}
729
730fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
731 let width = 74.min(area.width.saturating_sub(4));
732 let height = area.height.saturating_sub(2);
733 let popup = Rect {
734 x: area.x + (area.width.saturating_sub(width)) / 2,
735 y: area.y + 1,
736 width,
737 height,
738 };
739 f.render_widget(Clear, popup);
740 let block = Block::default()
741 .title(Line::from(vec![
742 Span::styled(" ? ", Style::default().fg(p.key)),
743 Span::styled(
744 "Keys ",
745 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
746 ),
747 ]))
748 .borders(Borders::ALL)
749 .border_type(BorderType::Thick)
750 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
751 .padding(Padding::new(1, 1, 1, 0));
752
753 let sections: &[(&str, &[(&str, &str)])] = &[
754 (
755 "Global",
756 &[
757 ("tab / l, shift+tab / h", "focus next / previous pane"),
758 ("j / k", "select item"),
759 ("enter", "open details / drill down"),
760 ("/", "filter the focused pane (esc clears)"),
761 ("ctrl+p", "command palette: fuzzy-jump anywhere"),
762 ("!", "problems: failures in every workspace, enter jumps"),
763 ("u", "upcoming runs: what fires next, soonest first"),
764 (":", "SQL console (prefilled on a catalog table)"),
765 ("$", "cost view: DBUs, dollars, top spenders"),
766 ("H", "arrange panes: hide and reorder"),
767 ("z", "zoom the focused pane"),
768 ("w", "switch workspace profile"),
769 ("t", "cycle color theme"),
770 ("r", "refresh now"),
771 ("?", "this help"),
772 ("q / ctrl+c", "quit"),
773 ],
774 ),
775 (
776 "Focused pane",
777 &[
778 ("s", "start / stop / run the selected item"),
779 ("c", "jobs/pipelines: spend over week, month, quarter, year"),
780 ("p", "jobs: on the run confirm, edit parameters first"),
781 ("S", "jobs: pause / resume the schedule or trigger"),
782 ("f", "pin / unpin the selected item as a favorite"),
783 ("F", "show only favorites in the focused pane"),
784 ("g", "access: grants and permissions"),
785 ("o", "open in the workspace web UI"),
786 ],
787 ),
788 (
789 "Unity Catalog pane",
790 &[
791 ("enter / backspace", "drill down / up (into volumes too)"),
792 ("p / P", "preview table rows (P picks the warehouse)"),
793 ("L", "lineage: upstream and downstream tables"),
794 (":", "query the selected table"),
795 ("enter on a file", "peek at its first 200 lines"),
796 ],
797 ),
798 (
799 "Details & runs",
800 &[
801 ("enter", "job/pipeline detail → latest run/update"),
802 ("h / l", "older / newer run"),
803 ("o", "full task output — keeps tailing while live"),
804 ("t", "timeline: per-task Gantt of the run"),
805 ("d", "dag: task dependency tree of the run"),
806 ("g", "grid: task states across recent runs, with trends"),
807 ("r", "repair a failed run — reruns only failed tasks"),
808 ("W", "watch the run — bell + flash when it finishes"),
809 ("s", "cancel the shown run"),
810 ("j / k, J", "scroll · toggle raw JSON"),
811 ],
812 ),
813 (
814 "Table previews",
815 &[
816 ("j / k", "scroll rows"),
817 ("← / →", "page columns (wide tables)"),
818 ("/", "filter columns by name"),
819 ("v / enter", "record view: one row, fields stacked"),
820 ("e", "export rows to CSV"),
821 ],
822 ),
823 (
824 "Secret scopes pane",
825 &[
826 ("enter / backspace", "open a scope / back to scopes"),
827 ("a", "create a scope / add a secret (value masked)"),
828 ("x", "delete the selected scope or secret"),
829 ("g", "scope ACLs"),
830 ],
831 ),
832 (
833 "SQL console",
834 &[
835 ("enter", "run the statement"),
836 ("tab", "complete catalog/schema/table/column names"),
837 ("↑ / ↓, ctrl+r", "history · incremental search"),
838 ("ctrl+x", "compose in $EDITOR"),
839 ("ctrl+s", "export results to CSV"),
840 ("pgup / pgdn", "scroll results"),
841 ("shift+← / →", "page result columns"),
842 ("esc", "cancel a running query, else close"),
843 ],
844 ),
845 ];
846 let mut lines: Vec<Line> = Vec::new();
847 for (title, keys) in sections {
848 lines.push(Line::from(Span::styled(
849 *title,
850 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
851 )));
852 for (k, desc) in *keys {
853 lines.push(Line::from(vec![
854 Span::styled(format!(" {k:<24}"), Style::default().fg(p.warn)),
855 Span::styled(*desc, Style::default().fg(p.text)),
856 ]));
857 }
858 lines.push(Line::default());
859 }
860 let total = lines.len();
861 let par = Paragraph::new(lines)
862 .scroll((app.help_scroll, 0))
863 .block(block);
864 f.render_widget(par, popup);
865 scrollbar(
867 f,
868 popup,
869 total,
870 popup.height.saturating_sub(3) as usize,
871 app.help_scroll as usize,
872 p,
873 );
874}
875
876fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
877 let Some(jump) = &app.jump else {
878 return;
879 };
880 let matches = app.jump_matches();
881 let width = 70.min(area.width.saturating_sub(4));
882 let height = (matches.len() as u16 + 5).min(area.height);
883 let popup = Rect {
884 x: area.x + (area.width.saturating_sub(width)) / 2,
885 y: area.y + 2.min(area.height.saturating_sub(height)),
886 width,
887 height,
888 };
889 f.render_widget(Clear, popup);
890 let block = Block::default()
891 .title(Line::from(vec![
892 Span::styled(" ⌕ ", Style::default().fg(p.key)),
893 Span::styled(
894 "Jump to ",
895 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
896 ),
897 ]))
898 .borders(Borders::ALL)
899 .border_type(BorderType::Thick)
900 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
901 .padding(Padding::horizontal(1));
902 let inner = block.inner(popup);
903 f.render_widget(block, popup);
904
905 let parts = Layout::default()
906 .direction(Direction::Vertical)
907 .constraints([Constraint::Length(2), Constraint::Min(0)])
908 .split(inner);
909 f.render_widget(
910 Paragraph::new(Line::from(vec![
911 Span::styled("❯ ", Style::default().fg(p.key)),
912 Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
913 Span::styled("▏", Style::default().fg(p.warn)),
914 ])),
915 parts[0],
916 );
917
918 if matches.is_empty() {
919 f.render_widget(
920 Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
921 parts[1],
922 );
923 return;
924 }
925 let items: Vec<ListItem> = matches
926 .iter()
927 .map(|(panel_idx, name, label)| {
928 let panel = Panel::ALL[*panel_idx];
929 ListItem::new(Line::from(vec![
930 Span::styled(
931 format!("{} ", panel.icon()),
932 Style::default().fg(accent(panel, p)),
933 ),
934 Span::styled(format!("{name} "), Style::default().fg(p.text)),
935 Span::styled(label.clone(), Style::default().fg(p.dim)),
936 ]))
937 })
938 .collect();
939 let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
940 let mut state = ListState::default().with_selected(Some(jump.index));
941 f.render_stateful_widget(list, parts[1], &mut state);
942}
943
944fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
945 let Some(pr) = &app.problems else {
946 return;
947 };
948 let truncate = |s: &str, max: usize| -> String {
949 if s.chars().count() <= max {
950 s.to_string()
951 } else {
952 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
953 format!("{cut}…")
954 }
955 };
956 let prefix_of = |problem: &crate::app::Problem| -> String {
960 problem
961 .profile
962 .as_ref()
963 .map(|pr| format!("{pr} ▸ "))
964 .unwrap_or_default()
965 };
966 let name_w = pr
967 .items
968 .iter()
969 .map(|i| i.name.chars().count() + prefix_of(i).chars().count())
970 .max()
971 .unwrap_or(10)
972 .clamp(10, 40);
973 let note_w = pr
974 .items
975 .iter()
976 .map(|i| i.note.chars().count())
977 .max()
978 .unwrap_or(0);
979 let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
981 let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
982 let note_space = (width as usize).saturating_sub(name_w + 30);
984 let popup = Rect {
985 x: area.x + (area.width.saturating_sub(width)) / 2,
986 y: area.y + (area.height.saturating_sub(height)) / 2,
987 width,
988 height,
989 };
990 f.render_widget(Clear, popup);
991 let mut title_spans = vec![
992 Span::styled(" ✗ ", Style::default().fg(p.err)),
993 Span::styled(
994 format!("Problems · {} ", pr.items.len()),
995 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
996 ),
997 ];
998 if pr.scanning {
999 title_spans.push(Span::styled(
1000 format!("· {} scanning workspaces ", app.spinner()),
1001 Style::default().fg(p.warn),
1002 ));
1003 }
1004 let block = Block::default()
1005 .title(Line::from(title_spans))
1006 .borders(Borders::ALL)
1007 .border_type(BorderType::Rounded)
1008 .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
1009 .padding(Padding::new(1, 1, 1, 1));
1010
1011 if pr.items.is_empty() {
1012 let (text, color) = if pr.scanning {
1013 (
1014 format!(
1015 "✓ nothing failing here — {} scanning other workspaces…",
1016 app.spinner()
1017 ),
1018 p.warn,
1019 )
1020 } else {
1021 ("✓ all clear — nothing failing right now".to_string(), p.ok)
1022 };
1023 let par = Paragraph::new(text)
1024 .style(Style::default().fg(color))
1025 .block(block);
1026 f.render_widget(par, popup);
1027 return;
1028 }
1029
1030 let items: Vec<ListItem> = pr
1031 .items
1032 .iter()
1033 .map(|problem| {
1034 let prefix = prefix_of(problem);
1035 let plen = prefix.chars().count().min(name_w.saturating_sub(4));
1036 let mut spans = vec![
1037 Span::styled("✗ ", Style::default().fg(p.err)),
1038 Span::styled(truncate(&prefix, plen), Style::default().fg(p.dim)),
1039 Span::styled(
1040 format!(
1041 "{:<width$}",
1042 truncate(&problem.name, name_w - plen),
1043 width = name_w + 2 - plen
1044 ),
1045 Style::default().fg(p.text),
1046 ),
1047 ];
1048 spans.push(match problem.panel {
1049 Some(i) => {
1050 let panel = Panel::ALL[i];
1051 Span::styled(
1052 format!("{} {:<18}", panel.icon(), panel.title()),
1053 Style::default().fg(accent(panel, p)),
1054 )
1055 }
1056 None => Span::styled(format!("⌂ {:<18}", "workspace"), Style::default().fg(p.dim)),
1057 });
1058 spans.push(Span::styled(
1059 truncate(&problem.note, note_space),
1060 Style::default().fg(p.dim),
1061 ));
1062 ListItem::new(Line::from(spans))
1063 })
1064 .collect();
1065 let list = List::new(items)
1066 .block(block)
1067 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1068 let mut state = ListState::default().with_selected(Some(pr.index));
1069 f.render_stateful_widget(list, popup, &mut state);
1070}
1071
1072fn draw_upcoming(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1075 let Some(u) = &app.upcoming else {
1076 return;
1077 };
1078 let label_w = u
1079 .items
1080 .iter()
1081 .map(|i| i.next.label.chars().count())
1082 .max()
1083 .unwrap_or(8)
1084 .clamp(8, 18);
1085 let name_w = u
1086 .items
1087 .iter()
1088 .map(|i| i.name.chars().count())
1089 .max()
1090 .unwrap_or(10)
1091 .clamp(10, 40);
1092 let desc_w = u
1093 .items
1094 .iter()
1095 .map(|i| i.next.desc.chars().count())
1096 .max()
1097 .unwrap_or(0);
1098 let width = ((label_w + name_w + desc_w + 12) as u16).min(area.width.saturating_sub(4));
1099 let height = (u.items.len().max(1) as u16 + 4).min(area.height);
1100 let desc_space = (width as usize).saturating_sub(label_w + name_w + 10);
1101 let popup = Rect {
1102 x: area.x + (area.width.saturating_sub(width)) / 2,
1103 y: area.y + (area.height.saturating_sub(height)) / 2,
1104 width,
1105 height,
1106 };
1107 f.render_widget(Clear, popup);
1108 let block = Block::default()
1109 .title(Line::from(vec![
1110 Span::styled(" ⏱ ", Style::default().fg(p.key)),
1111 Span::styled(
1112 format!("Upcoming runs · {} ", u.items.len()),
1113 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1114 ),
1115 ]))
1116 .borders(Borders::ALL)
1117 .border_type(BorderType::Rounded)
1118 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
1119 .padding(Padding::new(1, 1, 1, 1));
1120
1121 if u.loading {
1122 let par = Paragraph::new(format!("{} reading job schedules…", app.spinner()))
1123 .style(Style::default().fg(p.warn))
1124 .block(block);
1125 f.render_widget(par, popup);
1126 return;
1127 }
1128 if u.items.is_empty() {
1129 let par = Paragraph::new("no scheduled or triggered jobs — everything runs on demand")
1130 .style(Style::default().fg(p.dim))
1131 .block(block);
1132 f.render_widget(par, popup);
1133 return;
1134 }
1135
1136 let truncate = |s: &str, max: usize| -> String {
1137 if s.chars().count() <= max {
1138 s.to_string()
1139 } else {
1140 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
1141 format!("{cut}…")
1142 }
1143 };
1144 let items: Vec<ListItem> = u
1145 .items
1146 .iter()
1147 .map(|it| {
1148 let when_color = if it.next.paused {
1149 p.dim
1150 } else if it.next.at_ms.is_some() {
1151 p.ok
1152 } else {
1153 p.warn
1154 };
1155 ListItem::new(Line::from(vec![
1156 Span::styled(
1157 format!(
1158 "{:<width$}",
1159 truncate(&it.next.label, label_w),
1160 width = label_w + 2
1161 ),
1162 Style::default().fg(when_color),
1163 ),
1164 Span::styled(
1165 format!("{:<width$}", truncate(&it.name, name_w), width = name_w + 2),
1166 Style::default().fg(p.text),
1167 ),
1168 Span::styled(
1169 truncate(&it.next.desc, desc_space),
1170 Style::default().fg(p.dim),
1171 ),
1172 ]))
1173 })
1174 .collect();
1175 let list = List::new(items)
1176 .block(block)
1177 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1178 let mut state = ListState::default().with_selected(Some(u.index));
1179 f.render_stateful_widget(list, popup, &mut state);
1180}
1181
1182fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1183 let Some(picker) = &app.wh_picker else {
1184 return;
1185 };
1186 let warehouses = app.warehouses();
1187 let name_w = warehouses
1188 .iter()
1189 .map(|(n, _, _)| n.chars().count())
1190 .max()
1191 .unwrap_or(12)
1192 .max(12);
1193 let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
1195 let height = (warehouses.len() as u16 + 4).min(area.height);
1196 let popup = Rect {
1197 x: area.x + (area.width.saturating_sub(width)) / 2,
1198 y: area.y + (area.height.saturating_sub(height)) / 2,
1199 width,
1200 height,
1201 };
1202 f.render_widget(Clear, popup);
1203 let block = Block::default()
1204 .title(Line::from(vec![
1205 Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
1206 Span::styled(
1207 "Run query on ",
1208 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1209 ),
1210 ]))
1211 .borders(Borders::ALL)
1212 .border_type(BorderType::Rounded)
1213 .border_style(
1214 Style::default()
1215 .fg(p.warehouses)
1216 .add_modifier(Modifier::BOLD),
1217 )
1218 .padding(Padding::new(1, 1, 1, 1));
1219 let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
1220 let items: Vec<ListItem> = warehouses
1221 .iter()
1222 .map(|(name, id, running)| {
1223 let dot = if *running { p.ok } else { p.dim };
1224 let marker = if current_id == Some(id.as_str()) {
1225 "» "
1226 } else {
1227 " "
1228 };
1229 let state = if *running { "running" } else { "idle " };
1230 ListItem::new(Line::from(vec![
1231 Span::styled(marker, Style::default().fg(p.key)),
1232 Span::styled("● ", Style::default().fg(dot)),
1233 Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
1234 Span::styled(
1235 format!(" {state}"),
1236 Style::default().fg(if *running { p.ok } else { p.dim }),
1237 ),
1238 Span::styled(format!(" {id}"), Style::default().fg(p.dim)),
1239 ]))
1240 })
1241 .collect();
1242 let list = List::new(items)
1243 .block(block)
1244 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1245 let mut state = ListState::default().with_selected(Some(picker.index));
1246 f.render_stateful_widget(list, popup, &mut state);
1247}
1248
1249const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
1250const WORDMARK_BOT: &str = "█▄▀ █▀█ █ █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
1251
1252fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1253 let lines_total = 9u16;
1254 let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
1255 let frame = app.spinner_frame();
1256
1257 let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
1259 for (i, row) in bricks.iter().enumerate() {
1260 let line = Line::from(Span::styled(
1261 *row,
1262 Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
1263 ));
1264 let rect = Rect {
1265 x: area.x,
1266 y: top + i as u16,
1267 width: area.width,
1268 height: 1,
1269 };
1270 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1271 }
1272
1273 let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
1275 for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
1276 let spans: Vec<Span> = text
1277 .chars()
1278 .enumerate()
1279 .map(|(i, c)| {
1280 let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
1281 let style = if dist < 3 {
1282 Style::default()
1283 .fg(Color::White)
1284 .add_modifier(Modifier::BOLD)
1285 } else {
1286 Style::default().fg(p.brand)
1287 };
1288 Span::styled(c.to_string(), style)
1289 })
1290 .collect();
1291 let rect = Rect {
1292 x: area.x,
1293 y: top + row,
1294 width: area.width,
1295 height: 1,
1296 };
1297 f.render_widget(
1298 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
1299 rect,
1300 );
1301 }
1302
1303 let tagline = Line::from(vec![
1304 Span::styled("the ", Style::default().fg(p.dim)),
1305 Span::styled(
1306 "Lakehouse",
1307 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1308 ),
1309 Span::styled(" in your terminal", Style::default().fg(p.dim)),
1310 ]);
1311 let version = Line::from(Span::styled(
1312 format!("v{}", env!("CARGO_PKG_VERSION")),
1313 Style::default().fg(p.dim),
1314 ));
1315 for (offset, line) in [(7u16, tagline), (8u16, version)] {
1316 let rect = Rect {
1317 x: area.x,
1318 y: top + offset,
1319 width: area.width,
1320 height: 1,
1321 };
1322 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1323 }
1324}
1325
1326fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1327 let selected = app.picker.unwrap_or(0);
1328 let width = (app
1329 .profiles
1330 .iter()
1331 .map(|s| s.len())
1332 .max()
1333 .unwrap_or(10)
1334 .max(20) as u16
1335 + 6)
1336 .min(area.width);
1337 let height = (app.profiles.len() as u16 + 2).min(area.height);
1338 let popup = Rect {
1339 x: area.x + (area.width.saturating_sub(width)) / 2,
1340 y: area.y + (area.height.saturating_sub(height)) / 2,
1341 width,
1342 height,
1343 };
1344 f.render_widget(Clear, popup);
1345 let block = Block::default()
1346 .title(Line::from(Span::styled(
1347 " ⌂ Workspace ",
1348 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1349 )))
1350 .borders(Borders::ALL)
1351 .border_type(BorderType::Rounded)
1352 .border_style(Style::default().fg(p.key))
1353 .padding(Padding::horizontal(1));
1354 let items: Vec<ListItem> = app
1355 .profiles
1356 .iter()
1357 .map(|name| {
1358 let current = app.profile.as_deref() == Some(name.as_str());
1359 let marker = if current { "● " } else { " " };
1360 ListItem::new(Line::from(vec![
1361 Span::styled(marker, Style::default().fg(p.ok)),
1362 Span::styled(name.as_str(), Style::default().fg(p.text)),
1363 ]))
1364 })
1365 .collect();
1366 let list = List::new(items)
1367 .block(block)
1368 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1369 let mut state = ListState::default().with_selected(Some(selected));
1370 f.render_stateful_widget(list, popup, &mut state);
1371}
1372
1373fn bucket_color(bucket: &str, p: &Palette) -> Color {
1374 match bucket {
1375 "Jobs" => p.jobs,
1376 "SQL" => p.warehouses,
1377 "All-Purpose" => p.clusters,
1378 "DLT" => p.pipelines,
1379 _ => p.dim,
1380 }
1381}
1382
1383fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1384 let cv = app.cost.as_ref().unwrap();
1385 let scope = match &cv.data {
1386 Some(Ok(d)) if d.scoped => " · this workspace",
1387 Some(Ok(_)) => " · all workspaces",
1388 _ => "",
1389 };
1390 let title = Line::from(vec![
1391 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1392 Span::styled(
1393 format!("Usage · last 14 days{scope} "),
1394 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1395 ),
1396 Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1397 ]);
1398 let block = Block::default()
1399 .title(title)
1400 .borders(Borders::ALL)
1401 .border_type(BorderType::Rounded)
1402 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1403 .padding(Padding::new(1, 1, 1, 1));
1404
1405 match &cv.data {
1406 None => {
1407 let par = Paragraph::new(format!(
1408 "{} querying system.billing.usage — the warehouse may need to start…",
1409 app.spinner()
1410 ))
1411 .style(Style::default().fg(p.warn))
1412 .block(block);
1413 f.render_widget(par, area);
1414 }
1415 Some(Err(e)) => {
1416 let par = Paragraph::new(format!(
1417 "✗ {e}\n\nsystem tables need to be enabled and readable \
1418 (grants on the `system` catalog)"
1419 ))
1420 .style(Style::default().fg(p.err))
1421 .wrap(Wrap { trim: false })
1422 .block(block);
1423 f.render_widget(par, area);
1424 }
1425 Some(Ok(data)) if data.days.is_empty() => {
1426 let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1427 .style(Style::default().fg(p.dim))
1428 .block(block);
1429 f.render_widget(par, area);
1430 }
1431 Some(Ok(data)) => {
1432 let mut lines: Vec<Line> = Vec::new();
1433
1434 let mut legend = vec![Span::raw("")];
1436 for (bucket, total, usd) in &data.buckets {
1437 legend.push(Span::styled(
1438 "■ ",
1439 Style::default().fg(bucket_color(bucket, p)),
1440 ));
1441 let amount = if data.priced {
1442 format!("{bucket} {total:.1} (${usd:.0}) ")
1443 } else {
1444 format!("{bucket} {total:.1} ")
1445 };
1446 legend.push(Span::styled(amount, Style::default().fg(p.text)));
1447 }
1448 let sigma = if data.priced {
1449 format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1450 } else {
1451 format!("Σ {:.1} DBU", data.total)
1452 };
1453 legend.push(Span::styled(
1454 sigma,
1455 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1456 ));
1457 lines.push(Line::from(legend));
1458 if data.priced {
1459 lines.push(Line::from(Span::styled(
1460 " list prices before discounts",
1461 Style::default().fg(p.dim),
1462 )));
1463 }
1464 if !data.scoped {
1465 lines.push(Line::from(Span::styled(
1466 " couldn't resolve this workspace's id — showing the whole account",
1467 Style::default().fg(p.warn),
1468 )));
1469 }
1470 lines.push(Line::default());
1471
1472 let max_day = data
1473 .days
1474 .iter()
1475 .map(|d| d.total)
1476 .fold(0.0_f64, f64::max)
1477 .max(f64::EPSILON);
1478 let inner_w = area.width.saturating_sub(4) as usize;
1479 let bar_w = inner_w.saturating_sub(22).max(10);
1480
1481 for day in &data.days {
1482 let mut spans = vec![Span::styled(
1483 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1485 Style::default().fg(p.dim),
1486 )];
1487 for (bucket, value) in &day.by_bucket {
1488 let chars = ((value / max_day) * bar_w as f64)
1489 .round()
1490 .max(if *value > 0.0 { 1.0 } else { 0.0 })
1491 as usize;
1492 spans.push(Span::styled(
1493 "█".repeat(chars),
1494 Style::default().fg(bucket_color(bucket, p)),
1495 ));
1496 }
1497 let day_label = if data.priced {
1498 format!(" {:.1} · ${:.2}", day.total, day.total_usd)
1499 } else {
1500 format!(" {:.1}", day.total)
1501 };
1502 spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1503 lines.push(Line::from(spans));
1504 }
1505
1506 if !data.spenders.is_empty() {
1507 lines.push(Line::default());
1508 lines.push(Line::from(Span::styled(
1509 "TOP SPENDERS · 14 days",
1510 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1511 )));
1512 let name_w = data
1513 .spenders
1514 .iter()
1515 .map(|s| {
1516 app.resource_name(&s.kind, &s.id)
1517 .unwrap_or_else(|| s.id.clone())
1518 .chars()
1519 .count()
1520 })
1521 .max()
1522 .unwrap_or(0)
1523 .max(8);
1524 for (i, s) in data.spenders.iter().enumerate() {
1525 let name = app
1526 .resource_name(&s.kind, &s.id)
1527 .unwrap_or_else(|| s.id.clone());
1528 let kind_color = match s.kind.as_str() {
1529 "job" => p.jobs,
1530 "cluster" => p.clusters,
1531 "warehouse" => p.warehouses,
1532 _ => p.dim,
1533 };
1534 let amount = if data.priced {
1535 format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1536 } else {
1537 format!("{:>8.1} DBU", s.dbus)
1538 };
1539 lines.push(Line::from(vec![
1540 Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1541 Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1542 Span::styled(
1543 format!("{:<width$}", name, width = name_w + 2),
1544 Style::default().fg(p.text),
1545 ),
1546 Span::styled(amount, Style::default().fg(p.text)),
1547 ]));
1548 }
1549 }
1550
1551 let par = Paragraph::new(lines).block(block);
1552 f.render_widget(par, area);
1553 }
1554 }
1555}
1556
1557fn month_bars(
1559 months: &[(String, f64, f64)],
1560 priced: bool,
1561 width: usize,
1562 p: &Palette,
1563) -> Vec<Line<'static>> {
1564 let max = months
1565 .iter()
1566 .map(|(_, dbus, _)| *dbus)
1567 .fold(0.0_f64, f64::max)
1568 .max(f64::EPSILON);
1569 let bar_w = width.saturating_sub(30).max(10);
1570 months
1571 .iter()
1572 .map(|(month, dbus, usd)| {
1573 let chars =
1574 ((dbus / max) * bar_w as f64)
1575 .round()
1576 .max(if *dbus > 0.0 { 1.0 } else { 0.0 }) as usize;
1577 let amount = if priced {
1578 format!(" {dbus:.1} · ${usd:.2}")
1579 } else {
1580 format!(" {dbus:.1}")
1581 };
1582 Line::from(vec![
1583 Span::styled(format!("{month:<9}"), Style::default().fg(p.dim)),
1584 Span::styled("█".repeat(chars), Style::default().fg(p.jobs)),
1585 Span::styled(amount, Style::default().fg(p.text)),
1586 ])
1587 })
1588 .collect()
1589}
1590
1591fn draw_item_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1592 let cv = app.item_cost.as_ref().unwrap();
1593 let scope = match &cv.data {
1594 Some(Ok(d)) if d.scoped => " · this workspace",
1595 Some(Ok(_)) => " · all workspaces",
1596 _ => "",
1597 };
1598 let title = Line::from(vec![
1599 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1600 Span::styled(
1601 format!("Spend · {} ", cv.name),
1602 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1603 ),
1604 Span::styled(
1605 format!("· {}{scope} · via {} ", cv.kind.label(), cv.warehouse),
1606 Style::default().fg(p.dim),
1607 ),
1608 ]);
1609 let block = Block::default()
1610 .title(title)
1611 .borders(Borders::ALL)
1612 .border_type(BorderType::Rounded)
1613 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1614 .padding(Padding::new(1, 1, 1, 1));
1615
1616 match &cv.data {
1617 None => {
1618 let par = Paragraph::new(format!(
1619 "{} querying system.billing.usage — the warehouse may need to start…",
1620 app.spinner()
1621 ))
1622 .style(Style::default().fg(p.warn))
1623 .block(block);
1624 f.render_widget(par, area);
1625 }
1626 Some(Err(e)) => {
1627 let par = Paragraph::new(format!(
1628 "✗ {e}\n\nsystem tables need to be enabled and readable \
1629 (grants on the `system` catalog)"
1630 ))
1631 .style(Style::default().fg(p.err))
1632 .wrap(Wrap { trim: false })
1633 .block(block);
1634 f.render_widget(par, area);
1635 }
1636 Some(Ok(data)) if data.is_empty() => {
1637 let hint = match data.kind {
1640 fetchers::cost::ResourceKind::Job => {
1641 "\n\nruns on all-purpose compute aren't attributed to a job id — \
1642 only job compute and serverless are"
1643 }
1644 fetchers::cost::ResourceKind::Pipeline => "",
1645 };
1646 let par = Paragraph::new(format!(
1647 "∅ no usage attributed to this {} in the last 365 days{hint}",
1648 data.kind.label()
1649 ))
1650 .style(Style::default().fg(p.dim))
1651 .wrap(Wrap { trim: false })
1652 .block(block);
1653 f.render_widget(par, area);
1654 }
1655 Some(Ok(data)) => {
1656 let mut lines: Vec<Line> = vec![Line::from(vec![
1657 Span::styled(
1658 format!("{:<14}{:>12}", "PERIOD", "DBU"),
1659 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1660 ),
1661 Span::styled(
1662 if data.priced {
1663 format!("{:>12}{:>14}", "USD", "VS PRIOR")
1664 } else {
1665 format!("{:>14}", "VS PRIOR")
1666 },
1667 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1668 ),
1669 ])];
1670 for w in &data.windows {
1671 let mut spans = vec![
1672 Span::styled(format!("{:<14}", w.label), Style::default().fg(p.text)),
1673 Span::styled(
1674 format!("{:>12.1}", w.dbus),
1675 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1676 ),
1677 ];
1678 if data.priced {
1679 spans.push(Span::styled(
1680 format!("{:>12}", format!("${:.2}", w.usd)),
1681 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1682 ));
1683 }
1684 let (trend, color) = match w.trend(data.priced) {
1686 Some(pct) if pct > 0.005 => (format!("▲ {:.0}%", pct * 100.0), p.warn),
1687 Some(pct) if pct < -0.005 => (format!("▼ {:.0}%", -pct * 100.0), p.ok),
1688 Some(_) => ("~ flat".to_string(), p.dim),
1689 None => ("—".to_string(), p.dim),
1690 };
1691 spans.push(Span::styled(
1692 format!("{trend:>14}"),
1693 Style::default().fg(color),
1694 ));
1695 lines.push(Line::from(spans));
1696 }
1697
1698 lines.push(Line::default());
1699 lines.push(Line::from(Span::styled(
1700 if data.priced {
1701 " list prices before discounts · trend compares with the preceding window"
1702 } else {
1703 " trend compares with the preceding window"
1704 },
1705 Style::default().fg(p.dim),
1706 )));
1707 if !data.scoped {
1708 lines.push(Line::from(Span::styled(
1709 " couldn't resolve this workspace's id — showing the whole account",
1710 Style::default().fg(p.warn),
1711 )));
1712 }
1713
1714 lines.push(Line::default());
1715 lines.push(Line::from(Span::styled(
1716 "BY MONTH",
1717 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1718 )));
1719 let inner_w = area.width.saturating_sub(4) as usize;
1720 lines.extend(month_bars(&data.months, data.priced, inner_w, p));
1721
1722 let par = Paragraph::new(lines).block(block);
1723 f.render_widget(par, area);
1724 }
1725 }
1726}
1727
1728fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1729 let console = app.sql.as_ref().unwrap();
1730 let parts = Layout::default()
1731 .direction(Direction::Vertical)
1732 .constraints([Constraint::Length(3), Constraint::Min(0)])
1733 .split(area);
1734
1735 let via = if console.warehouse.is_empty() {
1736 String::new()
1737 } else {
1738 format!("via {} ", console.warehouse)
1739 };
1740 let prompt_block = Block::default()
1741 .title(Line::from(vec![
1742 Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1743 Span::styled(
1744 "SQL Console ",
1745 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1746 ),
1747 Span::styled(via, Style::default().fg(p.dim)),
1748 ]))
1749 .borders(Borders::ALL)
1750 .border_type(BorderType::Thick)
1751 .border_style(
1752 Style::default()
1753 .fg(p.warehouses)
1754 .add_modifier(Modifier::BOLD),
1755 )
1756 .padding(Padding::horizontal(1));
1757 if let Some((query, _)) = &app.hist_search {
1758 let matched = app
1760 .hist_search_current()
1761 .map(|s| s.replace('\n', " "))
1762 .unwrap_or_default();
1763 let mut spans = vec![
1764 Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1765 Span::styled(
1766 query.as_str(),
1767 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1768 ),
1769 Span::styled("`: ", Style::default().fg(p.dim)),
1770 ];
1771 spans.extend(sql_spans(&matched, p));
1772 let prompt = Paragraph::new(Line::from(spans)).block(prompt_block);
1773 f.render_widget(prompt, parts[0]);
1774 } else {
1775 let caret_byte = console
1779 .input
1780 .char_indices()
1781 .nth(console.cursor)
1782 .map(|(i, _)| i)
1783 .unwrap_or(console.input.len());
1784 let (before, after) = console.input.split_at(caret_byte);
1785 let inner_w = parts[0].width.saturating_sub(4) as usize; let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1787 let mut spans = vec![Span::styled("❯ ", Style::default().fg(p.key))];
1790 spans.extend(sql_spans(&before.replace('\n', " "), p));
1791 spans.push(Span::styled("▏", Style::default().fg(p.warn)));
1792 spans.extend(sql_spans(&after.replace('\n', " "), p));
1793 let prompt = Paragraph::new(Line::from(spans))
1794 .scroll((0, hscroll))
1795 .block(prompt_block);
1796 f.render_widget(prompt, parts[0]);
1797 }
1798
1799 let row_info = match &console.data {
1800 Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1801 _ => String::new(),
1802 };
1803 let mut results_title = vec![
1804 Span::styled(" Results ", Style::default().fg(p.text)),
1805 Span::styled(row_info, Style::default().fg(p.dim)),
1806 ];
1807 let avail = parts[1].width.saturating_sub(4) as usize;
1809 let sliced = match &console.data {
1810 Some(Ok(t)) if !t.headers.is_empty() => {
1811 let cols: Vec<usize> = (0..t.headers.len()).collect();
1812 let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
1813 if shown.len() < cols.len() {
1814 let first = console.col.min(cols.len() - 1) + 1;
1815 results_title.push(Span::styled(
1816 format!(
1817 "· cols {}–{} of {} (⇧←/→) ",
1818 first,
1819 first + shown.len() - 1,
1820 cols.len()
1821 ),
1822 Style::default().fg(p.dim),
1823 ));
1824 }
1825 Some((shown, constraints))
1826 }
1827 _ => None,
1828 };
1829 let results_block = Block::default()
1830 .title(Line::from(results_title))
1831 .borders(Borders::ALL)
1832 .border_type(BorderType::Rounded)
1833 .border_style(Style::default().fg(p.border))
1834 .padding(Padding::horizontal(1));
1835
1836 if console.running {
1837 let par = Paragraph::new(format!(
1838 "{} running on {} — the warehouse may need a moment to start…",
1839 app.spinner(),
1840 console.warehouse
1841 ))
1842 .style(Style::default().fg(p.warn))
1843 .block(results_block);
1844 f.render_widget(par, parts[1]);
1845 return;
1846 }
1847 match &console.data {
1848 None => {
1849 let par = Paragraph::new(
1850 "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1851 )
1852 .style(Style::default().fg(p.dim))
1853 .block(results_block);
1854 f.render_widget(par, parts[1]);
1855 }
1856 Some(Err(e)) => {
1857 let par = Paragraph::new(format!("✗ {e}"))
1858 .style(Style::default().fg(p.err))
1859 .wrap(Wrap { trim: false })
1860 .block(results_block);
1861 f.render_widget(par, parts[1]);
1862 }
1863 Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1864 let par = Paragraph::new("✓ statement succeeded — no result set")
1865 .style(Style::default().fg(p.ok))
1866 .block(results_block);
1867 f.render_widget(par, parts[1]);
1868 }
1869 Some(Ok(data)) => {
1870 let (shown, constraints) = sliced.unwrap_or_default();
1871 let header_cells: Vec<Cell> = shown
1872 .iter()
1873 .map(|&i| {
1874 Cell::from(data.headers[i].as_str()).style(
1875 Style::default()
1876 .fg(p.warehouses)
1877 .add_modifier(Modifier::BOLD),
1878 )
1879 })
1880 .collect();
1881 let header = Row::new(header_cells);
1882 let rows: Vec<Row> = data
1883 .rows
1884 .iter()
1885 .skip(console.scroll)
1886 .map(|r| {
1887 Row::new(
1888 shown
1889 .iter()
1890 .map(|&i| {
1891 Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
1892 .style(Style::default().fg(p.text))
1893 })
1894 .collect::<Vec<_>>(),
1895 )
1896 })
1897 .collect();
1898 let table = Table::new(rows, constraints)
1899 .header(header)
1900 .column_spacing(1)
1901 .block(results_block);
1902 f.render_widget(table, parts[1]);
1903 scrollbar(
1905 f,
1906 parts[1],
1907 data.rows.len(),
1908 parts[1].height.saturating_sub(3) as usize,
1909 console.scroll,
1910 p,
1911 );
1912 }
1913 }
1914}
1915
1916fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1918 let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
1919 return;
1920 };
1921 let inner_w = area.width.saturating_sub(4) as usize;
1923 let hscroll = (console.cursor + 3).saturating_sub(inner_w);
1924 let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
1925
1926 let lines: Vec<Line> = if comp.loading {
1927 vec![Line::from(Span::styled(
1928 format!("{} fetching names…", app.spinner()),
1929 Style::default().fg(p.warn),
1930 ))]
1931 } else {
1932 let visible = 8.min(comp.items.len());
1934 let skip = comp.index.saturating_sub(visible.saturating_sub(1));
1935 comp.items
1936 .iter()
1937 .enumerate()
1938 .skip(skip)
1939 .take(visible)
1940 .map(|(i, item)| {
1941 let style = if i == comp.index {
1942 Style::default()
1943 .fg(p.key)
1944 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1945 } else {
1946 Style::default().fg(p.text)
1947 };
1948 Line::from(Span::styled(format!(" {item} "), style))
1949 })
1950 .collect()
1951 };
1952 let widest = comp
1953 .items
1954 .iter()
1955 .map(|i| i.chars().count())
1956 .max()
1957 .unwrap_or(16);
1958 let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
1959 let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
1960 let popup = Rect {
1961 x: anchor.min(area.x + area.width.saturating_sub(width)),
1962 y: area.y + 3,
1963 width,
1964 height,
1965 };
1966 let title = if comp.loading {
1967 String::new()
1968 } else {
1969 format!(" {}/{} ", comp.index + 1, comp.items.len())
1970 };
1971 let block = Block::default()
1972 .title(Span::styled(title, Style::default().fg(p.dim)))
1973 .borders(Borders::ALL)
1974 .border_type(BorderType::Rounded)
1975 .border_style(Style::default().fg(p.key));
1976 f.render_widget(Clear, popup);
1977 f.render_widget(Paragraph::new(lines).block(block), popup);
1978}
1979
1980fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
1983 cols.iter()
1984 .map(|&i| {
1985 let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
1986 for row in data.rows.iter().take(50) {
1987 if let Some(c) = row.get(i) {
1988 w = w.max(c.chars().count());
1989 }
1990 }
1991 w.clamp(4, 36) as u16
1992 })
1993 .collect()
1994}
1995
1996fn grid_slice(
2001 data: &TableData,
2002 cols: &[usize],
2003 col: usize,
2004 avail: usize,
2005) -> (Vec<usize>, Vec<Constraint>) {
2006 let widths = grid_widths(data, cols);
2007 let col = col.min(cols.len().saturating_sub(1));
2008 let mut take = 0usize;
2009 let mut used = 0usize;
2010 for w in widths.iter().skip(col) {
2011 let next = used + *w as usize + if take > 0 { 1 } else { 0 };
2012 if take > 0 && next > avail {
2013 break;
2014 }
2015 used = next;
2016 take += 1;
2017 }
2018 let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
2019 let constraints: Vec<Constraint> = widths
2020 .iter()
2021 .skip(col)
2022 .take(take)
2023 .map(|&w| Constraint::Length(w))
2024 .collect();
2025 (shown, constraints)
2026}
2027
2028fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2029 let pv = app.preview.as_ref().unwrap();
2030 let acc = p.catalog;
2031 let mut title_spans: Vec<Span<'static>> = vec![
2032 Span::styled(" ◫ ", Style::default().fg(acc)),
2033 Span::styled(
2034 format!("{} · preview ", pv.name),
2035 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2036 ),
2037 Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
2038 ];
2039 let make_block = |spans: Vec<Span<'static>>| {
2040 Block::default()
2041 .title(Line::from(spans))
2042 .borders(Borders::ALL)
2043 .border_type(BorderType::Rounded)
2044 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2045 .padding(Padding::horizontal(1))
2046 };
2047
2048 match &pv.data {
2049 None => {
2050 let par = Paragraph::new(format!(
2051 "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
2052 app.spinner()
2053 ))
2054 .style(Style::default().fg(p.warn))
2055 .block(make_block(title_spans));
2056 f.render_widget(par, area);
2057 }
2058 Some(Err(e)) => {
2059 let text = format!(
2060 "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
2061 pv.warehouse,
2062 pv.warehouse_id,
2063 app.profile.as_deref().unwrap_or("default"),
2064 app.host.as_deref().unwrap_or("unknown"),
2065 );
2066 let par = Paragraph::new(text)
2067 .style(Style::default().fg(p.err))
2068 .wrap(Wrap { trim: false })
2069 .block(make_block(title_spans));
2070 f.render_widget(par, area);
2071 }
2072 Some(Ok(data)) => {
2073 title_spans.push(Span::styled(
2074 format!("· {} rows ", data.rows.len()),
2075 Style::default().fg(p.dim),
2076 ));
2077 let cols = pv.visible_cols();
2078 if !pv.filter.is_empty() {
2079 title_spans.push(Span::styled(
2080 format!("· cols /{} ", pv.filter),
2081 Style::default().fg(p.warn),
2082 ));
2083 }
2084 if cols.is_empty() {
2085 let par = Paragraph::new(format!(
2086 "no columns match /{} — backspace to widen, esc to clear",
2087 pv.filter
2088 ))
2089 .style(Style::default().fg(p.warn))
2090 .block(make_block(title_spans));
2091 f.render_widget(par, area);
2092 return;
2093 }
2094 if pv.record {
2095 let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
2098 title_spans.push(Span::styled(
2099 format!("· row {}/{} ", row_n + 1, data.rows.len()),
2100 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2101 ));
2102 let name_w = cols
2103 .iter()
2104 .map(|&i| data.headers[i].chars().count())
2105 .max()
2106 .unwrap_or(0)
2107 .min(32);
2108 let empty: Vec<String> = Vec::new();
2109 let row = data.rows.get(row_n).unwrap_or(&empty);
2110 let lines: Vec<Line> = cols
2111 .iter()
2112 .map(|&i| {
2113 let value = row.get(i).map(String::as_str).unwrap_or("");
2114 let vstyle = if value == "␀" {
2115 Style::default().fg(p.dim)
2116 } else {
2117 Style::default().fg(p.text)
2118 };
2119 Line::from(vec![
2120 Span::styled(
2121 format!("{:<name_w$} ", data.headers[i]),
2122 Style::default().fg(acc),
2123 ),
2124 Span::styled(value.to_string(), vstyle),
2125 ])
2126 })
2127 .collect();
2128 let total = lines.len();
2129 let par = Paragraph::new(lines)
2130 .scroll((pv.rscroll, 0))
2131 .block(make_block(title_spans));
2132 f.render_widget(par, area);
2133 scrollbar(
2134 f,
2135 area,
2136 total,
2137 area.height.saturating_sub(2) as usize,
2138 pv.rscroll as usize,
2139 p,
2140 );
2141 return;
2142 }
2143 let avail = area.width.saturating_sub(4) as usize; let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
2145 if shown.len() < cols.len() {
2146 let first = pv.col.min(cols.len() - 1) + 1;
2147 title_spans.push(Span::styled(
2148 format!(
2149 "· cols {}–{} of {} ",
2150 first,
2151 first + shown.len() - 1,
2152 cols.len()
2153 ),
2154 Style::default().fg(p.dim),
2155 ));
2156 }
2157 let header = Row::new(
2158 shown
2159 .iter()
2160 .map(|&i| {
2161 Cell::from(data.headers[i].clone())
2162 .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2163 })
2164 .collect::<Vec<_>>(),
2165 );
2166 let rows: Vec<Row> = data
2167 .rows
2168 .iter()
2169 .skip(pv.scroll)
2170 .map(|r| {
2171 Row::new(
2172 shown
2173 .iter()
2174 .map(|&i| {
2175 Cell::from(r.get(i).cloned().unwrap_or_default())
2176 .style(Style::default().fg(p.text))
2177 })
2178 .collect::<Vec<_>>(),
2179 )
2180 })
2181 .collect();
2182 let table = Table::new(rows, constraints)
2183 .header(header)
2184 .column_spacing(1)
2185 .block(make_block(title_spans));
2186 f.render_widget(table, area);
2187 scrollbar(
2189 f,
2190 area,
2191 data.rows.len(),
2192 area.height.saturating_sub(3) as usize,
2193 pv.scroll,
2194 p,
2195 );
2196 }
2197 }
2198}
2199
2200fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2201 let d = app.detail.as_ref().unwrap();
2202 let acc = accent(d.panel, p);
2203 let title = Line::from(vec![
2204 Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
2205 Span::styled(
2206 format!("{} · {} ", d.panel.title(), d.name),
2207 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2208 ),
2209 ]);
2210 let block = Block::default()
2211 .title(title)
2212 .borders(Borders::ALL)
2213 .border_type(BorderType::Rounded)
2214 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2215 .padding(Padding::horizontal(1));
2216
2217 let Some(data) = &d.data else {
2218 let par = Paragraph::new(format!("{} Loading…", app.spinner()))
2219 .style(Style::default().fg(p.warn))
2220 .block(block);
2221 f.render_widget(par, area);
2222 return;
2223 };
2224
2225 if d.show_raw || data.summary.is_empty() {
2227 let par = Paragraph::new(data.raw.as_str())
2228 .style(Style::default().fg(p.text))
2229 .wrap(Wrap { trim: false })
2230 .scroll((d.scroll, 0))
2231 .block(block);
2232 f.render_widget(par, area);
2233 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2234 scrollbar(
2235 f,
2236 area,
2237 total,
2238 area.height.saturating_sub(2) as usize,
2239 d.scroll as usize,
2240 p,
2241 );
2242 return;
2243 }
2244
2245 let mut lines: Vec<Line> = data
2246 .summary
2247 .iter()
2248 .map(|(k, v)| {
2249 Line::from(vec![
2250 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2251 Span::styled(v.as_str(), Style::default().fg(p.text)),
2252 ])
2253 })
2254 .collect();
2255 if !data.activity.is_empty() {
2256 lines.push(Line::default());
2257 lines.push(Line::from(Span::styled(
2258 d.section,
2259 Style::default().fg(acc).add_modifier(Modifier::BOLD),
2260 )));
2261 for (status, text) in &data.activity {
2262 let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
2264 if treeish {
2265 lines.push(Line::from(Span::styled(
2266 text.as_str(),
2267 Style::default().fg(status_color(status, p)),
2268 )));
2269 } else {
2270 lines.push(Line::from(vec![
2271 Span::styled("● ", Style::default().fg(status_color(status, p))),
2272 Span::styled(text.as_str(), Style::default().fg(p.text)),
2273 ]));
2274 }
2275 }
2276 }
2277 let total = lines.len();
2278 let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
2279 f.render_widget(par, area);
2280 scrollbar(
2281 f,
2282 area,
2283 total,
2284 area.height.saturating_sub(2) as usize,
2285 d.scroll as usize,
2286 p,
2287 );
2288}
2289
2290fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2291 let rv = app.run_view.as_ref().unwrap();
2292 let acc = accent(rv.panel, p);
2293 let noun = if rv.panel == Panel::Jobs {
2294 "run"
2295 } else {
2296 "update"
2297 };
2298 let mut title_spans = vec![
2299 Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
2300 Span::styled(
2301 format!("{} · {noun} ", rv.owner_name),
2302 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2303 ),
2304 ];
2305 if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
2306 title_spans.push(Span::styled(
2307 format!("{run_id} "),
2308 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2309 ));
2310 title_spans.push(Span::styled(
2311 format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
2312 Style::default().fg(p.dim),
2313 ));
2314 }
2315 if rv.live {
2316 title_spans.push(Span::styled(
2317 format!("{} live ", app.spinner()),
2318 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2319 ));
2320 }
2321 if rv.show_output {
2322 title_spans.push(Span::styled(
2323 if rv.live {
2324 "· output (tailing) "
2325 } else {
2326 "· output "
2327 },
2328 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2329 ));
2330 }
2331 if rv.show_timeline {
2332 title_spans.push(Span::styled(
2333 "· timeline ",
2334 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2335 ));
2336 }
2337 if rv.show_dag {
2338 title_spans.push(Span::styled(
2339 "· dag ",
2340 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2341 ));
2342 }
2343 if rv.show_grid {
2344 title_spans.push(Span::styled(
2345 "· history grid ",
2346 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2347 ));
2348 }
2349 let block = Block::default()
2350 .title(Line::from(title_spans))
2351 .borders(Borders::ALL)
2352 .border_type(BorderType::Rounded)
2353 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2354 .padding(Padding::horizontal(1));
2355
2356 if rv.show_output {
2358 let par = match &rv.output {
2359 Some(text) => {
2360 let lines: Vec<Line> = text.lines().map(|l| output_line(l, p)).collect();
2361 Paragraph::new(lines)
2362 .wrap(Wrap { trim: false })
2363 .scroll((rv.scroll, 0))
2364 .block(block)
2365 }
2366 None => Paragraph::new(format!(
2367 "{} fetching task output — one call per task…",
2368 app.spinner()
2369 ))
2370 .style(Style::default().fg(p.warn))
2371 .block(block),
2372 };
2373 f.render_widget(par, area);
2374 if let Some(text) = &rv.output {
2375 let total = wrapped_height(text, area.width.saturating_sub(4) as usize);
2376 scrollbar(
2377 f,
2378 area,
2379 total,
2380 area.height.saturating_sub(2) as usize,
2381 rv.scroll as usize,
2382 p,
2383 );
2384 }
2385 return;
2386 }
2387
2388 if rv.show_grid {
2389 draw_run_grid(f, area, rv, app, block, p);
2390 return;
2391 }
2392
2393 let Some(data) = &rv.data else {
2394 let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
2395 .style(Style::default().fg(p.warn))
2396 .block(block);
2397 f.render_widget(par, area);
2398 return;
2399 };
2400
2401 if rv.show_timeline {
2402 draw_run_timeline(f, area, rv.scroll, data, block, p);
2403 return;
2404 }
2405
2406 if rv.show_dag {
2407 draw_run_dag(f, area, rv.scroll, data, block, p);
2408 return;
2409 }
2410
2411 if rv.show_raw || data.summary.is_empty() {
2412 let par = Paragraph::new(data.raw.as_str())
2413 .style(Style::default().fg(p.text))
2414 .wrap(Wrap { trim: false })
2415 .scroll((rv.scroll, 0))
2416 .block(block);
2417 f.render_widget(par, area);
2418 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2419 scrollbar(
2420 f,
2421 area,
2422 total,
2423 area.height.saturating_sub(2) as usize,
2424 rv.scroll as usize,
2425 p,
2426 );
2427 return;
2428 }
2429
2430 let mut lines: Vec<Line> = data
2431 .summary
2432 .iter()
2433 .map(|(k, v)| {
2434 Line::from(vec![
2435 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2436 Span::styled(v.as_str(), Style::default().fg(p.text)),
2437 ])
2438 })
2439 .collect();
2440 if !data.activity.is_empty() {
2441 lines.push(Line::default());
2442 lines.push(Line::from(Span::styled(
2443 if rv.panel == Panel::Jobs {
2444 "Tasks"
2445 } else {
2446 "Event log"
2447 },
2448 Style::default().fg(acc).add_modifier(Modifier::BOLD),
2449 )));
2450 for (status, text) in &data.activity {
2451 if text.starts_with(" ↳") {
2453 lines.push(Line::from(Span::styled(
2454 text.as_str(),
2455 Style::default().fg(p.err),
2456 )));
2457 } else {
2458 lines.push(Line::from(vec![
2459 Span::styled("● ", Style::default().fg(status_color(status, p))),
2460 Span::styled(text.as_str(), Style::default().fg(p.text)),
2461 ]));
2462 }
2463 }
2464 }
2465 let total = lines.len();
2466 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2467 f.render_widget(par, area);
2468 scrollbar(
2469 f,
2470 area,
2471 total,
2472 area.height.saturating_sub(2) as usize,
2473 rv.scroll as usize,
2474 p,
2475 );
2476}
2477
2478fn draw_run_timeline(
2481 f: &mut Frame,
2482 area: Rect,
2483 scroll: u16,
2484 data: &DetailData,
2485 block: Block,
2486 p: &Palette,
2487) {
2488 let tasks = crate::fetchers::runs::timeline(&data.raw);
2489 let now = std::time::SystemTime::now()
2490 .duration_since(std::time::UNIX_EPOCH)
2491 .map(|d| d.as_millis() as u64)
2492 .unwrap_or(0);
2493 let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
2494 let Some(t0) = t0 else {
2495 let par = Paragraph::new(
2496 "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
2497 )
2498 .style(Style::default().fg(p.dim))
2499 .wrap(Wrap { trim: false })
2500 .block(block);
2501 f.render_widget(par, area);
2502 return;
2503 };
2504 let t1 = tasks
2505 .iter()
2506 .filter(|t| t.start > 0)
2507 .map(|t| t.end.unwrap_or(now).max(t.start))
2508 .max()
2509 .unwrap_or(t0);
2510 let span = (t1 - t0).max(1);
2511
2512 let name_w = tasks
2513 .iter()
2514 .map(|t| t.key.chars().count())
2515 .max()
2516 .unwrap_or(4)
2517 .clamp(4, 24);
2518 let inner_w = area.width.saturating_sub(4) as usize;
2519 let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
2521
2522 let mut lines: Vec<Line> = vec![
2523 Line::from(Span::styled(
2524 format!(
2525 "◷ {} task{} · span {}",
2526 tasks.len(),
2527 if tasks.len() == 1 { "" } else { "s" },
2528 crate::shape::fmt_duration_ms(span)
2529 ),
2530 Style::default().fg(p.dim),
2531 )),
2532 Line::default(),
2533 ];
2534 for t in &tasks {
2535 let mut key: String = t.key.chars().take(name_w).collect();
2536 if key.len() < t.key.len() {
2537 key.pop();
2538 key.push('…');
2539 }
2540 let name = Span::styled(format!("{key:<name_w$} "), Style::default().fg(p.text));
2541 if t.start == 0 {
2542 lines.push(Line::from(vec![
2543 name,
2544 Span::styled(
2545 format!("– {}", t.status.label().to_lowercase()),
2546 Style::default().fg(p.dim),
2547 ),
2548 ]));
2549 continue;
2550 }
2551 let end = t.end.unwrap_or(now).max(t.start);
2552 let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
2553 let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
2554 let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
2555 let dur = format!(
2556 " {}{}",
2557 crate::shape::fmt_duration_ms(end - t.start),
2558 if t.end.is_none() { "…" } else { "" }
2559 );
2560 lines.push(Line::from(vec![
2561 name,
2562 Span::styled(" ".repeat(off), Style::default()),
2563 Span::styled(
2564 "█".repeat(len),
2565 Style::default().fg(status_color(&t.status, p)),
2566 ),
2567 Span::styled(dur, Style::default().fg(p.dim)),
2568 ]));
2569 }
2570 let total = lines.len();
2571 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2572 f.render_widget(par, area);
2573 scrollbar(
2574 f,
2575 area,
2576 total,
2577 area.height.saturating_sub(2) as usize,
2578 scroll as usize,
2579 p,
2580 );
2581}
2582
2583fn draw_run_dag(
2586 f: &mut Frame,
2587 area: Rect,
2588 scroll: u16,
2589 data: &DetailData,
2590 block: Block,
2591 p: &Palette,
2592) {
2593 let rows = crate::fetchers::runs::dag(&data.raw);
2594 if rows.is_empty() {
2595 let par = Paragraph::new(
2596 "no task graph recorded — legacy single-task runs carry none (J shows the raw JSON)",
2597 )
2598 .style(Style::default().fg(p.dim))
2599 .wrap(Wrap { trim: false })
2600 .block(block);
2601 f.render_widget(par, area);
2602 return;
2603 }
2604 let mut lines: Vec<Line> = vec![
2605 Line::from(Span::styled(
2606 format!(
2607 "⧉ {} task{} · order of execution, top to bottom",
2608 rows.len(),
2609 if rows.len() == 1 { "" } else { "s" },
2610 ),
2611 Style::default().fg(p.dim),
2612 )),
2613 Line::default(),
2614 ];
2615 for r in &rows {
2616 let mut spans = vec![
2617 Span::styled(r.prefix.clone(), Style::default().fg(p.dim)),
2618 Span::styled("● ", Style::default().fg(status_color(&r.status, p))),
2619 Span::styled(r.key.clone(), Style::default().fg(p.text)),
2620 Span::styled(
2621 format!(" {}", r.status.label().to_lowercase()),
2622 Style::default().fg(status_color(&r.status, p)),
2623 ),
2624 ];
2625 if let Some(d) = r.duration {
2626 spans.push(Span::styled(
2627 format!(" · {}", crate::shape::fmt_duration_ms(d)),
2628 Style::default().fg(p.dim),
2629 ));
2630 }
2631 if !r.also_after.is_empty() {
2632 spans.push(Span::styled(
2633 format!(" (also after {})", r.also_after.join(", ")),
2634 Style::default().fg(p.dim),
2635 ));
2636 }
2637 lines.push(Line::from(spans));
2638 }
2639 let total = lines.len();
2640 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2641 f.render_widget(par, area);
2642 scrollbar(
2643 f,
2644 area,
2645 total,
2646 area.height.saturating_sub(2) as usize,
2647 scroll as usize,
2648 p,
2649 );
2650}
2651
2652fn spark(vals: &[u64]) -> String {
2655 const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2656 let (min, max) = vals
2657 .iter()
2658 .fold((u64::MAX, 0), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
2659 vals.iter()
2660 .map(|v| {
2661 if max == min {
2662 '▄'
2663 } else {
2664 BARS[((v - min) * 7 / (max - min)) as usize]
2665 }
2666 })
2667 .collect()
2668}
2669
2670fn draw_run_grid(
2676 f: &mut Frame,
2677 area: Rect,
2678 rv: &crate::app::RunView,
2679 app: &App,
2680 block: Block,
2681 p: &Palette,
2682) {
2683 let g = match &rv.grid {
2684 None => {
2685 let par = Paragraph::new(format!("{} loading run history…", app.spinner()))
2686 .style(Style::default().fg(p.warn))
2687 .block(block);
2688 f.render_widget(par, area);
2689 return;
2690 }
2691 Some(Err(e)) => {
2692 let par = Paragraph::new(format!("✗ {e}"))
2693 .style(Style::default().fg(p.err))
2694 .wrap(Wrap { trim: false })
2695 .block(block);
2696 f.render_widget(par, area);
2697 return;
2698 }
2699 Some(Ok(g)) => g,
2700 };
2701 if g.tasks.is_empty() {
2702 let par = Paragraph::new(
2703 "no per-task history recorded — legacy single-task runs carry none (J shows the raw JSON)",
2704 )
2705 .style(Style::default().fg(p.dim))
2706 .wrap(Wrap { trim: false })
2707 .block(block);
2708 f.render_widget(par, area);
2709 return;
2710 }
2711
2712 let name_w = g
2713 .tasks
2714 .iter()
2715 .map(|t| t.key.chars().count())
2716 .max()
2717 .unwrap_or(4)
2718 .clamp(4, 24);
2719 let inner_w = area.width.saturating_sub(4) as usize;
2720 let trend_w = 28;
2722 let cols_fit = (inner_w.saturating_sub(name_w + 2 + trend_w) / 2).max(1);
2724 let skip = g.runs.len().saturating_sub(cols_fit);
2725 let cols = &g.runs[skip..];
2726
2727 let mut lines: Vec<Line> = vec![
2728 Line::from(Span::styled(
2729 format!(
2730 "⊞ {} task{} × last {} run{} · oldest → newest",
2731 g.tasks.len(),
2732 if g.tasks.len() == 1 { "" } else { "s" },
2733 cols.len(),
2734 if cols.len() == 1 { "" } else { "s" },
2735 ),
2736 Style::default().fg(p.dim),
2737 )),
2738 Line::default(),
2739 ];
2740
2741 let sel = rv.runs.get(rv.idx).map(|(id, _, _)| id.as_str());
2743 if let Some(ci) = sel.and_then(|id| cols.iter().position(|r| r.run_id == id)) {
2744 let acc = accent(Panel::Jobs, p);
2745 lines.push(Line::from(vec![
2746 Span::raw(" ".repeat(name_w + 2 + 2 * ci)),
2747 Span::styled("▾", Style::default().fg(acc).add_modifier(Modifier::BOLD)),
2748 ]));
2749 }
2750
2751 let mut run_row = vec![Span::styled(
2752 format!("{:<name_w$} ", "run"),
2753 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
2754 )];
2755 for r in cols {
2756 run_row.push(Span::styled(
2757 format!("{} ", history_glyph(&r.status)),
2758 Style::default().fg(status_color(&r.status, p)),
2759 ));
2760 }
2761 if let Some(newest) = cols.last() {
2762 run_row.push(Span::styled(
2763 format!(" newest {}", newest.age),
2764 Style::default().fg(p.dim),
2765 ));
2766 }
2767 lines.push(Line::from(run_row));
2768 lines.push(Line::default());
2769
2770 for t in &g.tasks {
2771 let mut key: String = t.key.chars().take(name_w).collect();
2772 if key.chars().count() < t.key.chars().count() {
2773 key.pop();
2774 key.push('…');
2775 }
2776 let mut spans = vec![Span::styled(
2777 format!("{key:<name_w$} "),
2778 Style::default().fg(p.text),
2779 )];
2780 for cell in &t.cells[skip..] {
2781 spans.push(match cell {
2782 Some(s) => Span::styled(
2783 format!("{} ", history_glyph(s)),
2784 Style::default().fg(status_color(s, p)),
2785 ),
2786 None => Span::styled("· ", Style::default().fg(p.dim)),
2787 });
2788 }
2789 let ds = t.success_durations();
2790 if let Some(latest) = ds.last() {
2791 let window = &ds[ds.len().saturating_sub(10)..];
2792 spans.push(Span::styled(
2793 format!(" {}", spark(window)),
2794 Style::default().fg(p.dim),
2795 ));
2796 spans.push(Span::styled(
2797 format!(" {}", crate::shape::fmt_duration_ms(*latest)),
2798 Style::default().fg(p.text),
2799 ));
2800 }
2801 if let Some((latest, median)) = t.slowdown() {
2802 let ratio = latest as f64 / median as f64;
2803 let color = if ratio >= 2.0 { p.err } else { p.warn };
2804 spans.push(Span::styled(
2805 format!(" ▲{ratio:.1}×"),
2806 Style::default().fg(color).add_modifier(Modifier::BOLD),
2807 ));
2808 }
2809 lines.push(Line::from(spans));
2810 }
2811
2812 let total = lines.len();
2813 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2814 f.render_widget(par, area);
2815 scrollbar(
2816 f,
2817 area,
2818 total,
2819 area.height.saturating_sub(2) as usize,
2820 rv.scroll as usize,
2821 p,
2822 );
2823}
2824
2825fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2826 let block = Block::default()
2827 .borders(Borders::ALL)
2828 .border_type(BorderType::Rounded)
2829 .border_style(Style::default().fg(p.border));
2830 let inner = block.inner(area);
2831 f.render_widget(block, area);
2832
2833 let (from, to) = (p.grad_from, p.grad_to);
2837 let grad = |t: f32| {
2838 Color::Rgb(
2839 (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
2840 (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
2841 (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
2842 )
2843 };
2844 let mut left = vec![
2845 Span::styled(
2846 " ◢",
2847 Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
2848 ),
2849 Span::styled(
2850 "◤ ",
2851 Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
2852 ),
2853 ];
2854 let word: Vec<char> = "DATABRICKS".chars().collect();
2855 let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
2856 for (i, ch) in word.iter().enumerate() {
2857 let t = i as f32 / (word.len() - 1) as f32;
2858 let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
2859 Style::default().fg(p.text).add_modifier(Modifier::BOLD)
2860 } else {
2861 Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
2862 };
2863 left.push(Span::styled(ch.to_string(), style));
2864 }
2865 left.push(Span::raw(" "));
2866 for ch in "LAKEHOUSE".chars() {
2867 left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
2868 }
2869 left.push(Span::styled(
2870 format!("· v{}", env!("CARGO_PKG_VERSION")),
2871 Style::default().fg(p.dim),
2872 ));
2873 if let Some(profile) = &app.profile {
2874 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2875 left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
2876 left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
2877 }
2878 if let Some(Shape::Badge(b)) = &app.user_badge {
2879 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2880 left.push(Span::styled(
2881 format!("{} {}", b.label, b.value),
2882 Style::default().fg(p.key),
2883 ));
2884 }
2885 if app.zoomed {
2886 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2887 left.push(Span::styled(
2888 format!("⛶ {}", app.focus.title()),
2889 Style::default()
2890 .fg(accent(app.focus, p))
2891 .add_modifier(Modifier::BOLD),
2892 ));
2893 }
2894
2895 let (ok, pending, failed, idle) = app.status_counts();
2897 for (count, glyph, color) in [
2898 (ok, "●", p.ok),
2899 (pending, "◐", p.warn),
2900 (failed, "✗", p.err),
2901 (idle, "○", p.dim),
2902 ] {
2903 if count > 0 {
2904 left.push(Span::styled(" ", Style::default()));
2905 left.push(Span::styled(
2906 format!("{glyph} {count}"),
2907 Style::default().fg(color),
2908 ));
2909 }
2910 }
2911 if !app.watched.is_empty() {
2913 left.push(Span::styled(" ", Style::default()));
2914 left.push(Span::styled(
2915 format!("👁 {}", app.watched.len()),
2916 Style::default().fg(p.warn),
2917 ));
2918 }
2919 f.render_widget(Paragraph::new(Line::from(left)), inner);
2920
2921 let right = if app.loading {
2922 Line::from(Span::styled(
2923 format!("{} refreshing ", app.spinner()),
2924 Style::default().fg(p.warn),
2925 ))
2926 } else {
2927 Line::from(Span::styled(
2928 format!("updated {}s ago ", app.last_refresh_age().as_secs()),
2929 Style::default().fg(p.dim),
2930 ))
2931 };
2932 f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
2933}
2934
2935fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2936 let key =
2937 |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
2938 let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
2939
2940 if let Some(confirm) = &app.confirm {
2941 let mut spans = vec![
2942 Span::styled(
2943 format!(" ⚠ {} ", confirm.message),
2944 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2945 ),
2946 key("y"),
2947 dim(" confirm · "),
2948 ];
2949 if confirm.params.is_some() {
2950 spans.push(key("p"));
2951 spans.push(dim(" edit parameters · "));
2952 }
2953 spans.push(dim("any other key cancels"));
2954 f.render_widget(Paragraph::new(Line::from(spans)), area);
2955 return;
2956 }
2957
2958 if app.filter_entry {
2959 let line = Line::from(vec![
2960 Span::styled(
2961 format!(" /{}", app.active_filter()),
2962 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2963 ),
2964 Span::styled("▏", Style::default().fg(p.warn)),
2965 dim(" type to filter "),
2966 key("enter"),
2967 dim(" apply "),
2968 key("esc"),
2969 dim(" clear"),
2970 ]);
2971 f.render_widget(Paragraph::new(line), area);
2972 return;
2973 }
2974
2975 if let Some(pv) = &app.preview {
2976 if pv.filter_entry {
2977 let line = Line::from(vec![
2978 Span::styled(
2979 format!(" cols /{}", pv.filter),
2980 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2981 ),
2982 Span::styled("▏", Style::default().fg(p.warn)),
2983 dim(" type to filter columns "),
2984 key("enter"),
2985 dim(" apply "),
2986 key("esc"),
2987 dim(" clear"),
2988 ]);
2989 f.render_widget(Paragraph::new(line), area);
2990 return;
2991 }
2992 }
2993
2994 let mut spans = if app.param_form.is_some() {
2995 vec![
2996 dim(" type key=value pairs "),
2997 key("enter"),
2998 dim(" run "),
2999 key("esc"),
3000 dim(" cancel"),
3001 ]
3002 } else if app.secret_form.is_some() {
3003 vec![
3004 dim(" type "),
3005 key("enter"),
3006 dim(" next / save "),
3007 key("esc"),
3008 dim(" cancel"),
3009 ]
3010 } else if app.help {
3011 vec![
3012 dim(" "),
3013 key("j"),
3014 dim("/"),
3015 key("k"),
3016 dim(" scroll any other key closes"),
3017 ]
3018 } else if app.pane_cfg.is_some() {
3019 vec![
3020 dim(" "),
3021 key("j"),
3022 dim("/"),
3023 key("k"),
3024 dim(" select "),
3025 key("space"),
3026 dim(" show/hide "),
3027 key("J"),
3028 dim("/"),
3029 key("K"),
3030 dim(" move "),
3031 key("esc"),
3032 dim(" done"),
3033 ]
3034 } else if app.jump.is_some() {
3035 vec![
3036 dim(" type to search everything "),
3037 key("\u{2191}"),
3038 dim("/"),
3039 key("\u{2193}"),
3040 dim(" select "),
3041 key("enter"),
3042 dim(" jump "),
3043 key("esc"),
3044 dim(" close"),
3045 ]
3046 } else if app.problems.is_some() {
3047 vec![
3048 dim(" "),
3049 key("j"),
3050 dim("/"),
3051 key("k"),
3052 dim(" select "),
3053 key("enter"),
3054 dim(" jump to item / switch workspace "),
3055 key("esc"),
3056 dim(" close"),
3057 ]
3058 } else if app.upcoming.is_some() {
3059 vec![
3060 dim(" "),
3061 key("j"),
3062 dim("/"),
3063 key("k"),
3064 dim(" select "),
3065 key("enter"),
3066 dim(" jump to job "),
3067 key("esc"),
3068 dim(" close"),
3069 ]
3070 } else if app.cost.is_some() || app.item_cost.is_some() {
3071 vec![
3072 dim(" "),
3073 key("esc"),
3074 dim(" back "),
3075 key("q"),
3076 dim(" quit"),
3077 ]
3078 } else if app.wh_picker.is_some() {
3079 vec![
3080 dim(" "),
3081 key("j"),
3082 dim("/"),
3083 key("k"),
3084 dim(" select "),
3085 key("enter"),
3086 dim(" run preview "),
3087 key("esc"),
3088 dim(" cancel"),
3089 ]
3090 } else if app.sql.is_some() {
3091 if app.hist_search.is_some() {
3092 vec![
3093 dim(" type to search history "),
3094 key("^r"),
3095 dim(" older match "),
3096 key("enter"),
3097 dim(" accept "),
3098 key("esc"),
3099 dim(" cancel"),
3100 ]
3101 } else {
3102 vec![
3103 dim(" "),
3104 key("enter"),
3105 dim(" run "),
3106 key("tab"),
3107 dim(" complete "),
3108 key("↑"),
3109 dim("/"),
3110 key("↓"),
3111 dim(" history "),
3112 key("^r"),
3113 dim(" search "),
3114 key("^x"),
3115 dim(" editor "),
3116 key("pgup"),
3117 dim("/"),
3118 key("pgdn"),
3119 dim(" scroll "),
3120 key("⇧←→"),
3121 dim(" cols "),
3122 key("^s"),
3123 dim(" export "),
3124 key("esc"),
3125 dim(if app.sql.as_ref().is_some_and(|c| c.running) {
3126 " cancel query"
3127 } else {
3128 " close"
3129 }),
3130 ]
3131 }
3132 } else if let Some(pv) = &app.preview {
3133 if pv.record {
3134 vec![
3135 dim(" "),
3136 key("j"),
3137 dim("/"),
3138 key("k"),
3139 dim(" fields "),
3140 key("h"),
3141 dim("/"),
3142 key("l"),
3143 dim(" prev/next row "),
3144 key("v"),
3145 dim(" grid "),
3146 key("/"),
3147 dim(" filter cols "),
3148 key("esc"),
3149 dim(" back"),
3150 ]
3151 } else {
3152 vec![
3153 dim(" "),
3154 key("esc"),
3155 dim(" back "),
3156 key("j"),
3157 dim("/"),
3158 key("k"),
3159 dim(" rows "),
3160 key("←"),
3161 dim("/"),
3162 key("→"),
3163 dim(" columns "),
3164 key("v"),
3165 dim(" row view "),
3166 key("/"),
3167 dim(" filter cols "),
3168 key("e"),
3169 dim(" export csv "),
3170 key("q"),
3171 dim(" quit"),
3172 ]
3173 }
3174 } else if app.picker.is_some() {
3175 vec![
3176 dim(" "),
3177 key("j"),
3178 dim("/"),
3179 key("k"),
3180 dim(" select "),
3181 key("enter"),
3182 dim(" switch workspace "),
3183 key("esc"),
3184 dim(" cancel"),
3185 ]
3186 } else if let Some(rv) = &app.run_view {
3187 if rv.show_output {
3188 vec![
3189 dim(" "),
3190 key("esc"),
3191 dim(" back to run "),
3192 key("j"),
3193 dim("/"),
3194 key("k"),
3195 dim(" scroll "),
3196 key("q"),
3197 dim(" quit"),
3198 ]
3199 } else {
3200 let mut spans = vec![
3201 dim(" "),
3202 key("esc"),
3203 dim(" back "),
3204 key("h"),
3205 dim("/"),
3206 key("l"),
3207 dim(" older/newer "),
3208 ];
3209 if rv.panel == Panel::Jobs {
3210 spans.push(key("o"));
3211 spans.push(dim(" output "));
3212 spans.push(key("t"));
3213 spans.push(dim(" timeline "));
3214 spans.push(key("d"));
3215 spans.push(dim(" dag "));
3216 spans.push(key("g"));
3217 spans.push(dim(" grid "));
3218 spans.push(key("r"));
3219 spans.push(dim(" repair "));
3220 spans.push(key("W"));
3221 spans.push(dim(
3222 if app.watched.iter().any(|w| {
3223 rv.runs
3224 .get(rv.idx)
3225 .is_some_and(|(id, _, _)| *id == w.run_id)
3226 }) {
3227 " unwatch "
3228 } else {
3229 " watch "
3230 },
3231 ));
3232 }
3233 spans.extend([
3234 key("s"),
3235 dim(" cancel "),
3236 key("j"),
3237 dim("/"),
3238 key("k"),
3239 dim(" scroll "),
3240 key("J"),
3241 dim(" raw "),
3242 key("q"),
3243 dim(" quit"),
3244 ]);
3245 spans
3246 }
3247 } else if app.detail.is_some() {
3248 let mut spans = vec![dim(" "), key("esc"), dim(" back ")];
3249 match app.detail.as_ref() {
3250 Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
3251 spans.push(key("enter"));
3252 spans.push(dim(" latest run "));
3253 }
3254 Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
3255 spans.push(key("enter"));
3256 spans.push(dim(" latest update "));
3257 }
3258 _ => {}
3259 }
3260 spans.extend([
3261 key("j"),
3262 dim("/"),
3263 key("k"),
3264 dim(" scroll "),
3265 key("J"),
3266 dim(" raw "),
3267 key("o"),
3268 dim(" open "),
3269 key("q"),
3270 dim(" quit"),
3271 ]);
3272 spans
3273 } else {
3274 let mut spans = vec![
3275 dim(" "),
3276 key("tab"),
3277 dim("/"),
3278 key("h"),
3279 dim("/"),
3280 key("l"),
3281 dim(" switch "),
3282 key("j"),
3283 dim("/"),
3284 key("k"),
3285 dim(" select "),
3286 key("/"),
3287 dim(if app.active_filter().is_empty() {
3288 " filter "
3289 } else {
3290 " filter · esc clears "
3291 }),
3292 ];
3293 let enter_label = match app.focus {
3295 Panel::Secrets if app.secret_scope.is_some() => None,
3296 Panel::Secrets => Some(" open scope "),
3297 Panel::Catalog if app.uc_path.len() < 2 => Some(" open "),
3298 _ => Some(" details "),
3299 };
3300 if let Some(label) = enter_label {
3301 spans.push(key("enter"));
3302 spans.push(dim(label));
3303 }
3304 match app.focus {
3306 Panel::Catalog => {
3307 if !app.uc_path.is_empty() {
3308 spans.push(key("bksp"));
3309 spans.push(dim(" up "));
3310 }
3311 if app.uc_path.len() == 2 {
3312 spans.push(key("p"));
3313 spans.push(dim(" preview "));
3314 spans.push(key("L"));
3315 spans.push(dim(" lineage "));
3316 spans.push(key(":"));
3317 spans.push(dim(" query table "));
3318 }
3319 }
3320 Panel::Dashboards => {}
3321 Panel::Secrets => {
3322 if app.secret_scope.is_some() {
3323 spans.push(key("bksp"));
3324 spans.push(dim(" up "));
3325 }
3326 spans.push(key("a"));
3327 spans.push(dim(if app.secret_scope.is_some() {
3328 " add secret "
3329 } else {
3330 " new scope "
3331 }));
3332 spans.push(key("x"));
3333 spans.push(dim(" delete "));
3334 }
3335 _ => {
3336 spans.push(key("s"));
3337 spans.push(dim(" action "));
3338 if app.focus == Panel::Jobs {
3339 spans.push(key("S"));
3340 spans.push(dim(" pause "));
3341 }
3342 if matches!(app.focus, Panel::Jobs | Panel::Pipelines) {
3343 spans.push(key("c"));
3344 spans.push(dim(" spend "));
3345 }
3346 }
3347 }
3348 spans.extend([
3349 key("g"),
3350 dim(" access "),
3351 key("$"),
3352 dim(" cost "),
3353 key("!"),
3354 dim(" problems "),
3355 key("u"),
3356 dim(" upcoming "),
3357 ]);
3358 if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
3360 spans.push(key(":"));
3361 spans.push(dim(" sql "));
3362 }
3363 spans.extend([
3364 key("o"),
3365 dim(" open "),
3366 key("z"),
3367 dim(if app.zoomed { " unzoom " } else { " zoom " }),
3368 key("w"),
3369 dim(" workspace "),
3370 key("t"),
3371 dim(" theme "),
3372 key("r"),
3373 dim(" refresh "),
3374 key("?"),
3375 dim(" help "),
3376 key("q"),
3377 dim(" quit"),
3378 ]);
3379 spans
3380 };
3381 if let Some((msg, _)) = &app.flash {
3383 let color = if msg.starts_with('✗') { p.err } else { p.ok };
3384 let mut prefixed = vec![
3385 Span::styled(
3386 format!(" {msg}"),
3387 Style::default().fg(color).add_modifier(Modifier::BOLD),
3388 ),
3389 dim(" · "),
3390 ];
3391 prefixed.append(&mut spans);
3392 spans = prefixed;
3393 }
3394 f.render_widget(Paragraph::new(Line::from(spans)), area);
3395}
3396
3397#[allow(clippy::too_many_arguments)]
3398fn draw_panel(
3399 f: &mut Frame,
3400 area: Rect,
3401 panel: Panel,
3402 shape: Option<&Shape>,
3403 focused: bool,
3404 selected: Option<usize>,
3405 fresh: bool,
3406 breadcrumb: &str,
3407 spinner: &str,
3408 filter: &str,
3409 entering: bool,
3410 favs: &std::collections::HashSet<String>,
3411 fav_only: bool,
3412 p: &Palette,
3413) {
3414 let accent = accent(panel, p);
3415 let dimmed = |s: Style| {
3418 if focused {
3419 s
3420 } else {
3421 s.add_modifier(Modifier::DIM)
3422 }
3423 };
3424 let (border_style, title_style) = if focused {
3425 (
3426 Style::default().fg(accent).add_modifier(Modifier::BOLD),
3427 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
3428 )
3429 } else {
3430 (Style::default().fg(p.border), Style::default().fg(p.dim))
3431 };
3432 let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
3433 Some(Shape::List(items)) => Some(
3434 items
3435 .iter()
3436 .filter(|it| {
3437 crate::shape::item_matches(it, filter)
3438 && (!fav_only || favs.contains(&crate::shape::fav_key(it)))
3439 })
3440 .collect(),
3441 ),
3442 _ => None,
3443 };
3444 let count = match (&visible, shape) {
3445 (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
3447 format!(" · {}/{}", v.len(), items.len())
3448 }
3449 (Some(v), _) => format!(" · {}", v.len()),
3450 (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
3451 _ => String::new(),
3452 };
3453 let crumb = if !breadcrumb.is_empty() {
3454 format!(" ▸ {breadcrumb}")
3455 } else {
3456 String::new()
3457 };
3458 let title_style = if fresh {
3460 Style::default()
3461 .bg(accent)
3462 .fg(Color::Black)
3463 .add_modifier(Modifier::BOLD)
3464 } else {
3465 title_style
3466 };
3467 let mut title_spans = vec![
3468 Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
3469 Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
3470 ];
3471 if entering || !filter.is_empty() {
3472 title_spans.push(Span::styled(
3473 format!("/{}{} ", filter, if entering { "▏" } else { "" }),
3474 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3475 ));
3476 }
3477 if fav_only {
3478 title_spans.push(Span::styled(
3479 "★only ",
3480 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3481 ));
3482 }
3483 let title = Line::from(title_spans);
3484 let block = Block::default()
3485 .title(title)
3486 .borders(Borders::ALL)
3487 .border_type(if focused {
3488 BorderType::Thick
3489 } else {
3490 BorderType::Rounded
3491 })
3492 .border_style(border_style)
3493 .padding(Padding::horizontal(1));
3494
3495 match shape {
3496 None => {
3497 let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
3498 .style(Style::default().fg(p.warn))
3499 .block(block);
3500 f.render_widget(par, area);
3501 }
3502 Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
3503 let empty = if !items.is_empty() {
3504 format!("∅ nothing matches /{filter}")
3505 } else {
3506 let msg = match panel {
3507 Panel::Clusters => "no compute running",
3508 Panel::Jobs => "no jobs defined",
3509 Panel::Pipelines => "no pipelines",
3510 Panel::Warehouses => "no warehouses",
3511 Panel::Dashboards => "no dashboards yet",
3512 Panel::Catalog => "nothing at this level",
3513 Panel::Secrets => "no secret scopes",
3514 };
3515 format!("∅ {msg}")
3516 };
3517 let par = Paragraph::new(empty)
3518 .style(Style::default().fg(p.dim))
3519 .block(block);
3520 f.render_widget(par, area);
3521 }
3522 Some(Shape::List(_)) => {
3523 let list_items: Vec<ListItem> = visible
3524 .unwrap_or_default()
3525 .iter()
3526 .map(|item| {
3527 let color = status_color(&item.status, p);
3528 let chip = match item.status {
3529 Status::Stopped | Status::Unknown(_) => Span::styled(
3530 format!(" {} ", item.status.label()),
3531 Style::default().fg(color).add_modifier(Modifier::DIM),
3532 ),
3533 _ => Span::styled(
3534 format!(" {} ", item.status.label()),
3535 Style::default()
3536 .bg(color)
3537 .fg(Color::Black)
3538 .add_modifier(Modifier::BOLD),
3539 ),
3540 };
3541 let star = if favs.contains(&crate::shape::fav_key(item)) {
3542 Span::styled("★ ", Style::default().fg(p.warn))
3543 } else {
3544 Span::raw(" ")
3545 };
3546 let mut spans = vec![
3547 star,
3548 Span::styled("● ", Style::default().fg(color)),
3549 Span::styled(item.name.as_str(), dimmed(Style::default().fg(p.text))),
3550 Span::raw(" "),
3551 chip,
3552 ];
3553 if !item.history.is_empty() {
3554 spans.push(Span::raw(" "));
3555 for run in &item.history {
3556 spans.push(Span::styled(
3557 history_glyph(run),
3558 Style::default().fg(status_color(run, p)),
3559 ));
3560 }
3561 }
3562 if let Some(detail) = &item.detail {
3563 let fg = if item.alert.is_some() { p.warn } else { p.dim };
3565 spans.push(Span::styled(
3566 format!(" {}", detail),
3567 dimmed(Style::default().fg(fg)),
3568 ));
3569 }
3570 ListItem::new(Line::from(spans))
3571 })
3572 .collect();
3573 let list = List::new(list_items)
3574 .block(block)
3575 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
3576 match selected {
3577 Some(sel) => {
3578 let mut state = ListState::default().with_selected(Some(sel));
3579 f.render_stateful_widget(list, area, &mut state);
3580 }
3581 None => f.render_widget(list, area),
3582 }
3583 }
3584 Some(Shape::Table(data)) => {
3585 let header_cells: Vec<Cell> = data
3586 .headers
3587 .iter()
3588 .map(|h| {
3589 Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
3590 })
3591 .collect();
3592 let header = Row::new(header_cells).style(Style::default().fg(accent));
3593 let rows: Vec<Row> = data
3594 .rows
3595 .iter()
3596 .map(|r| {
3597 Row::new(
3598 r.iter()
3599 .map(|c| Cell::from(c.as_str()).style(dimmed(Style::default())))
3600 .collect::<Vec<_>>(),
3601 )
3602 })
3603 .collect();
3604 let widths: Vec<Constraint> = data
3605 .headers
3606 .iter()
3607 .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
3608 .collect();
3609 let table = Table::new(rows, widths).header(header).block(block);
3610 f.render_widget(table, area);
3611 }
3612 Some(Shape::Badge(b)) => {
3613 let text = format!("{}: {}", b.label, b.value);
3614 let par = Paragraph::new(text)
3615 .style(dimmed(Style::default().fg(p.text)))
3616 .block(block);
3617 f.render_widget(par, area);
3618 }
3619 Some(Shape::Text(t)) => {
3620 let color = if t.starts_with('✗') { p.err } else { p.text };
3621 let par = Paragraph::new(t.as_str())
3622 .style(dimmed(Style::default().fg(color)))
3623 .wrap(Wrap { trim: false })
3624 .block(block);
3625 f.render_widget(par, area);
3626 }
3627 }
3628}
3629
3630fn scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, pos: usize, p: &Palette) {
3634 if viewport == 0 || total <= viewport {
3635 return;
3636 }
3637 let max = total - viewport;
3638 let mut state = ScrollbarState::new(max).position(pos.min(max));
3639 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
3640 .begin_symbol(None)
3641 .end_symbol(None)
3642 .track_symbol(Some("│"))
3643 .track_style(Style::default().fg(p.border))
3644 .thumb_symbol("┃")
3645 .thumb_style(Style::default().fg(p.text));
3646 f.render_stateful_widget(
3647 sb,
3648 area.inner(Margin {
3649 horizontal: 0,
3650 vertical: 1,
3651 }),
3652 &mut state,
3653 );
3654}
3655
3656fn wrapped_height(text: &str, width: usize) -> usize {
3658 let w = width.max(1);
3659 text.lines()
3660 .map(|l| l.chars().count().div_ceil(w).max(1))
3661 .sum()
3662}
3663
3664const SQL_EXTRA_KEYWORDS: &[&str] = &[
3667 "TABLE", "VIEW", "IF", "EXISTS", "USE", "TRUE", "FALSE", "ASC", "DESC", "INTERVAL",
3668];
3669
3670fn is_sql_keyword(word: &str) -> bool {
3671 let up = word.to_ascii_uppercase();
3672 crate::app::SQL_KEYWORDS
3673 .iter()
3674 .any(|k| k.split_whitespace().any(|w| w == up))
3675 || SQL_EXTRA_KEYWORDS.contains(&up.as_str())
3676}
3677
3678fn sql_spans(sql: &str, p: &Palette) -> Vec<Span<'static>> {
3682 let chars: Vec<char> = sql.chars().collect();
3683 let mut spans = Vec::new();
3684 let mut i = 0;
3685 let push = |spans: &mut Vec<Span<'static>>, text: String, style: Style| {
3686 if !text.is_empty() {
3687 spans.push(Span::styled(text, style));
3688 }
3689 };
3690 while i < chars.len() {
3691 let c = chars[i];
3692 if c == '\'' || c == '`' || c == '"' {
3693 let start = i;
3696 i += 1;
3697 while i < chars.len() && chars[i] != c {
3698 i += 1;
3699 }
3700 if i < chars.len() {
3701 i += 1;
3702 }
3703 let color = if c == '\'' { p.ok } else { p.catalog };
3704 push(
3705 &mut spans,
3706 chars[start..i].iter().collect(),
3707 Style::default().fg(color),
3708 );
3709 } else if c == '-' && chars.get(i + 1) == Some(&'-') {
3710 push(
3711 &mut spans,
3712 chars[i..].iter().collect(),
3713 Style::default().fg(p.dim),
3714 );
3715 break;
3716 } else if c.is_ascii_digit() {
3717 let start = i;
3718 while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
3719 i += 1;
3720 }
3721 push(
3722 &mut spans,
3723 chars[start..i].iter().collect(),
3724 Style::default().fg(p.warn),
3725 );
3726 } else if c.is_alphanumeric() || c == '_' {
3727 let start = i;
3728 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
3729 i += 1;
3730 }
3731 let word: String = chars[start..i].iter().collect();
3732 let style = if is_sql_keyword(&word) {
3733 Style::default().fg(p.key).add_modifier(Modifier::BOLD)
3734 } else {
3735 Style::default().fg(p.text)
3736 };
3737 push(&mut spans, word, style);
3738 } else {
3739 let start = i;
3740 i += 1;
3741 while i < chars.len() {
3742 let c = chars[i];
3743 if c.is_alphanumeric() || matches!(c, '_' | '\'' | '`' | '"' | '-') {
3744 break;
3745 }
3746 i += 1;
3747 }
3748 push(
3749 &mut spans,
3750 chars[start..i].iter().collect(),
3751 Style::default().fg(p.text),
3752 );
3753 }
3754 }
3755 spans
3756}
3757
3758fn output_line<'a>(line: &'a str, p: &Palette) -> Line<'a> {
3762 if let Some(rest) = line.strip_prefix("── ") {
3763 let color = if rest.contains("FAILED") {
3764 p.err
3765 } else if rest.contains("RUNNING") || rest.contains("PENDING") {
3766 p.warn
3767 } else if rest.contains("SUCCESS") {
3768 p.ok
3769 } else {
3770 p.text
3771 };
3772 return Line::from(Span::styled(
3773 line,
3774 Style::default().fg(color).add_modifier(Modifier::BOLD),
3775 ));
3776 }
3777 let trimmed = line.trim_start();
3778 let indented = line.starts_with(char::is_whitespace);
3779 let body = if line.contains("ERROR")
3780 || line.contains("FATAL")
3781 || line.contains("Exception")
3782 || trimmed.starts_with("Traceback")
3783 || trimmed.starts_with("Caused by")
3784 || (indented && (trimmed.starts_with("at ") || trimmed.starts_with("File \"")))
3785 {
3786 Style::default().fg(p.err)
3787 } else if line.contains("WARN") {
3788 Style::default().fg(p.warn)
3789 } else if line == "logs (tail):" || trimmed.starts_with("(no output") {
3790 Style::default().fg(p.dim)
3791 } else {
3792 Style::default().fg(p.text)
3793 };
3794 let ts = timestamp_prefix(line);
3795 if ts > 0 {
3796 Line::from(vec![
3797 Span::styled(&line[..ts], Style::default().fg(p.dim)),
3798 Span::styled(&line[ts..], body),
3799 ])
3800 } else {
3801 Line::from(Span::styled(line, body))
3802 }
3803}
3804
3805fn timestamp_prefix(line: &str) -> usize {
3809 let mut len = 0;
3810 let mut digits = 0;
3811 for (i, c) in line.char_indices() {
3812 if c.is_ascii_digit() {
3813 digits += 1;
3814 } else if !matches!(c, '/' | '-' | ':' | '.' | ',' | 'T' | 'Z' | ' ') {
3815 break;
3816 }
3817 len = i + c.len_utf8();
3818 if len >= 30 {
3819 break;
3820 }
3821 }
3822 if digits >= 6 && len >= 8 {
3823 len
3824 } else {
3825 0
3826 }
3827}
3828
3829fn status_color(status: &Status, p: &Palette) -> Color {
3830 match status {
3831 Status::Running | Status::Success => p.ok,
3832 Status::Stopped => p.dim,
3833 Status::Pending => p.warn,
3834 Status::Failed => p.err,
3835 Status::Unknown(_) => p.text,
3836 }
3837}
3838
3839fn history_glyph(status: &Status) -> &'static str {
3840 match status {
3841 Status::Success => "✓",
3842 Status::Failed => "✗",
3843 Status::Running => "●",
3844 Status::Pending => "◐",
3845 Status::Stopped => "○",
3846 Status::Unknown(_) => "·",
3847 }
3848}
3849
3850#[cfg(test)]
3851mod tests {
3852 use super::*;
3853
3854 fn find<'a>(spans: &'a [Span], text: &str) -> &'a Span<'a> {
3855 spans
3856 .iter()
3857 .find(|s| s.content == text)
3858 .unwrap_or_else(|| panic!("no span {text:?} in {spans:?}"))
3859 }
3860
3861 #[test]
3862 fn sql_spans_color_keywords_strings_numbers_comments() {
3863 let p = palette(ThemeMode::Dark);
3864 let spans = sql_spans("SELECT count(x) FROM t WHERE s = 'bob' LIMIT 10 -- hi", &p);
3865 assert_eq!(find(&spans, "SELECT").style.fg, Some(p.key));
3866 assert!(find(&spans, "SELECT")
3867 .style
3868 .add_modifier
3869 .contains(Modifier::BOLD));
3870 assert_eq!(find(&spans, "count").style.fg, Some(p.key));
3871 assert_eq!(find(&spans, "t").style.fg, Some(p.text));
3872 assert_eq!(find(&spans, "'bob'").style.fg, Some(p.ok));
3873 assert_eq!(find(&spans, "10").style.fg, Some(p.warn));
3874 assert_eq!(find(&spans, "-- hi").style.fg, Some(p.dim));
3875 }
3876
3877 #[test]
3878 fn sql_spans_preserve_every_char() {
3879 let p = palette(ThemeMode::Dark);
3880 for sql in [
3881 "SELECT `col a`, b - 1 FROM main.sales.orders WHERE s = 'unclosed",
3882 "-- only a comment",
3883 " \"quoted id\" <> 3.5e2 ",
3884 ] {
3885 let joined: String = sql_spans(sql, &p)
3886 .iter()
3887 .map(|s| s.content.as_ref())
3888 .collect();
3889 assert_eq!(joined, sql);
3890 }
3891 }
3892
3893 #[test]
3894 fn spark_scales_min_to_max() {
3895 assert_eq!(spark(&[1, 1, 1]), "▄▄▄");
3896 assert_eq!(spark(&[0, 7]), "▁█");
3897 assert_eq!(spark(&[100, 150, 200]), "▁▄█");
3898 assert_eq!(spark(&[]), "");
3899 }
3900
3901 fn render(app: &App) -> Vec<String> {
3903 let backend = ratatui::backend::TestBackend::new(100, 30);
3904 let mut terminal = ratatui::Terminal::new(backend).unwrap();
3905 terminal.draw(|f| draw(f, app)).unwrap();
3906 let buffer = terminal.backend().buffer().clone();
3907 (0..buffer.area.height)
3908 .map(|y| {
3909 (0..buffer.area.width)
3910 .map(|x| buffer[(x, y)].symbol())
3911 .collect::<String>()
3912 .trim_end()
3913 .to_string()
3914 })
3915 .collect()
3916 }
3917
3918 fn item_cost_app(data: fetchers::cost::ResourceCost) -> App {
3919 let mut app = App::new(60, ThemeMode::Dark);
3920 app.dismiss_splash();
3921 app.item_cost = Some(crate::app::ItemCostView {
3922 warehouse: "Starter Warehouse".to_string(),
3923 kind: data.kind,
3924 name: "nightly-etl".to_string(),
3925 id: "42".to_string(),
3926 data: Some(Ok(data)),
3927 });
3928 app
3929 }
3930
3931 fn window(
3932 label: &'static str,
3933 days: i64,
3934 dbus: f64,
3935 usd: f64,
3936 prior: Option<(f64, f64)>,
3937 ) -> fetchers::cost::CostWindow {
3938 fetchers::cost::CostWindow {
3939 label,
3940 days,
3941 dbus,
3942 usd,
3943 prior,
3944 }
3945 }
3946
3947 #[test]
3948 fn item_cost_view_shows_every_window_with_its_trend() {
3949 let data = fetchers::cost::ResourceCost {
3950 kind: fetchers::cost::ResourceKind::Job,
3951 windows: vec![
3952 window("last week", 7, 12.4, 9.61, Some((10.0, 8.14))),
3953 window("last month", 30, 58.2, 45.1, Some((60.0, 47.0))),
3954 window("last quarter", 90, 140.9, 109.22, Some((0.0, 0.0))),
3955 window("last year", 365, 402.3, 311.8, None),
3956 ],
3957 months: vec![
3958 ("2026-06".to_string(), 40.1, 31.02),
3959 ("2026-07".to_string(), 58.2, 45.1),
3960 ],
3961 priced: true,
3962 scoped: true,
3963 };
3964 let text = render(&item_cost_app(data)).join("\n");
3965
3966 assert!(
3967 text.contains("Spend · nightly-etl · job · this workspace"),
3968 "{text}"
3969 );
3970 for label in ["last week", "last month", "last quarter", "last year"] {
3971 assert!(text.contains(label), "missing {label} in {text}");
3972 }
3973 assert!(text.contains("$9.61"), "{text}");
3974 assert!(text.contains("▲ 18%"), "{text}");
3977 assert!(text.contains("▼ 4%"), "{text}");
3978 assert!(text.contains("BY MONTH"), "{text}");
3979 assert!(text.contains("2026-06"), "{text}");
3980 }
3981
3982 #[test]
3983 fn item_cost_view_explains_an_unattributed_job() {
3984 let data = fetchers::cost::ResourceCost {
3985 kind: fetchers::cost::ResourceKind::Job,
3986 windows: Vec::new(),
3987 months: Vec::new(),
3988 priced: true,
3989 scoped: true,
3990 };
3991 let text = render(&item_cost_app(data)).join("\n");
3992 assert!(text.contains("no usage attributed to this job"), "{text}");
3993 assert!(text.contains("all-purpose compute"), "{text}");
3994 }
3995}