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