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 ("ctrl+v / f2", "record view: one row, fields stacked"),
841 ("pgup / pgdn", "scroll results · fields in record view"),
842 ("shift+← / →", "page result columns · rows in record view"),
843 (
844 "esc",
845 "cancel a running query, leave record view, else close",
846 ),
847 ],
848 ),
849 ];
850 let mut lines: Vec<Line> = Vec::new();
851 for (title, keys) in sections {
852 lines.push(Line::from(Span::styled(
853 *title,
854 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
855 )));
856 for (k, desc) in *keys {
857 lines.push(Line::from(vec![
858 Span::styled(format!(" {k:<24}"), Style::default().fg(p.warn)),
859 Span::styled(*desc, Style::default().fg(p.text)),
860 ]));
861 }
862 lines.push(Line::default());
863 }
864 let total = lines.len();
865 let par = Paragraph::new(lines)
866 .scroll((app.help_scroll, 0))
867 .block(block);
868 f.render_widget(par, popup);
869 scrollbar(
871 f,
872 popup,
873 total,
874 popup.height.saturating_sub(3) as usize,
875 app.help_scroll as usize,
876 p,
877 );
878}
879
880fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
881 let Some(jump) = &app.jump else {
882 return;
883 };
884 let matches = app.jump_matches();
885 let width = 70.min(area.width.saturating_sub(4));
886 let height = (matches.len() as u16 + 5).min(area.height);
887 let popup = Rect {
888 x: area.x + (area.width.saturating_sub(width)) / 2,
889 y: area.y + 2.min(area.height.saturating_sub(height)),
890 width,
891 height,
892 };
893 f.render_widget(Clear, popup);
894 let block = Block::default()
895 .title(Line::from(vec![
896 Span::styled(" ⌕ ", Style::default().fg(p.key)),
897 Span::styled(
898 "Jump to ",
899 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
900 ),
901 ]))
902 .borders(Borders::ALL)
903 .border_type(BorderType::Thick)
904 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
905 .padding(Padding::horizontal(1));
906 let inner = block.inner(popup);
907 f.render_widget(block, popup);
908
909 let parts = Layout::default()
910 .direction(Direction::Vertical)
911 .constraints([Constraint::Length(2), Constraint::Min(0)])
912 .split(inner);
913 f.render_widget(
914 Paragraph::new(Line::from(vec![
915 Span::styled("❯ ", Style::default().fg(p.key)),
916 Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
917 Span::styled("▏", Style::default().fg(p.warn)),
918 ])),
919 parts[0],
920 );
921
922 if matches.is_empty() {
923 f.render_widget(
924 Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
925 parts[1],
926 );
927 return;
928 }
929 let items: Vec<ListItem> = matches
930 .iter()
931 .map(|(panel_idx, name, label)| {
932 let panel = Panel::ALL[*panel_idx];
933 ListItem::new(Line::from(vec![
934 Span::styled(
935 format!("{} ", panel.icon()),
936 Style::default().fg(accent(panel, p)),
937 ),
938 Span::styled(format!("{name} "), Style::default().fg(p.text)),
939 Span::styled(label.clone(), Style::default().fg(p.dim)),
940 ]))
941 })
942 .collect();
943 let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
944 let mut state = ListState::default().with_selected(Some(jump.index));
945 f.render_stateful_widget(list, parts[1], &mut state);
946}
947
948fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
949 let Some(pr) = &app.problems else {
950 return;
951 };
952 let truncate = |s: &str, max: usize| -> String {
953 if s.chars().count() <= max {
954 s.to_string()
955 } else {
956 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
957 format!("{cut}…")
958 }
959 };
960 let prefix_of = |problem: &crate::app::Problem| -> String {
964 problem
965 .profile
966 .as_ref()
967 .map(|pr| format!("{pr} ▸ "))
968 .unwrap_or_default()
969 };
970 let name_w = pr
971 .items
972 .iter()
973 .map(|i| i.name.chars().count() + prefix_of(i).chars().count())
974 .max()
975 .unwrap_or(10)
976 .clamp(10, 40);
977 let note_w = pr
978 .items
979 .iter()
980 .map(|i| i.note.chars().count())
981 .max()
982 .unwrap_or(0);
983 let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
985 let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
986 let note_space = (width as usize).saturating_sub(name_w + 30);
988 let popup = Rect {
989 x: area.x + (area.width.saturating_sub(width)) / 2,
990 y: area.y + (area.height.saturating_sub(height)) / 2,
991 width,
992 height,
993 };
994 f.render_widget(Clear, popup);
995 let mut title_spans = vec![
996 Span::styled(" ✗ ", Style::default().fg(p.err)),
997 Span::styled(
998 format!("Problems · {} ", pr.items.len()),
999 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1000 ),
1001 ];
1002 if pr.scanning {
1003 title_spans.push(Span::styled(
1004 format!("· {} scanning workspaces ", app.spinner()),
1005 Style::default().fg(p.warn),
1006 ));
1007 }
1008 let block = Block::default()
1009 .title(Line::from(title_spans))
1010 .borders(Borders::ALL)
1011 .border_type(BorderType::Rounded)
1012 .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
1013 .padding(Padding::new(1, 1, 1, 1));
1014
1015 if pr.items.is_empty() {
1016 let (text, color) = if pr.scanning {
1017 (
1018 format!(
1019 "✓ nothing failing here — {} scanning other workspaces…",
1020 app.spinner()
1021 ),
1022 p.warn,
1023 )
1024 } else {
1025 ("✓ all clear — nothing failing right now".to_string(), p.ok)
1026 };
1027 let par = Paragraph::new(text)
1028 .style(Style::default().fg(color))
1029 .block(block);
1030 f.render_widget(par, popup);
1031 return;
1032 }
1033
1034 let items: Vec<ListItem> = pr
1035 .items
1036 .iter()
1037 .map(|problem| {
1038 let prefix = prefix_of(problem);
1039 let plen = prefix.chars().count().min(name_w.saturating_sub(4));
1040 let mut spans = vec![
1041 Span::styled("✗ ", Style::default().fg(p.err)),
1042 Span::styled(truncate(&prefix, plen), Style::default().fg(p.dim)),
1043 Span::styled(
1044 format!(
1045 "{:<width$}",
1046 truncate(&problem.name, name_w - plen),
1047 width = name_w + 2 - plen
1048 ),
1049 Style::default().fg(p.text),
1050 ),
1051 ];
1052 spans.push(match problem.panel {
1053 Some(i) => {
1054 let panel = Panel::ALL[i];
1055 Span::styled(
1056 format!("{} {:<18}", panel.icon(), panel.title()),
1057 Style::default().fg(accent(panel, p)),
1058 )
1059 }
1060 None => Span::styled(format!("⌂ {:<18}", "workspace"), Style::default().fg(p.dim)),
1061 });
1062 spans.push(Span::styled(
1063 truncate(&problem.note, note_space),
1064 Style::default().fg(p.dim),
1065 ));
1066 ListItem::new(Line::from(spans))
1067 })
1068 .collect();
1069 let list = List::new(items)
1070 .block(block)
1071 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1072 let mut state = ListState::default().with_selected(Some(pr.index));
1073 f.render_stateful_widget(list, popup, &mut state);
1074}
1075
1076fn draw_upcoming(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1079 let Some(u) = &app.upcoming else {
1080 return;
1081 };
1082 let label_w = u
1083 .items
1084 .iter()
1085 .map(|i| i.next.label.chars().count())
1086 .max()
1087 .unwrap_or(8)
1088 .clamp(8, 18);
1089 let name_w = u
1090 .items
1091 .iter()
1092 .map(|i| i.name.chars().count())
1093 .max()
1094 .unwrap_or(10)
1095 .clamp(10, 40);
1096 let desc_w = u
1097 .items
1098 .iter()
1099 .map(|i| i.next.desc.chars().count())
1100 .max()
1101 .unwrap_or(0);
1102 let width = ((label_w + name_w + desc_w + 12) as u16).min(area.width.saturating_sub(4));
1103 let height = (u.items.len().max(1) as u16 + 4).min(area.height);
1104 let desc_space = (width as usize).saturating_sub(label_w + name_w + 10);
1105 let popup = Rect {
1106 x: area.x + (area.width.saturating_sub(width)) / 2,
1107 y: area.y + (area.height.saturating_sub(height)) / 2,
1108 width,
1109 height,
1110 };
1111 f.render_widget(Clear, popup);
1112 let block = Block::default()
1113 .title(Line::from(vec![
1114 Span::styled(" ⏱ ", Style::default().fg(p.key)),
1115 Span::styled(
1116 format!("Upcoming runs · {} ", u.items.len()),
1117 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1118 ),
1119 ]))
1120 .borders(Borders::ALL)
1121 .border_type(BorderType::Rounded)
1122 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
1123 .padding(Padding::new(1, 1, 1, 1));
1124
1125 if u.loading {
1126 let par = Paragraph::new(format!("{} reading job schedules…", app.spinner()))
1127 .style(Style::default().fg(p.warn))
1128 .block(block);
1129 f.render_widget(par, popup);
1130 return;
1131 }
1132 if u.items.is_empty() {
1133 let par = Paragraph::new("no scheduled or triggered jobs — everything runs on demand")
1134 .style(Style::default().fg(p.dim))
1135 .block(block);
1136 f.render_widget(par, popup);
1137 return;
1138 }
1139
1140 let truncate = |s: &str, max: usize| -> String {
1141 if s.chars().count() <= max {
1142 s.to_string()
1143 } else {
1144 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
1145 format!("{cut}…")
1146 }
1147 };
1148 let items: Vec<ListItem> = u
1149 .items
1150 .iter()
1151 .map(|it| {
1152 let when_color = if it.next.paused {
1153 p.dim
1154 } else if it.next.at_ms.is_some() {
1155 p.ok
1156 } else {
1157 p.warn
1158 };
1159 ListItem::new(Line::from(vec![
1160 Span::styled(
1161 format!(
1162 "{:<width$}",
1163 truncate(&it.next.label, label_w),
1164 width = label_w + 2
1165 ),
1166 Style::default().fg(when_color),
1167 ),
1168 Span::styled(
1169 format!("{:<width$}", truncate(&it.name, name_w), width = name_w + 2),
1170 Style::default().fg(p.text),
1171 ),
1172 Span::styled(
1173 truncate(&it.next.desc, desc_space),
1174 Style::default().fg(p.dim),
1175 ),
1176 ]))
1177 })
1178 .collect();
1179 let list = List::new(items)
1180 .block(block)
1181 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1182 let mut state = ListState::default().with_selected(Some(u.index));
1183 f.render_stateful_widget(list, popup, &mut state);
1184}
1185
1186fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1187 let Some(picker) = &app.wh_picker else {
1188 return;
1189 };
1190 let warehouses = app.warehouses();
1191 let name_w = warehouses
1192 .iter()
1193 .map(|(n, _, _)| n.chars().count())
1194 .max()
1195 .unwrap_or(12)
1196 .max(12);
1197 let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
1199 let height = (warehouses.len() as u16 + 4).min(area.height);
1200 let popup = Rect {
1201 x: area.x + (area.width.saturating_sub(width)) / 2,
1202 y: area.y + (area.height.saturating_sub(height)) / 2,
1203 width,
1204 height,
1205 };
1206 f.render_widget(Clear, popup);
1207 let block = Block::default()
1208 .title(Line::from(vec![
1209 Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
1210 Span::styled(
1211 "Run query on ",
1212 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1213 ),
1214 ]))
1215 .borders(Borders::ALL)
1216 .border_type(BorderType::Rounded)
1217 .border_style(
1218 Style::default()
1219 .fg(p.warehouses)
1220 .add_modifier(Modifier::BOLD),
1221 )
1222 .padding(Padding::new(1, 1, 1, 1));
1223 let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
1224 let items: Vec<ListItem> = warehouses
1225 .iter()
1226 .map(|(name, id, running)| {
1227 let dot = if *running { p.ok } else { p.dim };
1228 let marker = if current_id == Some(id.as_str()) {
1229 "» "
1230 } else {
1231 " "
1232 };
1233 let state = if *running { "running" } else { "idle " };
1234 ListItem::new(Line::from(vec![
1235 Span::styled(marker, Style::default().fg(p.key)),
1236 Span::styled("● ", Style::default().fg(dot)),
1237 Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
1238 Span::styled(
1239 format!(" {state}"),
1240 Style::default().fg(if *running { p.ok } else { p.dim }),
1241 ),
1242 Span::styled(format!(" {id}"), Style::default().fg(p.dim)),
1243 ]))
1244 })
1245 .collect();
1246 let list = List::new(items)
1247 .block(block)
1248 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1249 let mut state = ListState::default().with_selected(Some(picker.index));
1250 f.render_stateful_widget(list, popup, &mut state);
1251}
1252
1253const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
1254const WORDMARK_BOT: &str = "█▄▀ █▀█ █ █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
1255
1256fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1257 let lines_total = 9u16;
1258 let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
1259 let frame = app.spinner_frame();
1260
1261 let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
1263 for (i, row) in bricks.iter().enumerate() {
1264 let line = Line::from(Span::styled(
1265 *row,
1266 Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
1267 ));
1268 let rect = Rect {
1269 x: area.x,
1270 y: top + i as u16,
1271 width: area.width,
1272 height: 1,
1273 };
1274 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1275 }
1276
1277 let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
1279 for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
1280 let spans: Vec<Span> = text
1281 .chars()
1282 .enumerate()
1283 .map(|(i, c)| {
1284 let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
1285 let style = if dist < 3 {
1286 Style::default()
1287 .fg(Color::White)
1288 .add_modifier(Modifier::BOLD)
1289 } else {
1290 Style::default().fg(p.brand)
1291 };
1292 Span::styled(c.to_string(), style)
1293 })
1294 .collect();
1295 let rect = Rect {
1296 x: area.x,
1297 y: top + row,
1298 width: area.width,
1299 height: 1,
1300 };
1301 f.render_widget(
1302 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
1303 rect,
1304 );
1305 }
1306
1307 let tagline = Line::from(vec![
1308 Span::styled("the ", Style::default().fg(p.dim)),
1309 Span::styled(
1310 "Lakehouse",
1311 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1312 ),
1313 Span::styled(" in your terminal", Style::default().fg(p.dim)),
1314 ]);
1315 let version = Line::from(Span::styled(
1316 format!("v{}", env!("CARGO_PKG_VERSION")),
1317 Style::default().fg(p.dim),
1318 ));
1319 for (offset, line) in [(7u16, tagline), (8u16, version)] {
1320 let rect = Rect {
1321 x: area.x,
1322 y: top + offset,
1323 width: area.width,
1324 height: 1,
1325 };
1326 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1327 }
1328}
1329
1330fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1331 let selected = app.picker.unwrap_or(0);
1332 let width = (app
1333 .profiles
1334 .iter()
1335 .map(|s| s.len())
1336 .max()
1337 .unwrap_or(10)
1338 .max(20) as u16
1339 + 6)
1340 .min(area.width);
1341 let height = (app.profiles.len() as u16 + 2).min(area.height);
1342 let popup = Rect {
1343 x: area.x + (area.width.saturating_sub(width)) / 2,
1344 y: area.y + (area.height.saturating_sub(height)) / 2,
1345 width,
1346 height,
1347 };
1348 f.render_widget(Clear, popup);
1349 let block = Block::default()
1350 .title(Line::from(Span::styled(
1351 " ⌂ Workspace ",
1352 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1353 )))
1354 .borders(Borders::ALL)
1355 .border_type(BorderType::Rounded)
1356 .border_style(Style::default().fg(p.key))
1357 .padding(Padding::horizontal(1));
1358 let items: Vec<ListItem> = app
1359 .profiles
1360 .iter()
1361 .map(|name| {
1362 let current = app.profile.as_deref() == Some(name.as_str());
1363 let marker = if current { "● " } else { " " };
1364 ListItem::new(Line::from(vec![
1365 Span::styled(marker, Style::default().fg(p.ok)),
1366 Span::styled(name.as_str(), Style::default().fg(p.text)),
1367 ]))
1368 })
1369 .collect();
1370 let list = List::new(items)
1371 .block(block)
1372 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1373 let mut state = ListState::default().with_selected(Some(selected));
1374 f.render_stateful_widget(list, popup, &mut state);
1375}
1376
1377fn bucket_color(bucket: &str, p: &Palette) -> Color {
1378 match bucket {
1379 "Jobs" => p.jobs,
1380 "SQL" => p.warehouses,
1381 "All-Purpose" => p.clusters,
1382 "DLT" => p.pipelines,
1383 _ => p.dim,
1384 }
1385}
1386
1387fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1388 let cv = app.cost.as_ref().unwrap();
1389 let scope = match &cv.data {
1390 Some(Ok(d)) if d.scoped => " · this workspace",
1391 Some(Ok(_)) => " · all workspaces",
1392 _ => "",
1393 };
1394 let title = Line::from(vec![
1395 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1396 Span::styled(
1397 format!("Usage · last 14 days{scope} "),
1398 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1399 ),
1400 Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1401 ]);
1402 let block = Block::default()
1403 .title(title)
1404 .borders(Borders::ALL)
1405 .border_type(BorderType::Rounded)
1406 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1407 .padding(Padding::new(1, 1, 1, 1));
1408
1409 match &cv.data {
1410 None => {
1411 let par = Paragraph::new(format!(
1412 "{} querying system.billing.usage — the warehouse may need to start…",
1413 app.spinner()
1414 ))
1415 .style(Style::default().fg(p.warn))
1416 .block(block);
1417 f.render_widget(par, area);
1418 }
1419 Some(Err(e)) => {
1420 let par = Paragraph::new(format!(
1421 "✗ {e}\n\nsystem tables need to be enabled and readable \
1422 (grants on the `system` catalog)"
1423 ))
1424 .style(Style::default().fg(p.err))
1425 .wrap(Wrap { trim: false })
1426 .block(block);
1427 f.render_widget(par, area);
1428 }
1429 Some(Ok(data)) if data.days.is_empty() => {
1430 let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1431 .style(Style::default().fg(p.dim))
1432 .block(block);
1433 f.render_widget(par, area);
1434 }
1435 Some(Ok(data)) => {
1436 let mut lines: Vec<Line> = Vec::new();
1437
1438 let mut legend = vec![Span::raw("")];
1440 for (bucket, total, usd) in &data.buckets {
1441 legend.push(Span::styled(
1442 "■ ",
1443 Style::default().fg(bucket_color(bucket, p)),
1444 ));
1445 let amount = if data.priced {
1446 format!("{bucket} {total:.1} (${usd:.0}) ")
1447 } else {
1448 format!("{bucket} {total:.1} ")
1449 };
1450 legend.push(Span::styled(amount, Style::default().fg(p.text)));
1451 }
1452 let sigma = if data.priced {
1453 format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1454 } else {
1455 format!("Σ {:.1} DBU", data.total)
1456 };
1457 legend.push(Span::styled(
1458 sigma,
1459 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1460 ));
1461 lines.push(Line::from(legend));
1462 if data.priced {
1463 lines.push(Line::from(Span::styled(
1464 " list prices before discounts",
1465 Style::default().fg(p.dim),
1466 )));
1467 }
1468 if !data.scoped {
1469 lines.push(Line::from(Span::styled(
1470 " couldn't resolve this workspace's id — showing the whole account",
1471 Style::default().fg(p.warn),
1472 )));
1473 }
1474 lines.push(Line::default());
1475
1476 let max_day = data
1477 .days
1478 .iter()
1479 .map(|d| d.total)
1480 .fold(0.0_f64, f64::max)
1481 .max(f64::EPSILON);
1482 let inner_w = area.width.saturating_sub(4) as usize;
1483 let bar_w = inner_w.saturating_sub(22).max(10);
1484
1485 for day in &data.days {
1486 let mut spans = vec![Span::styled(
1487 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1489 Style::default().fg(p.dim),
1490 )];
1491 for (bucket, value) in &day.by_bucket {
1492 let chars = ((value / max_day) * bar_w as f64)
1493 .round()
1494 .max(if *value > 0.0 { 1.0 } else { 0.0 })
1495 as usize;
1496 spans.push(Span::styled(
1497 "█".repeat(chars),
1498 Style::default().fg(bucket_color(bucket, p)),
1499 ));
1500 }
1501 let day_label = if data.priced {
1502 format!(" {:.1} · ${:.2}", day.total, day.total_usd)
1503 } else {
1504 format!(" {:.1}", day.total)
1505 };
1506 spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1507 lines.push(Line::from(spans));
1508 }
1509
1510 if !data.spenders.is_empty() {
1511 lines.push(Line::default());
1512 lines.push(Line::from(Span::styled(
1513 "TOP SPENDERS · 14 days",
1514 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1515 )));
1516 let name_w = data
1517 .spenders
1518 .iter()
1519 .map(|s| {
1520 app.resource_name(&s.kind, &s.id)
1521 .unwrap_or_else(|| s.id.clone())
1522 .chars()
1523 .count()
1524 })
1525 .max()
1526 .unwrap_or(0)
1527 .max(8);
1528 for (i, s) in data.spenders.iter().enumerate() {
1529 let name = app
1530 .resource_name(&s.kind, &s.id)
1531 .unwrap_or_else(|| s.id.clone());
1532 let kind_color = match s.kind.as_str() {
1533 "job" => p.jobs,
1534 "cluster" => p.clusters,
1535 "warehouse" => p.warehouses,
1536 _ => p.dim,
1537 };
1538 let amount = if data.priced {
1539 format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1540 } else {
1541 format!("{:>8.1} DBU", s.dbus)
1542 };
1543 lines.push(Line::from(vec![
1544 Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1545 Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1546 Span::styled(
1547 format!("{:<width$}", name, width = name_w + 2),
1548 Style::default().fg(p.text),
1549 ),
1550 Span::styled(amount, Style::default().fg(p.text)),
1551 ]));
1552 }
1553 }
1554
1555 let par = Paragraph::new(lines).block(block);
1556 f.render_widget(par, area);
1557 }
1558 }
1559}
1560
1561fn month_bars(
1563 months: &[(String, f64, f64)],
1564 priced: bool,
1565 width: usize,
1566 p: &Palette,
1567) -> Vec<Line<'static>> {
1568 let max = months
1569 .iter()
1570 .map(|(_, dbus, _)| *dbus)
1571 .fold(0.0_f64, f64::max)
1572 .max(f64::EPSILON);
1573 let bar_w = width.saturating_sub(30).max(10);
1574 months
1575 .iter()
1576 .map(|(month, dbus, usd)| {
1577 let chars =
1578 ((dbus / max) * bar_w as f64)
1579 .round()
1580 .max(if *dbus > 0.0 { 1.0 } else { 0.0 }) as usize;
1581 let amount = if priced {
1582 format!(" {dbus:.1} · ${usd:.2}")
1583 } else {
1584 format!(" {dbus:.1}")
1585 };
1586 Line::from(vec![
1587 Span::styled(format!("{month:<9}"), Style::default().fg(p.dim)),
1588 Span::styled("█".repeat(chars), Style::default().fg(p.jobs)),
1589 Span::styled(amount, Style::default().fg(p.text)),
1590 ])
1591 })
1592 .collect()
1593}
1594
1595fn draw_item_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1596 let cv = app.item_cost.as_ref().unwrap();
1597 let scope = match &cv.data {
1598 Some(Ok(d)) if d.scoped => " · this workspace",
1599 Some(Ok(_)) => " · all workspaces",
1600 _ => "",
1601 };
1602 let title = Line::from(vec![
1603 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1604 Span::styled(
1605 format!("Spend · {} ", cv.name),
1606 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1607 ),
1608 Span::styled(
1609 format!("· {}{scope} · via {} ", cv.kind.label(), cv.warehouse),
1610 Style::default().fg(p.dim),
1611 ),
1612 ]);
1613 let block = Block::default()
1614 .title(title)
1615 .borders(Borders::ALL)
1616 .border_type(BorderType::Rounded)
1617 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1618 .padding(Padding::new(1, 1, 1, 1));
1619
1620 match &cv.data {
1621 None => {
1622 let par = Paragraph::new(format!(
1623 "{} querying system.billing.usage — the warehouse may need to start…",
1624 app.spinner()
1625 ))
1626 .style(Style::default().fg(p.warn))
1627 .block(block);
1628 f.render_widget(par, area);
1629 }
1630 Some(Err(e)) => {
1631 let par = Paragraph::new(format!(
1632 "✗ {e}\n\nsystem tables need to be enabled and readable \
1633 (grants on the `system` catalog)"
1634 ))
1635 .style(Style::default().fg(p.err))
1636 .wrap(Wrap { trim: false })
1637 .block(block);
1638 f.render_widget(par, area);
1639 }
1640 Some(Ok(data)) if data.is_empty() => {
1641 let hint = match data.kind {
1644 fetchers::cost::ResourceKind::Job => {
1645 "\n\nruns on all-purpose compute aren't attributed to a job id — \
1646 only job compute and serverless are"
1647 }
1648 fetchers::cost::ResourceKind::Pipeline => "",
1649 };
1650 let par = Paragraph::new(format!(
1651 "∅ no usage attributed to this {} in the last 365 days{hint}",
1652 data.kind.label()
1653 ))
1654 .style(Style::default().fg(p.dim))
1655 .wrap(Wrap { trim: false })
1656 .block(block);
1657 f.render_widget(par, area);
1658 }
1659 Some(Ok(data)) => {
1660 let mut lines: Vec<Line> = vec![Line::from(vec![
1661 Span::styled(
1662 format!("{:<14}{:>12}", "PERIOD", "DBU"),
1663 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1664 ),
1665 Span::styled(
1666 if data.priced {
1667 format!("{:>12}{:>14}", "USD", "VS PRIOR")
1668 } else {
1669 format!("{:>14}", "VS PRIOR")
1670 },
1671 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1672 ),
1673 ])];
1674 for w in &data.windows {
1675 let mut spans = vec![
1676 Span::styled(format!("{:<14}", w.label), Style::default().fg(p.text)),
1677 Span::styled(
1678 format!("{:>12.1}", w.dbus),
1679 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1680 ),
1681 ];
1682 if data.priced {
1683 spans.push(Span::styled(
1684 format!("{:>12}", format!("${:.2}", w.usd)),
1685 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1686 ));
1687 }
1688 let (trend, color) = match w.trend(data.priced) {
1690 Some(pct) if pct > 0.005 => (format!("▲ {:.0}%", pct * 100.0), p.warn),
1691 Some(pct) if pct < -0.005 => (format!("▼ {:.0}%", -pct * 100.0), p.ok),
1692 Some(_) => ("~ flat".to_string(), p.dim),
1693 None => ("—".to_string(), p.dim),
1694 };
1695 spans.push(Span::styled(
1696 format!("{trend:>14}"),
1697 Style::default().fg(color),
1698 ));
1699 lines.push(Line::from(spans));
1700 }
1701
1702 lines.push(Line::default());
1703 lines.push(Line::from(Span::styled(
1704 if data.priced {
1705 " list prices before discounts · trend compares with the preceding window"
1706 } else {
1707 " trend compares with the preceding window"
1708 },
1709 Style::default().fg(p.dim),
1710 )));
1711 if !data.scoped {
1712 lines.push(Line::from(Span::styled(
1713 " couldn't resolve this workspace's id — showing the whole account",
1714 Style::default().fg(p.warn),
1715 )));
1716 }
1717
1718 lines.push(Line::default());
1719 lines.push(Line::from(Span::styled(
1720 "BY MONTH",
1721 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1722 )));
1723 let inner_w = area.width.saturating_sub(4) as usize;
1724 lines.extend(month_bars(&data.months, data.priced, inner_w, p));
1725
1726 let par = Paragraph::new(lines).block(block);
1727 f.render_widget(par, area);
1728 }
1729 }
1730}
1731
1732fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1733 let console = app.sql.as_ref().unwrap();
1734 let parts = Layout::default()
1735 .direction(Direction::Vertical)
1736 .constraints([Constraint::Length(3), Constraint::Min(0)])
1737 .split(area);
1738
1739 let via = if console.warehouse.is_empty() {
1740 String::new()
1741 } else {
1742 format!("via {} ", console.warehouse)
1743 };
1744 let prompt_block = Block::default()
1745 .title(Line::from(vec![
1746 Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1747 Span::styled(
1748 "SQL Console ",
1749 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1750 ),
1751 Span::styled(via, Style::default().fg(p.dim)),
1752 ]))
1753 .borders(Borders::ALL)
1754 .border_type(BorderType::Thick)
1755 .border_style(
1756 Style::default()
1757 .fg(p.warehouses)
1758 .add_modifier(Modifier::BOLD),
1759 )
1760 .padding(Padding::horizontal(1));
1761 if let Some((query, _)) = &app.hist_search {
1762 let matched = app
1764 .hist_search_current()
1765 .map(|s| s.replace('\n', " "))
1766 .unwrap_or_default();
1767 let mut spans = vec![
1768 Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1769 Span::styled(
1770 query.as_str(),
1771 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1772 ),
1773 Span::styled("`: ", Style::default().fg(p.dim)),
1774 ];
1775 spans.extend(sql_spans(&matched, p));
1776 let prompt = Paragraph::new(Line::from(spans)).block(prompt_block);
1777 f.render_widget(prompt, parts[0]);
1778 } else {
1779 let caret_byte = console
1783 .input
1784 .char_indices()
1785 .nth(console.cursor)
1786 .map(|(i, _)| i)
1787 .unwrap_or(console.input.len());
1788 let (before, after) = console.input.split_at(caret_byte);
1789 let inner_w = parts[0].width.saturating_sub(4) as usize; let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1791 let mut spans = vec![Span::styled("❯ ", Style::default().fg(p.key))];
1794 spans.extend(sql_spans(&before.replace('\n', " "), p));
1795 spans.push(Span::styled("▏", Style::default().fg(p.warn)));
1796 spans.extend(sql_spans(&after.replace('\n', " "), p));
1797 let prompt = Paragraph::new(Line::from(spans))
1798 .scroll((0, hscroll))
1799 .block(prompt_block);
1800 f.render_widget(prompt, parts[0]);
1801 }
1802
1803 let row_info = match &console.data {
1804 Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1805 _ => String::new(),
1806 };
1807 let mut results_title = vec![
1808 Span::styled(" Results ", Style::default().fg(p.text)),
1809 Span::styled(row_info, Style::default().fg(p.dim)),
1810 ];
1811 let avail = parts[1].width.saturating_sub(4) as usize;
1813 let sliced = match &console.data {
1814 Some(Ok(t)) if console.record && !t.rows.is_empty() => {
1815 let row_n = console.scroll.min(t.rows.len().saturating_sub(1));
1816 results_title.push(Span::styled(
1817 format!("· row {}/{} (⇧←/→) ", row_n + 1, t.rows.len()),
1818 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1819 ));
1820 None
1821 }
1822 Some(Ok(t)) if !t.headers.is_empty() => {
1823 let cols: Vec<usize> = (0..t.headers.len()).collect();
1824 let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
1825 if shown.len() < cols.len() {
1826 let first = console.col.min(cols.len() - 1) + 1;
1827 results_title.push(Span::styled(
1828 format!(
1829 "· cols {}–{} of {} (⇧←/→) ",
1830 first,
1831 first + shown.len() - 1,
1832 cols.len()
1833 ),
1834 Style::default().fg(p.dim),
1835 ));
1836 }
1837 Some((shown, constraints))
1838 }
1839 _ => None,
1840 };
1841 let results_block = Block::default()
1842 .title(Line::from(results_title))
1843 .borders(Borders::ALL)
1844 .border_type(BorderType::Rounded)
1845 .border_style(Style::default().fg(p.border))
1846 .padding(Padding::horizontal(1));
1847
1848 if console.running {
1849 let par = Paragraph::new(format!(
1850 "{} running on {} — the warehouse may need a moment to start…",
1851 app.spinner(),
1852 console.warehouse
1853 ))
1854 .style(Style::default().fg(p.warn))
1855 .block(results_block);
1856 f.render_widget(par, parts[1]);
1857 return;
1858 }
1859 match &console.data {
1860 None => {
1861 let par = Paragraph::new(
1862 "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1863 )
1864 .style(Style::default().fg(p.dim))
1865 .block(results_block);
1866 f.render_widget(par, parts[1]);
1867 }
1868 Some(Err(e)) => {
1869 let par = Paragraph::new(format!("✗ {e}"))
1870 .style(Style::default().fg(p.err))
1871 .wrap(Wrap { trim: false })
1872 .block(results_block);
1873 f.render_widget(par, parts[1]);
1874 }
1875 Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1876 let par = Paragraph::new("✓ statement succeeded — no result set")
1877 .style(Style::default().fg(p.ok))
1878 .block(results_block);
1879 f.render_widget(par, parts[1]);
1880 }
1881 Some(Ok(data)) if console.record && !data.rows.is_empty() => {
1884 let row_n = console.scroll.min(data.rows.len().saturating_sub(1));
1885 let name_w = data
1886 .headers
1887 .iter()
1888 .map(|h| h.chars().count())
1889 .max()
1890 .unwrap_or(0)
1891 .min(32);
1892 let empty: Vec<String> = Vec::new();
1893 let row = data.rows.get(row_n).unwrap_or(&empty);
1894 let lines: Vec<Line> = data
1895 .headers
1896 .iter()
1897 .enumerate()
1898 .map(|(i, header)| {
1899 let value = row.get(i).map(String::as_str).unwrap_or("");
1900 let vstyle = if value == "␀" {
1901 Style::default().fg(p.dim)
1902 } else {
1903 Style::default().fg(p.text)
1904 };
1905 Line::from(vec![
1906 Span::styled(
1907 format!("{header:<name_w$} "),
1908 Style::default().fg(p.warehouses),
1909 ),
1910 Span::styled(value.to_string(), vstyle),
1911 ])
1912 })
1913 .collect();
1914 let total = lines.len();
1915 let par = Paragraph::new(lines)
1916 .scroll((console.rscroll, 0))
1917 .block(results_block);
1918 f.render_widget(par, parts[1]);
1919 scrollbar(
1920 f,
1921 parts[1],
1922 total,
1923 parts[1].height.saturating_sub(2) as usize,
1924 console.rscroll as usize,
1925 p,
1926 );
1927 }
1928 Some(Ok(data)) => {
1929 let (shown, constraints) = sliced.unwrap_or_default();
1930 let header_cells: Vec<Cell> = shown
1931 .iter()
1932 .map(|&i| {
1933 Cell::from(data.headers[i].as_str()).style(
1934 Style::default()
1935 .fg(p.warehouses)
1936 .add_modifier(Modifier::BOLD),
1937 )
1938 })
1939 .collect();
1940 let header = Row::new(header_cells);
1941 let rows: Vec<Row> = data
1942 .rows
1943 .iter()
1944 .skip(console.scroll)
1945 .map(|r| {
1946 Row::new(
1947 shown
1948 .iter()
1949 .map(|&i| {
1950 Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
1951 .style(Style::default().fg(p.text))
1952 })
1953 .collect::<Vec<_>>(),
1954 )
1955 })
1956 .collect();
1957 let table = Table::new(rows, constraints)
1958 .header(header)
1959 .column_spacing(1)
1960 .block(results_block);
1961 f.render_widget(table, parts[1]);
1962 scrollbar(
1964 f,
1965 parts[1],
1966 data.rows.len(),
1967 parts[1].height.saturating_sub(3) as usize,
1968 console.scroll,
1969 p,
1970 );
1971 }
1972 }
1973}
1974
1975fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1977 let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
1978 return;
1979 };
1980 let inner_w = area.width.saturating_sub(4) as usize;
1982 let hscroll = (console.cursor + 3).saturating_sub(inner_w);
1983 let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
1984
1985 let lines: Vec<Line> = if comp.loading {
1986 vec![Line::from(Span::styled(
1987 format!("{} fetching names…", app.spinner()),
1988 Style::default().fg(p.warn),
1989 ))]
1990 } else {
1991 let visible = 8.min(comp.items.len());
1993 let skip = comp.index.saturating_sub(visible.saturating_sub(1));
1994 comp.items
1995 .iter()
1996 .enumerate()
1997 .skip(skip)
1998 .take(visible)
1999 .map(|(i, item)| {
2000 let style = if i == comp.index {
2001 Style::default()
2002 .fg(p.key)
2003 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
2004 } else {
2005 Style::default().fg(p.text)
2006 };
2007 Line::from(Span::styled(format!(" {item} "), style))
2008 })
2009 .collect()
2010 };
2011 let widest = comp
2012 .items
2013 .iter()
2014 .map(|i| i.chars().count())
2015 .max()
2016 .unwrap_or(16);
2017 let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
2018 let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
2019 let popup = Rect {
2020 x: anchor.min(area.x + area.width.saturating_sub(width)),
2021 y: area.y + 3,
2022 width,
2023 height,
2024 };
2025 let title = if comp.loading {
2026 String::new()
2027 } else {
2028 format!(" {}/{} ", comp.index + 1, comp.items.len())
2029 };
2030 let block = Block::default()
2031 .title(Span::styled(title, Style::default().fg(p.dim)))
2032 .borders(Borders::ALL)
2033 .border_type(BorderType::Rounded)
2034 .border_style(Style::default().fg(p.key));
2035 f.render_widget(Clear, popup);
2036 f.render_widget(Paragraph::new(lines).block(block), popup);
2037}
2038
2039fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
2042 cols.iter()
2043 .map(|&i| {
2044 let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
2045 for row in data.rows.iter().take(50) {
2046 if let Some(c) = row.get(i) {
2047 w = w.max(c.chars().count());
2048 }
2049 }
2050 w.clamp(4, 36) as u16
2051 })
2052 .collect()
2053}
2054
2055fn grid_slice(
2060 data: &TableData,
2061 cols: &[usize],
2062 col: usize,
2063 avail: usize,
2064) -> (Vec<usize>, Vec<Constraint>) {
2065 let widths = grid_widths(data, cols);
2066 let col = col.min(cols.len().saturating_sub(1));
2067 let mut take = 0usize;
2068 let mut used = 0usize;
2069 for w in widths.iter().skip(col) {
2070 let next = used + *w as usize + if take > 0 { 1 } else { 0 };
2071 if take > 0 && next > avail {
2072 break;
2073 }
2074 used = next;
2075 take += 1;
2076 }
2077 let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
2078 let constraints: Vec<Constraint> = widths
2079 .iter()
2080 .skip(col)
2081 .take(take)
2082 .map(|&w| Constraint::Length(w))
2083 .collect();
2084 (shown, constraints)
2085}
2086
2087fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2088 let pv = app.preview.as_ref().unwrap();
2089 let acc = p.catalog;
2090 let mut title_spans: Vec<Span<'static>> = vec![
2091 Span::styled(" ◫ ", Style::default().fg(acc)),
2092 Span::styled(
2093 format!("{} · preview ", pv.name),
2094 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2095 ),
2096 Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
2097 ];
2098 let make_block = |spans: Vec<Span<'static>>| {
2099 Block::default()
2100 .title(Line::from(spans))
2101 .borders(Borders::ALL)
2102 .border_type(BorderType::Rounded)
2103 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2104 .padding(Padding::horizontal(1))
2105 };
2106
2107 match &pv.data {
2108 None => {
2109 let par = Paragraph::new(format!(
2110 "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
2111 app.spinner()
2112 ))
2113 .style(Style::default().fg(p.warn))
2114 .block(make_block(title_spans));
2115 f.render_widget(par, area);
2116 }
2117 Some(Err(e)) => {
2118 let text = format!(
2119 "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
2120 pv.warehouse,
2121 pv.warehouse_id,
2122 app.profile.as_deref().unwrap_or("default"),
2123 app.host.as_deref().unwrap_or("unknown"),
2124 );
2125 let par = Paragraph::new(text)
2126 .style(Style::default().fg(p.err))
2127 .wrap(Wrap { trim: false })
2128 .block(make_block(title_spans));
2129 f.render_widget(par, area);
2130 }
2131 Some(Ok(data)) => {
2132 title_spans.push(Span::styled(
2133 format!("· {} rows ", data.rows.len()),
2134 Style::default().fg(p.dim),
2135 ));
2136 let cols = pv.visible_cols();
2137 if !pv.filter.is_empty() {
2138 title_spans.push(Span::styled(
2139 format!("· cols /{} ", pv.filter),
2140 Style::default().fg(p.warn),
2141 ));
2142 }
2143 if cols.is_empty() {
2144 let par = Paragraph::new(format!(
2145 "no columns match /{} — backspace to widen, esc to clear",
2146 pv.filter
2147 ))
2148 .style(Style::default().fg(p.warn))
2149 .block(make_block(title_spans));
2150 f.render_widget(par, area);
2151 return;
2152 }
2153 if pv.record {
2154 let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
2157 title_spans.push(Span::styled(
2158 format!("· row {}/{} ", row_n + 1, data.rows.len()),
2159 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2160 ));
2161 let name_w = cols
2162 .iter()
2163 .map(|&i| data.headers[i].chars().count())
2164 .max()
2165 .unwrap_or(0)
2166 .min(32);
2167 let empty: Vec<String> = Vec::new();
2168 let row = data.rows.get(row_n).unwrap_or(&empty);
2169 let lines: Vec<Line> = cols
2170 .iter()
2171 .map(|&i| {
2172 let value = row.get(i).map(String::as_str).unwrap_or("");
2173 let vstyle = if value == "␀" {
2174 Style::default().fg(p.dim)
2175 } else {
2176 Style::default().fg(p.text)
2177 };
2178 Line::from(vec![
2179 Span::styled(
2180 format!("{:<name_w$} ", data.headers[i]),
2181 Style::default().fg(acc),
2182 ),
2183 Span::styled(value.to_string(), vstyle),
2184 ])
2185 })
2186 .collect();
2187 let total = lines.len();
2188 let par = Paragraph::new(lines)
2189 .scroll((pv.rscroll, 0))
2190 .block(make_block(title_spans));
2191 f.render_widget(par, area);
2192 scrollbar(
2193 f,
2194 area,
2195 total,
2196 area.height.saturating_sub(2) as usize,
2197 pv.rscroll as usize,
2198 p,
2199 );
2200 return;
2201 }
2202 let avail = area.width.saturating_sub(4) as usize; let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
2204 if shown.len() < cols.len() {
2205 let first = pv.col.min(cols.len() - 1) + 1;
2206 title_spans.push(Span::styled(
2207 format!(
2208 "· cols {}–{} of {} ",
2209 first,
2210 first + shown.len() - 1,
2211 cols.len()
2212 ),
2213 Style::default().fg(p.dim),
2214 ));
2215 }
2216 let header = Row::new(
2217 shown
2218 .iter()
2219 .map(|&i| {
2220 Cell::from(data.headers[i].clone())
2221 .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2222 })
2223 .collect::<Vec<_>>(),
2224 );
2225 let rows: Vec<Row> = data
2226 .rows
2227 .iter()
2228 .skip(pv.scroll)
2229 .map(|r| {
2230 Row::new(
2231 shown
2232 .iter()
2233 .map(|&i| {
2234 Cell::from(r.get(i).cloned().unwrap_or_default())
2235 .style(Style::default().fg(p.text))
2236 })
2237 .collect::<Vec<_>>(),
2238 )
2239 })
2240 .collect();
2241 let table = Table::new(rows, constraints)
2242 .header(header)
2243 .column_spacing(1)
2244 .block(make_block(title_spans));
2245 f.render_widget(table, area);
2246 scrollbar(
2248 f,
2249 area,
2250 data.rows.len(),
2251 area.height.saturating_sub(3) as usize,
2252 pv.scroll,
2253 p,
2254 );
2255 }
2256 }
2257}
2258
2259fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2260 let d = app.detail.as_ref().unwrap();
2261 let acc = accent(d.panel, p);
2262 let title = Line::from(vec![
2263 Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
2264 Span::styled(
2265 format!("{} · {} ", d.panel.title(), d.name),
2266 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2267 ),
2268 ]);
2269 let block = Block::default()
2270 .title(title)
2271 .borders(Borders::ALL)
2272 .border_type(BorderType::Rounded)
2273 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2274 .padding(Padding::horizontal(1));
2275
2276 let Some(data) = &d.data else {
2277 let par = Paragraph::new(format!("{} Loading…", app.spinner()))
2278 .style(Style::default().fg(p.warn))
2279 .block(block);
2280 f.render_widget(par, area);
2281 return;
2282 };
2283
2284 if d.show_raw || data.summary.is_empty() {
2286 let par = Paragraph::new(data.raw.as_str())
2287 .style(Style::default().fg(p.text))
2288 .wrap(Wrap { trim: false })
2289 .scroll((d.scroll, 0))
2290 .block(block);
2291 f.render_widget(par, area);
2292 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2293 scrollbar(
2294 f,
2295 area,
2296 total,
2297 area.height.saturating_sub(2) as usize,
2298 d.scroll as usize,
2299 p,
2300 );
2301 return;
2302 }
2303
2304 let mut lines: Vec<Line> = data
2305 .summary
2306 .iter()
2307 .map(|(k, v)| {
2308 Line::from(vec![
2309 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2310 Span::styled(v.as_str(), Style::default().fg(p.text)),
2311 ])
2312 })
2313 .collect();
2314 if !data.activity.is_empty() {
2315 lines.push(Line::default());
2316 lines.push(Line::from(Span::styled(
2317 d.section,
2318 Style::default().fg(acc).add_modifier(Modifier::BOLD),
2319 )));
2320 for (status, text) in &data.activity {
2321 let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
2323 if treeish {
2324 lines.push(Line::from(Span::styled(
2325 text.as_str(),
2326 Style::default().fg(status_color(status, p)),
2327 )));
2328 } else {
2329 lines.push(Line::from(vec![
2330 Span::styled("● ", Style::default().fg(status_color(status, p))),
2331 Span::styled(text.as_str(), Style::default().fg(p.text)),
2332 ]));
2333 }
2334 }
2335 }
2336 let total = lines.len();
2337 let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
2338 f.render_widget(par, area);
2339 scrollbar(
2340 f,
2341 area,
2342 total,
2343 area.height.saturating_sub(2) as usize,
2344 d.scroll as usize,
2345 p,
2346 );
2347}
2348
2349fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2350 let rv = app.run_view.as_ref().unwrap();
2351 let acc = accent(rv.panel, p);
2352 let noun = if rv.panel == Panel::Jobs {
2353 "run"
2354 } else {
2355 "update"
2356 };
2357 let mut title_spans = vec![
2358 Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
2359 Span::styled(
2360 format!("{} · {noun} ", rv.owner_name),
2361 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2362 ),
2363 ];
2364 if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
2365 title_spans.push(Span::styled(
2366 format!("{run_id} "),
2367 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2368 ));
2369 title_spans.push(Span::styled(
2370 format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
2371 Style::default().fg(p.dim),
2372 ));
2373 }
2374 if rv.live {
2375 title_spans.push(Span::styled(
2376 format!("{} live ", app.spinner()),
2377 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2378 ));
2379 }
2380 if rv.show_output {
2381 title_spans.push(Span::styled(
2382 if rv.live {
2383 "· output (tailing) "
2384 } else {
2385 "· output "
2386 },
2387 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2388 ));
2389 }
2390 if rv.show_timeline {
2391 title_spans.push(Span::styled(
2392 "· timeline ",
2393 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2394 ));
2395 }
2396 if rv.show_dag {
2397 title_spans.push(Span::styled(
2398 "· dag ",
2399 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2400 ));
2401 }
2402 if rv.show_grid {
2403 title_spans.push(Span::styled(
2404 "· history grid ",
2405 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2406 ));
2407 }
2408 let block = Block::default()
2409 .title(Line::from(title_spans))
2410 .borders(Borders::ALL)
2411 .border_type(BorderType::Rounded)
2412 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2413 .padding(Padding::horizontal(1));
2414
2415 if rv.show_output {
2417 let par = match &rv.output {
2418 Some(text) => {
2419 let lines: Vec<Line> = text.lines().map(|l| output_line(l, p)).collect();
2420 Paragraph::new(lines)
2421 .wrap(Wrap { trim: false })
2422 .scroll((rv.scroll, 0))
2423 .block(block)
2424 }
2425 None => Paragraph::new(format!(
2426 "{} fetching task output — one call per task…",
2427 app.spinner()
2428 ))
2429 .style(Style::default().fg(p.warn))
2430 .block(block),
2431 };
2432 f.render_widget(par, area);
2433 if let Some(text) = &rv.output {
2434 let total = wrapped_height(text, area.width.saturating_sub(4) as usize);
2435 scrollbar(
2436 f,
2437 area,
2438 total,
2439 area.height.saturating_sub(2) as usize,
2440 rv.scroll as usize,
2441 p,
2442 );
2443 }
2444 return;
2445 }
2446
2447 if rv.show_grid {
2448 draw_run_grid(f, area, rv, app, block, p);
2449 return;
2450 }
2451
2452 let Some(data) = &rv.data else {
2453 let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
2454 .style(Style::default().fg(p.warn))
2455 .block(block);
2456 f.render_widget(par, area);
2457 return;
2458 };
2459
2460 if rv.show_timeline {
2461 draw_run_timeline(f, area, rv.scroll, data, block, p);
2462 return;
2463 }
2464
2465 if rv.show_dag {
2466 draw_run_dag(f, area, rv.scroll, data, block, p);
2467 return;
2468 }
2469
2470 if rv.show_raw || data.summary.is_empty() {
2471 let par = Paragraph::new(data.raw.as_str())
2472 .style(Style::default().fg(p.text))
2473 .wrap(Wrap { trim: false })
2474 .scroll((rv.scroll, 0))
2475 .block(block);
2476 f.render_widget(par, area);
2477 let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2478 scrollbar(
2479 f,
2480 area,
2481 total,
2482 area.height.saturating_sub(2) as usize,
2483 rv.scroll as usize,
2484 p,
2485 );
2486 return;
2487 }
2488
2489 let mut lines: Vec<Line> = data
2490 .summary
2491 .iter()
2492 .map(|(k, v)| {
2493 Line::from(vec![
2494 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2495 Span::styled(v.as_str(), Style::default().fg(p.text)),
2496 ])
2497 })
2498 .collect();
2499 if !data.activity.is_empty() {
2500 lines.push(Line::default());
2501 lines.push(Line::from(Span::styled(
2502 if rv.panel == Panel::Jobs {
2503 "Tasks"
2504 } else {
2505 "Event log"
2506 },
2507 Style::default().fg(acc).add_modifier(Modifier::BOLD),
2508 )));
2509 for (status, text) in &data.activity {
2510 if text.starts_with(" ↳") {
2512 lines.push(Line::from(Span::styled(
2513 text.as_str(),
2514 Style::default().fg(p.err),
2515 )));
2516 } else {
2517 lines.push(Line::from(vec![
2518 Span::styled("● ", Style::default().fg(status_color(status, p))),
2519 Span::styled(text.as_str(), Style::default().fg(p.text)),
2520 ]));
2521 }
2522 }
2523 }
2524 let total = lines.len();
2525 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2526 f.render_widget(par, area);
2527 scrollbar(
2528 f,
2529 area,
2530 total,
2531 area.height.saturating_sub(2) as usize,
2532 rv.scroll as usize,
2533 p,
2534 );
2535}
2536
2537fn draw_run_timeline(
2540 f: &mut Frame,
2541 area: Rect,
2542 scroll: u16,
2543 data: &DetailData,
2544 block: Block,
2545 p: &Palette,
2546) {
2547 let tasks = crate::fetchers::runs::timeline(&data.raw);
2548 let now = std::time::SystemTime::now()
2549 .duration_since(std::time::UNIX_EPOCH)
2550 .map(|d| d.as_millis() as u64)
2551 .unwrap_or(0);
2552 let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
2553 let Some(t0) = t0 else {
2554 let par = Paragraph::new(
2555 "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
2556 )
2557 .style(Style::default().fg(p.dim))
2558 .wrap(Wrap { trim: false })
2559 .block(block);
2560 f.render_widget(par, area);
2561 return;
2562 };
2563 let t1 = tasks
2564 .iter()
2565 .filter(|t| t.start > 0)
2566 .map(|t| t.end.unwrap_or(now).max(t.start))
2567 .max()
2568 .unwrap_or(t0);
2569 let span = (t1 - t0).max(1);
2570
2571 let name_w = tasks
2572 .iter()
2573 .map(|t| t.key.chars().count())
2574 .max()
2575 .unwrap_or(4)
2576 .clamp(4, 24);
2577 let inner_w = area.width.saturating_sub(4) as usize;
2578 let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
2580
2581 let mut lines: Vec<Line> = vec![
2582 Line::from(Span::styled(
2583 format!(
2584 "◷ {} task{} · span {}",
2585 tasks.len(),
2586 if tasks.len() == 1 { "" } else { "s" },
2587 crate::shape::fmt_duration_ms(span)
2588 ),
2589 Style::default().fg(p.dim),
2590 )),
2591 Line::default(),
2592 ];
2593 for t in &tasks {
2594 let mut key: String = t.key.chars().take(name_w).collect();
2595 if key.len() < t.key.len() {
2596 key.pop();
2597 key.push('…');
2598 }
2599 let name = Span::styled(format!("{key:<name_w$} "), Style::default().fg(p.text));
2600 if t.start == 0 {
2601 lines.push(Line::from(vec![
2602 name,
2603 Span::styled(
2604 format!("– {}", t.status.label().to_lowercase()),
2605 Style::default().fg(p.dim),
2606 ),
2607 ]));
2608 continue;
2609 }
2610 let end = t.end.unwrap_or(now).max(t.start);
2611 let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
2612 let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
2613 let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
2614 let dur = format!(
2615 " {}{}",
2616 crate::shape::fmt_duration_ms(end - t.start),
2617 if t.end.is_none() { "…" } else { "" }
2618 );
2619 lines.push(Line::from(vec![
2620 name,
2621 Span::styled(" ".repeat(off), Style::default()),
2622 Span::styled(
2623 "█".repeat(len),
2624 Style::default().fg(status_color(&t.status, p)),
2625 ),
2626 Span::styled(dur, Style::default().fg(p.dim)),
2627 ]));
2628 }
2629 let total = lines.len();
2630 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2631 f.render_widget(par, area);
2632 scrollbar(
2633 f,
2634 area,
2635 total,
2636 area.height.saturating_sub(2) as usize,
2637 scroll as usize,
2638 p,
2639 );
2640}
2641
2642fn draw_run_dag(
2645 f: &mut Frame,
2646 area: Rect,
2647 scroll: u16,
2648 data: &DetailData,
2649 block: Block,
2650 p: &Palette,
2651) {
2652 let rows = crate::fetchers::runs::dag(&data.raw);
2653 if rows.is_empty() {
2654 let par = Paragraph::new(
2655 "no task graph recorded — legacy single-task runs carry none (J shows the raw JSON)",
2656 )
2657 .style(Style::default().fg(p.dim))
2658 .wrap(Wrap { trim: false })
2659 .block(block);
2660 f.render_widget(par, area);
2661 return;
2662 }
2663 let mut lines: Vec<Line> = vec![
2664 Line::from(Span::styled(
2665 format!(
2666 "⧉ {} task{} · order of execution, top to bottom",
2667 rows.len(),
2668 if rows.len() == 1 { "" } else { "s" },
2669 ),
2670 Style::default().fg(p.dim),
2671 )),
2672 Line::default(),
2673 ];
2674 for r in &rows {
2675 let mut spans = vec![
2676 Span::styled(r.prefix.clone(), Style::default().fg(p.dim)),
2677 Span::styled("● ", Style::default().fg(status_color(&r.status, p))),
2678 Span::styled(r.key.clone(), Style::default().fg(p.text)),
2679 Span::styled(
2680 format!(" {}", r.status.label().to_lowercase()),
2681 Style::default().fg(status_color(&r.status, p)),
2682 ),
2683 ];
2684 if let Some(d) = r.duration {
2685 spans.push(Span::styled(
2686 format!(" · {}", crate::shape::fmt_duration_ms(d)),
2687 Style::default().fg(p.dim),
2688 ));
2689 }
2690 if !r.also_after.is_empty() {
2691 spans.push(Span::styled(
2692 format!(" (also after {})", r.also_after.join(", ")),
2693 Style::default().fg(p.dim),
2694 ));
2695 }
2696 lines.push(Line::from(spans));
2697 }
2698 let total = lines.len();
2699 let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2700 f.render_widget(par, area);
2701 scrollbar(
2702 f,
2703 area,
2704 total,
2705 area.height.saturating_sub(2) as usize,
2706 scroll as usize,
2707 p,
2708 );
2709}
2710
2711fn spark(vals: &[u64]) -> String {
2714 const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2715 let (min, max) = vals
2716 .iter()
2717 .fold((u64::MAX, 0), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
2718 vals.iter()
2719 .map(|v| {
2720 if max == min {
2721 '▄'
2722 } else {
2723 BARS[((v - min) * 7 / (max - min)) as usize]
2724 }
2725 })
2726 .collect()
2727}
2728
2729fn draw_run_grid(
2735 f: &mut Frame,
2736 area: Rect,
2737 rv: &crate::app::RunView,
2738 app: &App,
2739 block: Block,
2740 p: &Palette,
2741) {
2742 let g = match &rv.grid {
2743 None => {
2744 let par = Paragraph::new(format!("{} loading run history…", app.spinner()))
2745 .style(Style::default().fg(p.warn))
2746 .block(block);
2747 f.render_widget(par, area);
2748 return;
2749 }
2750 Some(Err(e)) => {
2751 let par = Paragraph::new(format!("✗ {e}"))
2752 .style(Style::default().fg(p.err))
2753 .wrap(Wrap { trim: false })
2754 .block(block);
2755 f.render_widget(par, area);
2756 return;
2757 }
2758 Some(Ok(g)) => g,
2759 };
2760 if g.tasks.is_empty() {
2761 let par = Paragraph::new(
2762 "no per-task history recorded — legacy single-task runs carry none (J shows the raw JSON)",
2763 )
2764 .style(Style::default().fg(p.dim))
2765 .wrap(Wrap { trim: false })
2766 .block(block);
2767 f.render_widget(par, area);
2768 return;
2769 }
2770
2771 let name_w = g
2772 .tasks
2773 .iter()
2774 .map(|t| t.key.chars().count())
2775 .max()
2776 .unwrap_or(4)
2777 .clamp(4, 24);
2778 let inner_w = area.width.saturating_sub(4) as usize;
2779 let trend_w = 28;
2781 let cols_fit = (inner_w.saturating_sub(name_w + 2 + trend_w) / 2).max(1);
2783 let skip = g.runs.len().saturating_sub(cols_fit);
2784 let cols = &g.runs[skip..];
2785
2786 let mut lines: Vec<Line> = vec![
2787 Line::from(Span::styled(
2788 format!(
2789 "⊞ {} task{} × last {} run{} · oldest → newest",
2790 g.tasks.len(),
2791 if g.tasks.len() == 1 { "" } else { "s" },
2792 cols.len(),
2793 if cols.len() == 1 { "" } else { "s" },
2794 ),
2795 Style::default().fg(p.dim),
2796 )),
2797 Line::default(),
2798 ];
2799
2800 let sel = rv.runs.get(rv.idx).map(|(id, _, _)| id.as_str());
2802 if let Some(ci) = sel.and_then(|id| cols.iter().position(|r| r.run_id == id)) {
2803 let acc = accent(Panel::Jobs, p);
2804 lines.push(Line::from(vec![
2805 Span::raw(" ".repeat(name_w + 2 + 2 * ci)),
2806 Span::styled("▾", Style::default().fg(acc).add_modifier(Modifier::BOLD)),
2807 ]));
2808 }
2809
2810 let mut run_row = vec![Span::styled(
2811 format!("{:<name_w$} ", "run"),
2812 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
2813 )];
2814 for r in cols {
2815 run_row.push(Span::styled(
2816 format!("{} ", history_glyph(&r.status)),
2817 Style::default().fg(status_color(&r.status, p)),
2818 ));
2819 }
2820 if let Some(newest) = cols.last() {
2821 run_row.push(Span::styled(
2822 format!(" newest {}", newest.age),
2823 Style::default().fg(p.dim),
2824 ));
2825 }
2826 lines.push(Line::from(run_row));
2827 lines.push(Line::default());
2828
2829 for t in &g.tasks {
2830 let mut key: String = t.key.chars().take(name_w).collect();
2831 if key.chars().count() < t.key.chars().count() {
2832 key.pop();
2833 key.push('…');
2834 }
2835 let mut spans = vec![Span::styled(
2836 format!("{key:<name_w$} "),
2837 Style::default().fg(p.text),
2838 )];
2839 for cell in &t.cells[skip..] {
2840 spans.push(match cell {
2841 Some(s) => Span::styled(
2842 format!("{} ", history_glyph(s)),
2843 Style::default().fg(status_color(s, p)),
2844 ),
2845 None => Span::styled("· ", Style::default().fg(p.dim)),
2846 });
2847 }
2848 let ds = t.success_durations();
2849 if let Some(latest) = ds.last() {
2850 let window = &ds[ds.len().saturating_sub(10)..];
2851 spans.push(Span::styled(
2852 format!(" {}", spark(window)),
2853 Style::default().fg(p.dim),
2854 ));
2855 spans.push(Span::styled(
2856 format!(" {}", crate::shape::fmt_duration_ms(*latest)),
2857 Style::default().fg(p.text),
2858 ));
2859 }
2860 if let Some((latest, median)) = t.slowdown() {
2861 let ratio = latest as f64 / median as f64;
2862 let color = if ratio >= 2.0 { p.err } else { p.warn };
2863 spans.push(Span::styled(
2864 format!(" ▲{ratio:.1}×"),
2865 Style::default().fg(color).add_modifier(Modifier::BOLD),
2866 ));
2867 }
2868 lines.push(Line::from(spans));
2869 }
2870
2871 let total = lines.len();
2872 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2873 f.render_widget(par, area);
2874 scrollbar(
2875 f,
2876 area,
2877 total,
2878 area.height.saturating_sub(2) as usize,
2879 rv.scroll as usize,
2880 p,
2881 );
2882}
2883
2884fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2885 let block = Block::default()
2886 .borders(Borders::ALL)
2887 .border_type(BorderType::Rounded)
2888 .border_style(Style::default().fg(p.border));
2889 let inner = block.inner(area);
2890 f.render_widget(block, area);
2891
2892 let (from, to) = (p.grad_from, p.grad_to);
2896 let grad = |t: f32| {
2897 Color::Rgb(
2898 (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
2899 (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
2900 (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
2901 )
2902 };
2903 let mut left = vec![
2904 Span::styled(
2905 " ◢",
2906 Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
2907 ),
2908 Span::styled(
2909 "◤ ",
2910 Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
2911 ),
2912 ];
2913 let word: Vec<char> = "DATABRICKS".chars().collect();
2914 let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
2915 for (i, ch) in word.iter().enumerate() {
2916 let t = i as f32 / (word.len() - 1) as f32;
2917 let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
2918 Style::default().fg(p.text).add_modifier(Modifier::BOLD)
2919 } else {
2920 Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
2921 };
2922 left.push(Span::styled(ch.to_string(), style));
2923 }
2924 left.push(Span::raw(" "));
2925 for ch in "LAKEHOUSE".chars() {
2926 left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
2927 }
2928 left.push(Span::styled(
2929 format!("· v{}", env!("CARGO_PKG_VERSION")),
2930 Style::default().fg(p.dim),
2931 ));
2932 if let Some(profile) = &app.profile {
2933 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2934 left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
2935 left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
2936 }
2937 if let Some(Shape::Badge(b)) = &app.user_badge {
2938 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2939 left.push(Span::styled(
2940 format!("{} {}", b.label, b.value),
2941 Style::default().fg(p.key),
2942 ));
2943 }
2944 if app.zoomed {
2945 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
2946 left.push(Span::styled(
2947 format!("⛶ {}", app.focus.title()),
2948 Style::default()
2949 .fg(accent(app.focus, p))
2950 .add_modifier(Modifier::BOLD),
2951 ));
2952 }
2953
2954 let (ok, pending, failed, idle) = app.status_counts();
2956 for (count, glyph, color) in [
2957 (ok, "●", p.ok),
2958 (pending, "◐", p.warn),
2959 (failed, "✗", p.err),
2960 (idle, "○", p.dim),
2961 ] {
2962 if count > 0 {
2963 left.push(Span::styled(" ", Style::default()));
2964 left.push(Span::styled(
2965 format!("{glyph} {count}"),
2966 Style::default().fg(color),
2967 ));
2968 }
2969 }
2970 if !app.watched.is_empty() {
2972 left.push(Span::styled(" ", Style::default()));
2973 left.push(Span::styled(
2974 format!("👁 {}", app.watched.len()),
2975 Style::default().fg(p.warn),
2976 ));
2977 }
2978 f.render_widget(Paragraph::new(Line::from(left)), inner);
2979
2980 let right = if app.loading {
2981 Line::from(Span::styled(
2982 format!("{} refreshing ", app.spinner()),
2983 Style::default().fg(p.warn),
2984 ))
2985 } else {
2986 Line::from(Span::styled(
2987 format!("updated {}s ago ", app.last_refresh_age().as_secs()),
2988 Style::default().fg(p.dim),
2989 ))
2990 };
2991 f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
2992}
2993
2994fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2995 let key =
2996 |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
2997 let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
2998
2999 if let Some(confirm) = &app.confirm {
3000 let mut spans = vec![
3001 Span::styled(
3002 format!(" ⚠ {} ", confirm.message),
3003 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3004 ),
3005 key("y"),
3006 dim(" confirm · "),
3007 ];
3008 if confirm.params.is_some() {
3009 spans.push(key("p"));
3010 spans.push(dim(" edit parameters · "));
3011 }
3012 spans.push(dim("any other key cancels"));
3013 f.render_widget(Paragraph::new(Line::from(spans)), area);
3014 return;
3015 }
3016
3017 if app.filter_entry {
3018 let line = Line::from(vec![
3019 Span::styled(
3020 format!(" /{}", app.active_filter()),
3021 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3022 ),
3023 Span::styled("▏", Style::default().fg(p.warn)),
3024 dim(" type to filter "),
3025 key("enter"),
3026 dim(" apply "),
3027 key("esc"),
3028 dim(" clear"),
3029 ]);
3030 f.render_widget(Paragraph::new(line), area);
3031 return;
3032 }
3033
3034 if let Some(pv) = &app.preview {
3035 if pv.filter_entry {
3036 let line = Line::from(vec![
3037 Span::styled(
3038 format!(" cols /{}", pv.filter),
3039 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3040 ),
3041 Span::styled("▏", Style::default().fg(p.warn)),
3042 dim(" type to filter columns "),
3043 key("enter"),
3044 dim(" apply "),
3045 key("esc"),
3046 dim(" clear"),
3047 ]);
3048 f.render_widget(Paragraph::new(line), area);
3049 return;
3050 }
3051 }
3052
3053 let mut spans = if app.param_form.is_some() {
3054 vec![
3055 dim(" type key=value pairs "),
3056 key("enter"),
3057 dim(" run "),
3058 key("esc"),
3059 dim(" cancel"),
3060 ]
3061 } else if app.secret_form.is_some() {
3062 vec![
3063 dim(" type "),
3064 key("enter"),
3065 dim(" next / save "),
3066 key("esc"),
3067 dim(" cancel"),
3068 ]
3069 } else if app.help {
3070 vec![
3071 dim(" "),
3072 key("j"),
3073 dim("/"),
3074 key("k"),
3075 dim(" scroll any other key closes"),
3076 ]
3077 } else if app.pane_cfg.is_some() {
3078 vec![
3079 dim(" "),
3080 key("j"),
3081 dim("/"),
3082 key("k"),
3083 dim(" select "),
3084 key("space"),
3085 dim(" show/hide "),
3086 key("J"),
3087 dim("/"),
3088 key("K"),
3089 dim(" move "),
3090 key("esc"),
3091 dim(" done"),
3092 ]
3093 } else if app.jump.is_some() {
3094 vec![
3095 dim(" type to search everything "),
3096 key("\u{2191}"),
3097 dim("/"),
3098 key("\u{2193}"),
3099 dim(" select "),
3100 key("enter"),
3101 dim(" jump "),
3102 key("esc"),
3103 dim(" close"),
3104 ]
3105 } else if app.problems.is_some() {
3106 vec![
3107 dim(" "),
3108 key("j"),
3109 dim("/"),
3110 key("k"),
3111 dim(" select "),
3112 key("enter"),
3113 dim(" jump to item / switch workspace "),
3114 key("esc"),
3115 dim(" close"),
3116 ]
3117 } else if app.upcoming.is_some() {
3118 vec![
3119 dim(" "),
3120 key("j"),
3121 dim("/"),
3122 key("k"),
3123 dim(" select "),
3124 key("enter"),
3125 dim(" jump to job "),
3126 key("esc"),
3127 dim(" close"),
3128 ]
3129 } else if app.cost.is_some() || app.item_cost.is_some() {
3130 vec![
3131 dim(" "),
3132 key("esc"),
3133 dim(" back "),
3134 key("q"),
3135 dim(" quit"),
3136 ]
3137 } else if app.wh_picker.is_some() {
3138 vec![
3139 dim(" "),
3140 key("j"),
3141 dim("/"),
3142 key("k"),
3143 dim(" select "),
3144 key("enter"),
3145 dim(" run preview "),
3146 key("esc"),
3147 dim(" cancel"),
3148 ]
3149 } else if app.sql.is_some() {
3150 if app.hist_search.is_some() {
3151 vec![
3152 dim(" type to search history "),
3153 key("^r"),
3154 dim(" older match "),
3155 key("enter"),
3156 dim(" accept "),
3157 key("esc"),
3158 dim(" cancel"),
3159 ]
3160 } else {
3161 let record = app.sql.as_ref().is_some_and(|c| c.record);
3162 let transposable = app
3165 .sql
3166 .as_ref()
3167 .is_some_and(|c| matches!(&c.data, Some(Ok(t)) if !t.rows.is_empty()));
3168 let mut spans = vec![
3169 dim(" "),
3170 key("enter"),
3171 dim(" run "),
3172 key("tab"),
3173 dim(" complete "),
3174 key("↑"),
3175 dim("/"),
3176 key("↓"),
3177 dim(" history "),
3178 key("^r"),
3179 dim(" search "),
3180 key("^x"),
3181 dim(" editor "),
3182 key("pgup"),
3183 dim("/"),
3184 key("pgdn"),
3185 dim(if record { " fields " } else { " scroll " }),
3187 key("⇧←→"),
3188 dim(if record { " rows " } else { " cols " }),
3189 ];
3190 if transposable {
3191 spans.push(key("f2"));
3197 spans.push(dim(if record { " grid " } else { " record " }));
3198 }
3199 spans.extend([
3200 key("^s"),
3201 dim(" export "),
3202 key("esc"),
3203 dim(if app.sql.as_ref().is_some_and(|c| c.running) {
3204 " cancel query"
3205 } else if record {
3206 " back to grid"
3207 } else {
3208 " close"
3209 }),
3210 ]);
3211 spans
3212 }
3213 } else if let Some(pv) = &app.preview {
3214 if pv.record {
3215 vec![
3216 dim(" "),
3217 key("j"),
3218 dim("/"),
3219 key("k"),
3220 dim(" fields "),
3221 key("h"),
3222 dim("/"),
3223 key("l"),
3224 dim(" prev/next row "),
3225 key("v"),
3226 dim(" grid "),
3227 key("/"),
3228 dim(" filter cols "),
3229 key("esc"),
3230 dim(" back"),
3231 ]
3232 } else {
3233 vec![
3234 dim(" "),
3235 key("esc"),
3236 dim(" back "),
3237 key("j"),
3238 dim("/"),
3239 key("k"),
3240 dim(" rows "),
3241 key("←"),
3242 dim("/"),
3243 key("→"),
3244 dim(" columns "),
3245 key("v"),
3246 dim(" row view "),
3247 key("/"),
3248 dim(" filter cols "),
3249 key("e"),
3250 dim(" export csv "),
3251 key("q"),
3252 dim(" quit"),
3253 ]
3254 }
3255 } else if app.picker.is_some() {
3256 vec![
3257 dim(" "),
3258 key("j"),
3259 dim("/"),
3260 key("k"),
3261 dim(" select "),
3262 key("enter"),
3263 dim(" switch workspace "),
3264 key("esc"),
3265 dim(" cancel"),
3266 ]
3267 } else if let Some(rv) = &app.run_view {
3268 if rv.show_output {
3269 vec![
3270 dim(" "),
3271 key("esc"),
3272 dim(" back to run "),
3273 key("j"),
3274 dim("/"),
3275 key("k"),
3276 dim(" scroll "),
3277 key("q"),
3278 dim(" quit"),
3279 ]
3280 } else {
3281 let mut spans = vec![
3282 dim(" "),
3283 key("esc"),
3284 dim(" back "),
3285 key("h"),
3286 dim("/"),
3287 key("l"),
3288 dim(" older/newer "),
3289 ];
3290 if rv.panel == Panel::Jobs {
3291 spans.push(key("o"));
3292 spans.push(dim(" output "));
3293 spans.push(key("t"));
3294 spans.push(dim(" timeline "));
3295 spans.push(key("d"));
3296 spans.push(dim(" dag "));
3297 spans.push(key("g"));
3298 spans.push(dim(" grid "));
3299 spans.push(key("r"));
3300 spans.push(dim(" repair "));
3301 spans.push(key("W"));
3302 spans.push(dim(
3303 if app.watched.iter().any(|w| {
3304 rv.runs
3305 .get(rv.idx)
3306 .is_some_and(|(id, _, _)| *id == w.run_id)
3307 }) {
3308 " unwatch "
3309 } else {
3310 " watch "
3311 },
3312 ));
3313 }
3314 spans.extend([
3315 key("s"),
3316 dim(" cancel "),
3317 key("j"),
3318 dim("/"),
3319 key("k"),
3320 dim(" scroll "),
3321 key("J"),
3322 dim(" raw "),
3323 key("q"),
3324 dim(" quit"),
3325 ]);
3326 spans
3327 }
3328 } else if app.detail.is_some() {
3329 let mut spans = vec![dim(" "), key("esc"), dim(" back ")];
3330 match app.detail.as_ref() {
3331 Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
3332 spans.push(key("enter"));
3333 spans.push(dim(" latest run "));
3334 }
3335 Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
3336 spans.push(key("enter"));
3337 spans.push(dim(" latest update "));
3338 }
3339 _ => {}
3340 }
3341 spans.extend([
3342 key("j"),
3343 dim("/"),
3344 key("k"),
3345 dim(" scroll "),
3346 key("J"),
3347 dim(" raw "),
3348 key("o"),
3349 dim(" open "),
3350 key("q"),
3351 dim(" quit"),
3352 ]);
3353 spans
3354 } else {
3355 let mut spans = vec![
3356 dim(" "),
3357 key("tab"),
3358 dim("/"),
3359 key("h"),
3360 dim("/"),
3361 key("l"),
3362 dim(" switch "),
3363 key("j"),
3364 dim("/"),
3365 key("k"),
3366 dim(" select "),
3367 key("/"),
3368 dim(if app.active_filter().is_empty() {
3369 " filter "
3370 } else {
3371 " filter · esc clears "
3372 }),
3373 ];
3374 let enter_label = match app.focus {
3376 Panel::Secrets if app.secret_scope.is_some() => None,
3377 Panel::Secrets => Some(" open scope "),
3378 Panel::Catalog if app.uc_path.len() < 2 => Some(" open "),
3379 _ => Some(" details "),
3380 };
3381 if let Some(label) = enter_label {
3382 spans.push(key("enter"));
3383 spans.push(dim(label));
3384 }
3385 match app.focus {
3387 Panel::Catalog => {
3388 if !app.uc_path.is_empty() {
3389 spans.push(key("bksp"));
3390 spans.push(dim(" up "));
3391 }
3392 if app.uc_path.len() == 2 {
3393 spans.push(key("p"));
3394 spans.push(dim(" preview "));
3395 spans.push(key("L"));
3396 spans.push(dim(" lineage "));
3397 spans.push(key(":"));
3398 spans.push(dim(" query table "));
3399 }
3400 }
3401 Panel::Dashboards => {}
3402 Panel::Secrets => {
3403 if app.secret_scope.is_some() {
3404 spans.push(key("bksp"));
3405 spans.push(dim(" up "));
3406 }
3407 spans.push(key("a"));
3408 spans.push(dim(if app.secret_scope.is_some() {
3409 " add secret "
3410 } else {
3411 " new scope "
3412 }));
3413 spans.push(key("x"));
3414 spans.push(dim(" delete "));
3415 }
3416 _ => {
3417 spans.push(key("s"));
3418 spans.push(dim(" action "));
3419 if app.focus == Panel::Jobs {
3420 spans.push(key("S"));
3421 spans.push(dim(" pause "));
3422 }
3423 if matches!(app.focus, Panel::Jobs | Panel::Pipelines) {
3424 spans.push(key("c"));
3425 spans.push(dim(" spend "));
3426 }
3427 }
3428 }
3429 spans.extend([
3430 key("g"),
3431 dim(" access "),
3432 key("$"),
3433 dim(" cost "),
3434 key("!"),
3435 dim(" problems "),
3436 key("u"),
3437 dim(" upcoming "),
3438 ]);
3439 if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
3441 spans.push(key(":"));
3442 spans.push(dim(" sql "));
3443 }
3444 spans.extend([
3445 key("o"),
3446 dim(" open "),
3447 key("z"),
3448 dim(if app.zoomed { " unzoom " } else { " zoom " }),
3449 key("w"),
3450 dim(" workspace "),
3451 key("t"),
3452 dim(" theme "),
3453 key("r"),
3454 dim(" refresh "),
3455 key("?"),
3456 dim(" help "),
3457 key("q"),
3458 dim(" quit"),
3459 ]);
3460 spans
3461 };
3462 if let Some((msg, _)) = &app.flash {
3464 let color = if msg.starts_with('✗') { p.err } else { p.ok };
3465 let mut prefixed = vec![
3466 Span::styled(
3467 format!(" {msg}"),
3468 Style::default().fg(color).add_modifier(Modifier::BOLD),
3469 ),
3470 dim(" · "),
3471 ];
3472 prefixed.append(&mut spans);
3473 spans = prefixed;
3474 }
3475 f.render_widget(Paragraph::new(Line::from(spans)), area);
3476}
3477
3478#[allow(clippy::too_many_arguments)]
3479fn draw_panel(
3480 f: &mut Frame,
3481 area: Rect,
3482 panel: Panel,
3483 shape: Option<&Shape>,
3484 focused: bool,
3485 selected: Option<usize>,
3486 fresh: bool,
3487 breadcrumb: &str,
3488 spinner: &str,
3489 filter: &str,
3490 entering: bool,
3491 favs: &std::collections::HashSet<String>,
3492 fav_only: bool,
3493 p: &Palette,
3494) {
3495 let accent = accent(panel, p);
3496 let dimmed = |s: Style| {
3499 if focused {
3500 s
3501 } else {
3502 s.add_modifier(Modifier::DIM)
3503 }
3504 };
3505 let (border_style, title_style) = if focused {
3506 (
3507 Style::default().fg(accent).add_modifier(Modifier::BOLD),
3508 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
3509 )
3510 } else {
3511 (Style::default().fg(p.border), Style::default().fg(p.dim))
3512 };
3513 let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
3514 Some(Shape::List(items)) => Some(
3515 items
3516 .iter()
3517 .filter(|it| {
3518 crate::shape::item_matches(it, filter)
3519 && (!fav_only || favs.contains(&crate::shape::fav_key(it)))
3520 })
3521 .collect(),
3522 ),
3523 _ => None,
3524 };
3525 let count = match (&visible, shape) {
3526 (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
3528 format!(" · {}/{}", v.len(), items.len())
3529 }
3530 (Some(v), _) => format!(" · {}", v.len()),
3531 (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
3532 _ => String::new(),
3533 };
3534 let crumb = if !breadcrumb.is_empty() {
3535 format!(" ▸ {breadcrumb}")
3536 } else {
3537 String::new()
3538 };
3539 let title_style = if fresh {
3541 Style::default()
3542 .bg(accent)
3543 .fg(Color::Black)
3544 .add_modifier(Modifier::BOLD)
3545 } else {
3546 title_style
3547 };
3548 let mut title_spans = vec![
3549 Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
3550 Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
3551 ];
3552 if entering || !filter.is_empty() {
3553 title_spans.push(Span::styled(
3554 format!("/{}{} ", filter, if entering { "▏" } else { "" }),
3555 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3556 ));
3557 }
3558 if fav_only {
3559 title_spans.push(Span::styled(
3560 "★only ",
3561 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3562 ));
3563 }
3564 let title = Line::from(title_spans);
3565 let block = Block::default()
3566 .title(title)
3567 .borders(Borders::ALL)
3568 .border_type(if focused {
3569 BorderType::Thick
3570 } else {
3571 BorderType::Rounded
3572 })
3573 .border_style(border_style)
3574 .padding(Padding::horizontal(1));
3575
3576 match shape {
3577 None => {
3578 let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
3579 .style(Style::default().fg(p.warn))
3580 .block(block);
3581 f.render_widget(par, area);
3582 }
3583 Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
3584 let empty = if !items.is_empty() {
3585 format!("∅ nothing matches /{filter}")
3586 } else {
3587 let msg = match panel {
3588 Panel::Clusters => "no compute running",
3589 Panel::Jobs => "no jobs defined",
3590 Panel::Pipelines => "no pipelines",
3591 Panel::Warehouses => "no warehouses",
3592 Panel::Dashboards => "no dashboards yet",
3593 Panel::Catalog => "nothing at this level",
3594 Panel::Secrets => "no secret scopes",
3595 };
3596 format!("∅ {msg}")
3597 };
3598 let par = Paragraph::new(empty)
3599 .style(Style::default().fg(p.dim))
3600 .block(block);
3601 f.render_widget(par, area);
3602 }
3603 Some(Shape::List(_)) => {
3604 let list_items: Vec<ListItem> = visible
3605 .unwrap_or_default()
3606 .iter()
3607 .map(|item| {
3608 let color = status_color(&item.status, p);
3609 let chip = match item.status {
3610 Status::Stopped | Status::Unknown(_) => Span::styled(
3611 format!(" {} ", item.status.label()),
3612 Style::default().fg(color).add_modifier(Modifier::DIM),
3613 ),
3614 _ => Span::styled(
3615 format!(" {} ", item.status.label()),
3616 Style::default()
3617 .bg(color)
3618 .fg(Color::Black)
3619 .add_modifier(Modifier::BOLD),
3620 ),
3621 };
3622 let star = if favs.contains(&crate::shape::fav_key(item)) {
3623 Span::styled("★ ", Style::default().fg(p.warn))
3624 } else {
3625 Span::raw(" ")
3626 };
3627 let mut spans = vec![
3628 star,
3629 Span::styled("● ", Style::default().fg(color)),
3630 Span::styled(item.name.as_str(), dimmed(Style::default().fg(p.text))),
3631 Span::raw(" "),
3632 chip,
3633 ];
3634 if !item.history.is_empty() {
3635 spans.push(Span::raw(" "));
3636 for run in &item.history {
3637 spans.push(Span::styled(
3638 history_glyph(run),
3639 Style::default().fg(status_color(run, p)),
3640 ));
3641 }
3642 }
3643 if let Some(detail) = &item.detail {
3644 let fg = if item.alert.is_some() { p.warn } else { p.dim };
3646 spans.push(Span::styled(
3647 format!(" {}", detail),
3648 dimmed(Style::default().fg(fg)),
3649 ));
3650 }
3651 ListItem::new(Line::from(spans))
3652 })
3653 .collect();
3654 let list = List::new(list_items)
3655 .block(block)
3656 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
3657 match selected {
3658 Some(sel) => {
3659 let mut state = ListState::default().with_selected(Some(sel));
3660 f.render_stateful_widget(list, area, &mut state);
3661 }
3662 None => f.render_widget(list, area),
3663 }
3664 }
3665 Some(Shape::Table(data)) => {
3666 let header_cells: Vec<Cell> = data
3667 .headers
3668 .iter()
3669 .map(|h| {
3670 Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
3671 })
3672 .collect();
3673 let header = Row::new(header_cells).style(Style::default().fg(accent));
3674 let rows: Vec<Row> = data
3675 .rows
3676 .iter()
3677 .map(|r| {
3678 Row::new(
3679 r.iter()
3680 .map(|c| Cell::from(c.as_str()).style(dimmed(Style::default())))
3681 .collect::<Vec<_>>(),
3682 )
3683 })
3684 .collect();
3685 let widths: Vec<Constraint> = data
3686 .headers
3687 .iter()
3688 .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
3689 .collect();
3690 let table = Table::new(rows, widths).header(header).block(block);
3691 f.render_widget(table, area);
3692 }
3693 Some(Shape::Badge(b)) => {
3694 let text = format!("{}: {}", b.label, b.value);
3695 let par = Paragraph::new(text)
3696 .style(dimmed(Style::default().fg(p.text)))
3697 .block(block);
3698 f.render_widget(par, area);
3699 }
3700 Some(Shape::Text(t)) => {
3701 let color = if t.starts_with('✗') { p.err } else { p.text };
3702 let par = Paragraph::new(t.as_str())
3703 .style(dimmed(Style::default().fg(color)))
3704 .wrap(Wrap { trim: false })
3705 .block(block);
3706 f.render_widget(par, area);
3707 }
3708 }
3709}
3710
3711fn scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, pos: usize, p: &Palette) {
3715 if viewport == 0 || total <= viewport {
3716 return;
3717 }
3718 let max = total - viewport;
3719 let mut state = ScrollbarState::new(max).position(pos.min(max));
3720 let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
3721 .begin_symbol(None)
3722 .end_symbol(None)
3723 .track_symbol(Some("│"))
3724 .track_style(Style::default().fg(p.border))
3725 .thumb_symbol("┃")
3726 .thumb_style(Style::default().fg(p.text));
3727 f.render_stateful_widget(
3728 sb,
3729 area.inner(Margin {
3730 horizontal: 0,
3731 vertical: 1,
3732 }),
3733 &mut state,
3734 );
3735}
3736
3737fn wrapped_height(text: &str, width: usize) -> usize {
3739 let w = width.max(1);
3740 text.lines()
3741 .map(|l| l.chars().count().div_ceil(w).max(1))
3742 .sum()
3743}
3744
3745const SQL_EXTRA_KEYWORDS: &[&str] = &[
3748 "TABLE", "VIEW", "IF", "EXISTS", "USE", "TRUE", "FALSE", "ASC", "DESC", "INTERVAL",
3749];
3750
3751fn is_sql_keyword(word: &str) -> bool {
3752 let up = word.to_ascii_uppercase();
3753 crate::app::SQL_KEYWORDS
3754 .iter()
3755 .any(|k| k.split_whitespace().any(|w| w == up))
3756 || SQL_EXTRA_KEYWORDS.contains(&up.as_str())
3757}
3758
3759fn sql_spans(sql: &str, p: &Palette) -> Vec<Span<'static>> {
3763 let chars: Vec<char> = sql.chars().collect();
3764 let mut spans = Vec::new();
3765 let mut i = 0;
3766 let push = |spans: &mut Vec<Span<'static>>, text: String, style: Style| {
3767 if !text.is_empty() {
3768 spans.push(Span::styled(text, style));
3769 }
3770 };
3771 while i < chars.len() {
3772 let c = chars[i];
3773 if c == '\'' || c == '`' || c == '"' {
3774 let start = i;
3777 i += 1;
3778 while i < chars.len() && chars[i] != c {
3779 i += 1;
3780 }
3781 if i < chars.len() {
3782 i += 1;
3783 }
3784 let color = if c == '\'' { p.ok } else { p.catalog };
3785 push(
3786 &mut spans,
3787 chars[start..i].iter().collect(),
3788 Style::default().fg(color),
3789 );
3790 } else if c == '-' && chars.get(i + 1) == Some(&'-') {
3791 push(
3792 &mut spans,
3793 chars[i..].iter().collect(),
3794 Style::default().fg(p.dim),
3795 );
3796 break;
3797 } else if c.is_ascii_digit() {
3798 let start = i;
3799 while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
3800 i += 1;
3801 }
3802 push(
3803 &mut spans,
3804 chars[start..i].iter().collect(),
3805 Style::default().fg(p.warn),
3806 );
3807 } else if c.is_alphanumeric() || c == '_' {
3808 let start = i;
3809 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
3810 i += 1;
3811 }
3812 let word: String = chars[start..i].iter().collect();
3813 let style = if is_sql_keyword(&word) {
3814 Style::default().fg(p.key).add_modifier(Modifier::BOLD)
3815 } else {
3816 Style::default().fg(p.text)
3817 };
3818 push(&mut spans, word, style);
3819 } else {
3820 let start = i;
3821 i += 1;
3822 while i < chars.len() {
3823 let c = chars[i];
3824 if c.is_alphanumeric() || matches!(c, '_' | '\'' | '`' | '"' | '-') {
3825 break;
3826 }
3827 i += 1;
3828 }
3829 push(
3830 &mut spans,
3831 chars[start..i].iter().collect(),
3832 Style::default().fg(p.text),
3833 );
3834 }
3835 }
3836 spans
3837}
3838
3839fn output_line<'a>(line: &'a str, p: &Palette) -> Line<'a> {
3843 if let Some(rest) = line.strip_prefix("── ") {
3844 let color = if rest.contains("FAILED") {
3845 p.err
3846 } else if rest.contains("RUNNING") || rest.contains("PENDING") {
3847 p.warn
3848 } else if rest.contains("SUCCESS") {
3849 p.ok
3850 } else {
3851 p.text
3852 };
3853 return Line::from(Span::styled(
3854 line,
3855 Style::default().fg(color).add_modifier(Modifier::BOLD),
3856 ));
3857 }
3858 let trimmed = line.trim_start();
3859 let indented = line.starts_with(char::is_whitespace);
3860 let body = if line.contains("ERROR")
3861 || line.contains("FATAL")
3862 || line.contains("Exception")
3863 || trimmed.starts_with("Traceback")
3864 || trimmed.starts_with("Caused by")
3865 || (indented && (trimmed.starts_with("at ") || trimmed.starts_with("File \"")))
3866 {
3867 Style::default().fg(p.err)
3868 } else if line.contains("WARN") {
3869 Style::default().fg(p.warn)
3870 } else if line == "logs (tail):" || trimmed.starts_with("(no output") {
3871 Style::default().fg(p.dim)
3872 } else {
3873 Style::default().fg(p.text)
3874 };
3875 let ts = timestamp_prefix(line);
3876 if ts > 0 {
3877 Line::from(vec![
3878 Span::styled(&line[..ts], Style::default().fg(p.dim)),
3879 Span::styled(&line[ts..], body),
3880 ])
3881 } else {
3882 Line::from(Span::styled(line, body))
3883 }
3884}
3885
3886fn timestamp_prefix(line: &str) -> usize {
3890 let mut len = 0;
3891 let mut digits = 0;
3892 for (i, c) in line.char_indices() {
3893 if c.is_ascii_digit() {
3894 digits += 1;
3895 } else if !matches!(c, '/' | '-' | ':' | '.' | ',' | 'T' | 'Z' | ' ') {
3896 break;
3897 }
3898 len = i + c.len_utf8();
3899 if len >= 30 {
3900 break;
3901 }
3902 }
3903 if digits >= 6 && len >= 8 {
3904 len
3905 } else {
3906 0
3907 }
3908}
3909
3910fn status_color(status: &Status, p: &Palette) -> Color {
3911 match status {
3912 Status::Running | Status::Success => p.ok,
3913 Status::Stopped => p.dim,
3914 Status::Pending => p.warn,
3915 Status::Failed => p.err,
3916 Status::Unknown(_) => p.text,
3917 }
3918}
3919
3920fn history_glyph(status: &Status) -> &'static str {
3921 match status {
3922 Status::Success => "✓",
3923 Status::Failed => "✗",
3924 Status::Running => "●",
3925 Status::Pending => "◐",
3926 Status::Stopped => "○",
3927 Status::Unknown(_) => "·",
3928 }
3929}
3930
3931#[cfg(test)]
3932mod tests {
3933 use super::*;
3934
3935 fn find<'a>(spans: &'a [Span], text: &str) -> &'a Span<'a> {
3936 spans
3937 .iter()
3938 .find(|s| s.content == text)
3939 .unwrap_or_else(|| panic!("no span {text:?} in {spans:?}"))
3940 }
3941
3942 #[test]
3943 fn sql_spans_color_keywords_strings_numbers_comments() {
3944 let p = palette(ThemeMode::Dark);
3945 let spans = sql_spans("SELECT count(x) FROM t WHERE s = 'bob' LIMIT 10 -- hi", &p);
3946 assert_eq!(find(&spans, "SELECT").style.fg, Some(p.key));
3947 assert!(find(&spans, "SELECT")
3948 .style
3949 .add_modifier
3950 .contains(Modifier::BOLD));
3951 assert_eq!(find(&spans, "count").style.fg, Some(p.key));
3952 assert_eq!(find(&spans, "t").style.fg, Some(p.text));
3953 assert_eq!(find(&spans, "'bob'").style.fg, Some(p.ok));
3954 assert_eq!(find(&spans, "10").style.fg, Some(p.warn));
3955 assert_eq!(find(&spans, "-- hi").style.fg, Some(p.dim));
3956 }
3957
3958 #[test]
3959 fn sql_spans_preserve_every_char() {
3960 let p = palette(ThemeMode::Dark);
3961 for sql in [
3962 "SELECT `col a`, b - 1 FROM main.sales.orders WHERE s = 'unclosed",
3963 "-- only a comment",
3964 " \"quoted id\" <> 3.5e2 ",
3965 ] {
3966 let joined: String = sql_spans(sql, &p)
3967 .iter()
3968 .map(|s| s.content.as_ref())
3969 .collect();
3970 assert_eq!(joined, sql);
3971 }
3972 }
3973
3974 #[test]
3975 fn spark_scales_min_to_max() {
3976 assert_eq!(spark(&[1, 1, 1]), "▄▄▄");
3977 assert_eq!(spark(&[0, 7]), "▁█");
3978 assert_eq!(spark(&[100, 150, 200]), "▁▄█");
3979 assert_eq!(spark(&[]), "");
3980 }
3981
3982 fn render_at(app: &App, width: u16) -> Vec<String> {
3985 let backend = ratatui::backend::TestBackend::new(width, 30);
3986 let mut terminal = ratatui::Terminal::new(backend).unwrap();
3987 terminal.draw(|f| draw(f, app)).unwrap();
3988 let buffer = terminal.backend().buffer().clone();
3989 (0..buffer.area.height)
3990 .map(|y| {
3991 (0..buffer.area.width)
3992 .map(|x| buffer[(x, y)].symbol())
3993 .collect::<String>()
3994 .trim_end()
3995 .to_string()
3996 })
3997 .collect()
3998 }
3999
4000 fn render(app: &App) -> Vec<String> {
4001 render_at(app, 100)
4002 }
4003
4004 fn item_cost_app(data: fetchers::cost::ResourceCost) -> App {
4005 let mut app = App::new(60, ThemeMode::Dark);
4006 app.dismiss_splash();
4007 app.item_cost = Some(crate::app::ItemCostView {
4008 warehouse: "Starter Warehouse".to_string(),
4009 kind: data.kind,
4010 name: "nightly-etl".to_string(),
4011 id: "42".to_string(),
4012 data: Some(Ok(data)),
4013 });
4014 app
4015 }
4016
4017 fn window(
4018 label: &'static str,
4019 days: i64,
4020 dbus: f64,
4021 usd: f64,
4022 prior: Option<(f64, f64)>,
4023 ) -> fetchers::cost::CostWindow {
4024 fetchers::cost::CostWindow {
4025 label,
4026 days,
4027 dbus,
4028 usd,
4029 prior,
4030 }
4031 }
4032
4033 #[test]
4034 fn item_cost_view_shows_every_window_with_its_trend() {
4035 let data = fetchers::cost::ResourceCost {
4036 kind: fetchers::cost::ResourceKind::Job,
4037 windows: vec![
4038 window("last week", 7, 12.4, 9.61, Some((10.0, 8.14))),
4039 window("last month", 30, 58.2, 45.1, Some((60.0, 47.0))),
4040 window("last quarter", 90, 140.9, 109.22, Some((0.0, 0.0))),
4041 window("last year", 365, 402.3, 311.8, None),
4042 ],
4043 months: vec![
4044 ("2026-06".to_string(), 40.1, 31.02),
4045 ("2026-07".to_string(), 58.2, 45.1),
4046 ],
4047 priced: true,
4048 scoped: true,
4049 };
4050 let text = render(&item_cost_app(data)).join("\n");
4051
4052 assert!(
4053 text.contains("Spend · nightly-etl · job · this workspace"),
4054 "{text}"
4055 );
4056 for label in ["last week", "last month", "last quarter", "last year"] {
4057 assert!(text.contains(label), "missing {label} in {text}");
4058 }
4059 assert!(text.contains("$9.61"), "{text}");
4060 assert!(text.contains("▲ 18%"), "{text}");
4063 assert!(text.contains("▼ 4%"), "{text}");
4064 assert!(text.contains("BY MONTH"), "{text}");
4065 assert!(text.contains("2026-06"), "{text}");
4066 }
4067
4068 fn sql_app(record: bool) -> App {
4069 let mut app = App::new(60, ThemeMode::Dark);
4070 app.dismiss_splash();
4071 app.sql = Some(crate::app::SqlConsole {
4072 input: "SELECT * FROM main.sales.orders LIMIT 50".to_string(),
4073 cursor: 0,
4074 warehouse: "Starter Warehouse".to_string(),
4075 running: false,
4076 data: Some(Ok(TableData {
4077 headers: vec![
4078 "order_id".to_string(),
4079 "customer".to_string(),
4080 "shipped_at".to_string(),
4081 ],
4082 rows: vec![
4083 vec!["1001".to_string(), "acme".to_string(), "␀".to_string()],
4084 vec![
4085 "1002".to_string(),
4086 "globex".to_string(),
4087 "2026-08-01".to_string(),
4088 ],
4089 ],
4090 })),
4091 last_sql: "SELECT * FROM main.sales.orders LIMIT 50".to_string(),
4092 scroll: 1,
4093 col: 0,
4094 record,
4095 rscroll: 0,
4096 });
4097 app
4098 }
4099
4100 #[test]
4101 fn sql_record_view_stacks_one_row_and_marks_which() {
4102 let text = render_at(&sql_app(true), 140).join("\n");
4103
4104 assert!(text.contains("row 2/2"), "{text}");
4106 assert!(text.contains("order_id 1002"), "{text}");
4107 assert!(text.contains("customer globex"), "{text}");
4108 assert!(text.contains("shipped_at 2026-08-01"), "{text}");
4109 assert!(text.contains("fields"), "{text}");
4111 assert!(text.contains("f2 grid"), "{text}");
4112 }
4113
4114 #[test]
4115 fn sql_grid_view_is_unchanged_and_offers_the_record_toggle() {
4116 let text = render_at(&sql_app(false), 140).join("\n");
4117 assert!(text.contains("order_id"), "{text}");
4119 assert!(text.contains("customer"), "{text}");
4120 assert!(!text.contains("row 2/2"), "{text}");
4121 assert!(text.contains("f2 record"), "{text}");
4122 }
4123
4124 #[test]
4125 fn item_cost_view_explains_an_unattributed_job() {
4126 let data = fetchers::cost::ResourceCost {
4127 kind: fetchers::cost::ResourceKind::Job,
4128 windows: Vec::new(),
4129 months: Vec::new(),
4130 priced: true,
4131 scoped: true,
4132 };
4133 let text = render(&item_cost_app(data)).join("\n");
4134 assert!(text.contains("no usage attributed to this job"), "{text}");
4135 assert!(text.contains("all-purpose compute"), "{text}");
4136 }
4137}