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