1use crate::cli::DatabricksCli;
2use crate::fetchers;
3use crate::shape::{DetailData, Shape, Status};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6use tokio::sync::{mpsc, oneshot};
7
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum ThemeMode {
10 Dark,
11 Light,
12 CatppuccinMocha,
13 CatppuccinLatte,
14 GruvboxDark,
15 Dracula,
16 Nord,
17 TokyoNight,
18}
19
20impl ThemeMode {
21 pub const ALL: &'static [ThemeMode] = &[
22 ThemeMode::Dark,
23 ThemeMode::Light,
24 ThemeMode::CatppuccinMocha,
25 ThemeMode::CatppuccinLatte,
26 ThemeMode::GruvboxDark,
27 ThemeMode::Dracula,
28 ThemeMode::Nord,
29 ThemeMode::TokyoNight,
30 ];
31
32 pub fn toggled(self) -> Self {
34 let idx = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
35 Self::ALL[(idx + 1) % Self::ALL.len()]
36 }
37
38 pub fn name(&self) -> &'static str {
39 match self {
40 ThemeMode::Dark => "Dark (terminal colors)",
41 ThemeMode::Light => "Light",
42 ThemeMode::CatppuccinMocha => "Catppuccin Mocha",
43 ThemeMode::CatppuccinLatte => "Catppuccin Latte",
44 ThemeMode::GruvboxDark => "Gruvbox Dark",
45 ThemeMode::Dracula => "Dracula",
46 ThemeMode::Nord => "Nord",
47 ThemeMode::TokyoNight => "Tokyo Night",
48 }
49 }
50
51 pub fn id(&self) -> &'static str {
53 match self {
54 ThemeMode::Dark => "dark",
55 ThemeMode::Light => "light",
56 ThemeMode::CatppuccinMocha => "catppuccin-mocha",
57 ThemeMode::CatppuccinLatte => "catppuccin-latte",
58 ThemeMode::GruvboxDark => "gruvbox",
59 ThemeMode::Dracula => "dracula",
60 ThemeMode::Nord => "nord",
61 ThemeMode::TokyoNight => "tokyo-night",
62 }
63 }
64
65 pub fn from_id(id: &str) -> Option<Self> {
66 Self::ALL.iter().copied().find(|t| t.id() == id)
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub enum Panel {
72 Clusters,
73 Jobs,
74 Pipelines,
75 Warehouses,
76 Dashboards,
77 Catalog,
78}
79
80impl Panel {
81 pub const ALL: &'static [Panel] = &[
82 Panel::Clusters,
83 Panel::Jobs,
84 Panel::Pipelines,
85 Panel::Warehouses,
86 Panel::Dashboards,
87 Panel::Catalog,
88 ];
89
90 pub fn title(&self) -> &'static str {
91 match self {
92 Panel::Clusters => "Compute",
93 Panel::Jobs => "Lakeflow Jobs",
94 Panel::Pipelines => "Lakeflow Pipelines",
95 Panel::Warehouses => "SQL Warehouses",
96 Panel::Dashboards => "AI/BI Dashboards",
97 Panel::Catalog => "Unity Catalog",
98 }
99 }
100
101 pub fn icon(&self) -> &'static str {
102 match self {
103 Panel::Clusters => "⌬",
104 Panel::Jobs => "⟳",
105 Panel::Pipelines => "⋙",
106 Panel::Warehouses => "⌁",
107 Panel::Dashboards => "▦",
108 Panel::Catalog => "⧉",
109 }
110 }
111
112 pub fn cli_group(&self) -> &'static str {
114 match self {
115 Panel::Clusters => "clusters",
116 Panel::Jobs => "jobs",
117 Panel::Pipelines => "pipelines",
118 Panel::Warehouses => "warehouses",
119 Panel::Dashboards => "lakeview",
120 Panel::Catalog => "tables",
121 }
122 }
123}
124
125pub struct Detail {
126 pub panel: Panel,
127 pub name: String,
128 pub id: String,
129 pub kind: Option<String>,
131 pub section: &'static str,
133 pub data: Option<DetailData>,
135 pub show_raw: bool,
137 pub scroll: u16,
138}
139
140pub struct Preview {
142 pub name: String,
143 pub warehouse: String,
145 pub warehouse_id: String,
146 pub data: Option<Result<crate::shape::TableData, String>>,
148 pub scroll: usize,
149}
150
151enum PickTarget {
153 Preview(String),
154 Cost,
155 Lineage(String),
156 Sql(String),
157}
158
159pub struct SqlConsole {
161 pub input: String,
162 pub cursor: usize,
164 pub warehouse: String,
166 pub running: bool,
167 pub data: Option<Result<crate::shape::TableData, String>>,
168 pub last_sql: String,
170 pub scroll: usize,
171}
172
173fn history_path() -> Option<std::path::PathBuf> {
175 let home = std::env::var_os("HOME")?;
176 Some(
177 std::path::PathBuf::from(home)
178 .join(".config")
179 .join("databricks-tui")
180 .join("history"),
181 )
182}
183
184fn load_history() -> Vec<String> {
185 history_path()
186 .and_then(|p| std::fs::read_to_string(p).ok())
187 .map(|s| {
188 s.lines()
189 .filter(|l| !l.trim().is_empty())
190 .map(str::to_string)
191 .collect()
192 })
193 .unwrap_or_default()
194}
195
196fn save_history(history: &[String]) {
197 let Some(path) = history_path() else {
198 return;
199 };
200 if let Some(dir) = path.parent() {
201 let _ = std::fs::create_dir_all(dir);
202 crate::config::restrict(dir, 0o700);
203 }
204 let tail: Vec<&str> = history
206 .iter()
207 .rev()
208 .take(200)
209 .rev()
210 .map(String::as_str)
211 .collect();
212 let _ = std::fs::write(&path, tail.join("\n") + "\n");
214 crate::config::restrict(&path, 0o600);
215}
216
217fn subsequence(haystack: &str, needle: &str) -> bool {
219 let mut chars = haystack.chars();
220 needle.chars().all(|n| chars.any(|h| h == n))
221}
222
223fn byte_at(input: &str, cursor: usize) -> usize {
225 input
226 .char_indices()
227 .nth(cursor)
228 .map(|(i, _)| i)
229 .unwrap_or(input.len())
230}
231
232pub struct WhPicker {
234 pub index: usize,
235 target: PickTarget,
236}
237
238pub struct CostView {
240 pub warehouse: String,
241 pub data: Option<Result<fetchers::cost::CostData, String>>,
242}
243
244pub struct RunView {
247 pub panel: Panel,
249 pub owner_name: String,
250 owner_id: String,
252 pub runs: Vec<(String, Status, String)>,
254 pub idx: usize,
256 pub data: Option<DetailData>,
257 pub show_raw: bool,
258 pub scroll: u16,
259 pub live: bool,
261 fetched_at: Instant,
262}
263
264type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
266
267enum RunUpdate {
268 Opened(Result<RunOpened, String>),
269 Detail(DetailData, bool),
270}
271
272pub struct Problem {
274 pub panel: usize,
275 pub name: String,
276 pub status: Status,
277 pub note: String,
278}
279
280pub struct Problems {
282 pub items: Vec<Problem>,
283 pub index: usize,
284}
285
286pub struct Confirm {
288 pub message: String,
289 args: Vec<String>,
290}
291
292enum Update {
293 Panel(usize, Result<Shape, String>),
294 Badge(Option<Shape>),
295}
296
297const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
298
299pub struct App {
300 pub focus: Panel,
301 pub theme: ThemeMode,
302 pub zoomed: bool,
303 pub shapes: Vec<Option<Shape>>,
304 pub user_badge: Option<Shape>,
305 pub error: Option<String>,
306 pub refresh_interval: Duration,
307 last_refresh: Instant,
308 pub loading: bool,
309 pub detail: Option<Detail>,
310 pub confirm: Option<Confirm>,
311 pub flash: Option<(String, Instant)>,
312 pub selected: [usize; 6],
313 pub host: Option<String>,
314 pub profiles: Vec<String>,
316 pub profile: Option<String>,
317 pub picker: Option<usize>,
319 pub problems: Option<Problems>,
321 pub uc_path: Vec<String>,
323 uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
324 pub preview: Option<Preview>,
325 preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
326 pub wh_picker: Option<WhPicker>,
327 pub preview_warehouse: Option<(String, String)>,
329 pub cost: Option<CostView>,
330 #[allow(clippy::type_complexity)]
331 cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
332 workspace_id: Option<String>,
335 pub sql: Option<SqlConsole>,
336 sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
337 sql_history: Vec<String>,
339 hist_idx: Option<usize>,
341 hist_draft: String,
343 pub hist_search: Option<(String, usize)>,
345 pub run_view: Option<RunView>,
346 run_rx: Option<oneshot::Receiver<RunUpdate>>,
347 pending: Option<mpsc::UnboundedReceiver<Update>>,
348 detail_rx: Option<oneshot::Receiver<DetailData>>,
349 action_rx: Option<oneshot::Receiver<Result<String, String>>>,
350 host_rx: Option<oneshot::Receiver<Option<String>>>,
351 in_flight: usize,
352 spinner_frame: usize,
353 pub splash_until: Option<Instant>,
355 pub updated_at: [Option<Instant>; 6],
357 pub filters: [String; 6],
359 pub filter_entry: bool,
361 pub config: crate::config::Config,
363 failed_seen: [Option<std::collections::HashSet<String>>; 6],
366 pub jump: Option<Jump>,
368 sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
370}
371
372pub struct Jump {
374 pub query: String,
375 pub index: usize,
376}
377
378impl App {
379 pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
380 Self {
381 focus: Panel::Clusters,
382 theme,
383 zoomed: false,
384 shapes: vec![None; 6],
385 user_badge: None,
386 error: None,
387 refresh_interval: Duration::from_secs(refresh_secs),
388 last_refresh: Instant::now()
389 .checked_sub(Duration::from_secs(refresh_secs + 1))
390 .unwrap_or(Instant::now()),
391 loading: false,
392 detail: None,
393 confirm: None,
394 flash: None,
395 selected: [0; 6],
396 host: None,
397 profiles: Vec::new(),
398 profile: None,
399 picker: None,
400 problems: None,
401 uc_path: Vec::new(),
402 uc_rx: None,
403 preview: None,
404 preview_rx: None,
405 wh_picker: None,
406 preview_warehouse: None,
407 cost: None,
408 cost_rx: None,
409 workspace_id: None,
410 sql: None,
411 sql_rx: None,
412 sql_history: load_history(),
413 hist_idx: None,
414 hist_draft: String::new(),
415 hist_search: None,
416 run_view: None,
417 run_rx: None,
418 pending: None,
419 detail_rx: None,
420 action_rx: None,
421 host_rx: None,
422 in_flight: 0,
423 spinner_frame: 0,
424 splash_until: Some(Instant::now() + Duration::from_millis(1600)),
425 updated_at: [None; 6],
426 filters: Default::default(),
427 filter_entry: false,
428 config: crate::config::Config::load(),
429 failed_seen: Default::default(),
430 jump: None,
431 sql_stmt: None,
432 }
433 }
434
435 fn alert_new_failures(&mut self, idx: usize) {
438 if idx == 5 {
440 return;
441 }
442 let Some(Shape::List(items)) = &self.shapes[idx] else {
443 return;
444 };
445 let failed: std::collections::HashSet<String> = items
446 .iter()
447 .filter(|it| {
448 matches!(it.status, Status::Failed)
449 || it
450 .history
451 .last()
452 .is_some_and(|s| matches!(s, Status::Failed))
453 })
454 .map(|it| it.name.clone())
455 .collect();
456 if let Some(prev) = &self.failed_seen[idx] {
457 let mut newly: Vec<&String> = failed.difference(prev).collect();
458 if !newly.is_empty() {
459 newly.sort();
460 let extra = if newly.len() > 1 {
461 format!(" (+{} more)", newly.len() - 1)
462 } else {
463 String::new()
464 };
465 self.flash = Some((
466 format!("✗ {}{extra} just failed — ! to inspect", newly[0]),
467 Instant::now(),
468 ));
469 print!("\x07");
471 let _ = std::io::Write::flush(&mut std::io::stdout());
472 }
473 }
474 self.failed_seen[idx] = Some(failed);
475 }
476
477 pub fn persist_theme(&mut self) {
479 self.config.theme = Some(self.theme.id().to_string());
480 self.config.save();
481 }
482
483 pub fn restore_warehouse_pref(&mut self) {
485 let profile = self.profile.as_deref().unwrap_or("DEFAULT");
486 self.preview_warehouse = self.config.warehouses.get(profile).cloned();
487 }
488
489 pub fn splash_active(&self) -> bool {
490 self.splash_until
491 .map(|t| Instant::now() < t)
492 .unwrap_or(false)
493 }
494
495 pub fn dismiss_splash(&mut self) {
496 self.splash_until = None;
497 }
498
499 pub fn any_fresh(&self) -> bool {
501 self.updated_at
502 .iter()
503 .flatten()
504 .any(|t| t.elapsed() < Duration::from_millis(1200))
505 }
506
507 pub fn open_picker(&mut self) {
508 if self.profiles.is_empty() {
509 return;
510 }
511 let current = self
512 .profile
513 .as_deref()
514 .and_then(|p| self.profiles.iter().position(|n| n == p))
515 .unwrap_or(0);
516 self.picker = Some(current);
517 }
518
519 pub fn picker_next(&mut self) {
520 if let Some(i) = self.picker {
521 self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
522 }
523 }
524
525 pub fn picker_prev(&mut self) {
526 if let Some(i) = self.picker {
527 self.picker = Some(i.saturating_sub(1));
528 }
529 }
530
531 pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
533 let idx = self.picker.take()?;
534 let name = self.profiles.get(idx)?.clone();
535 let profile_arg = if name == "DEFAULT" {
536 None
537 } else {
538 Some(name.clone())
539 };
540 self.profile = Some(name);
541
542 self.shapes = vec![None; 6];
544 self.user_badge = None;
545 self.host = None;
546 self.selected = [0; 6];
547 self.detail = None;
548 self.detail_rx = None;
549 self.confirm = None;
550 self.problems = None;
551 self.uc_path.clear();
552 self.uc_rx = None;
553 self.preview = None;
554 self.preview_rx = None;
555 self.wh_picker = None;
556 self.preview_warehouse = None;
557 self.cost = None;
558 self.cost_rx = None;
559 self.workspace_id = None;
560 self.sql = None;
561 self.sql_rx = None;
562 self.run_view = None;
563 self.run_rx = None;
564 self.pending = None;
565 self.in_flight = 0;
566 self.loading = false;
567 self.zoomed = false;
568 self.filters = Default::default();
569 self.filter_entry = false;
570 self.failed_seen = Default::default();
571 self.jump = None;
572 self.sql_stmt = None;
573 self.restore_warehouse_pref();
574
575 Some(Arc::new(DatabricksCli::new(profile_arg)))
576 }
577
578 pub fn open_jump(&mut self) {
579 self.jump = Some(Jump {
580 query: String::new(),
581 index: 0,
582 });
583 }
584
585 pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
589 let Some(jump) = &self.jump else {
590 return Vec::new();
591 };
592 let q = jump.query.to_lowercase();
593 let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
594 for (i, shape) in self.shapes.iter().enumerate() {
595 let Some(Shape::List(items)) = shape else {
596 continue;
597 };
598 for it in items {
599 let name = it.name.to_lowercase();
600 let rank = if q.is_empty() || name.contains(&q) {
601 0
602 } else if subsequence(&name, &q) {
603 1
604 } else {
605 continue;
606 };
607 scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
608 }
609 }
610 scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
611 scored
612 .into_iter()
613 .take(12)
614 .map(|(_, i, name, label)| (i, name, label))
615 .collect()
616 }
617
618 pub fn jump_push(&mut self, c: char) {
619 if let Some(j) = &mut self.jump {
620 j.query.push(c);
621 j.index = 0;
622 }
623 }
624
625 pub fn jump_pop(&mut self) {
626 if let Some(j) = &mut self.jump {
627 j.query.pop();
628 j.index = 0;
629 }
630 }
631
632 pub fn jump_next(&mut self) {
633 let len = self.jump_matches().len();
634 if let Some(j) = &mut self.jump {
635 j.index = (j.index + 1).min(len.saturating_sub(1));
636 }
637 }
638
639 pub fn jump_prev(&mut self) {
640 if let Some(j) = &mut self.jump {
641 j.index = j.index.saturating_sub(1);
642 }
643 }
644
645 pub fn jump_go(&mut self) {
647 let matches = self.jump_matches();
648 let Some(jump) = self.jump.take() else {
649 return;
650 };
651 let Some((panel_idx, name, _)) = matches.get(jump.index) else {
652 return;
653 };
654 self.focus = Panel::ALL[*panel_idx];
655 self.filters[*panel_idx].clear();
656 if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
657 if let Some(pos) = items.iter().position(|i| &i.name == name) {
658 self.selected[*panel_idx] = pos;
659 }
660 }
661 }
662
663 pub fn open_problems(&mut self) {
666 let mut items = Vec::new();
667 for (i, shape) in self.shapes.iter().enumerate() {
668 let Some(Shape::List(list)) = shape else {
669 continue;
670 };
671 for it in list {
672 let failed_now = matches!(it.status, Status::Failed);
673 let failed_last = it
674 .history
675 .last()
676 .is_some_and(|s| matches!(s, Status::Failed));
677 if failed_now || failed_last {
678 let note = if failed_now {
679 it.detail.clone().unwrap_or_default()
680 } else {
681 "latest run failed".to_string()
682 };
683 items.push(Problem {
684 panel: i,
685 name: it.name.clone(),
686 status: it.status.clone(),
687 note,
688 });
689 }
690 }
691 }
692 self.problems = Some(Problems { items, index: 0 });
693 }
694
695 pub fn problems_next(&mut self) {
696 if let Some(pr) = &mut self.problems {
697 pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
698 }
699 }
700
701 pub fn problems_prev(&mut self) {
702 if let Some(pr) = &mut self.problems {
703 pr.index = pr.index.saturating_sub(1);
704 }
705 }
706
707 pub fn problems_jump(&mut self) {
709 let Some(pr) = self.problems.take() else {
710 return;
711 };
712 let Some(problem) = pr.items.get(pr.index) else {
713 return;
714 };
715 self.focus = Panel::ALL[problem.panel];
716 self.filters[problem.panel].clear();
718 if let Some(Shape::List(list)) = &self.shapes[problem.panel] {
719 if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
720 self.selected[problem.panel] = pos;
721 }
722 }
723 }
724
725 pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
728 let (tx, rx) = oneshot::channel();
729 self.host_rx = Some(rx);
730 let cli = Arc::clone(cli);
731 tokio::spawn(async move {
732 let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
733 json["details"]["host"]
734 .as_str()
735 .or_else(|| json["host"].as_str())
736 .map(str::to_string)
737 });
738 let _ = tx.send(host);
739 });
740 }
741
742 pub fn poll_host(&mut self) {
743 if let Some(rx) = &mut self.host_rx {
744 match rx.try_recv() {
745 Ok(host) => {
746 self.host = host;
747 self.host_rx = None;
748 }
749 Err(oneshot::error::TryRecvError::Empty) => {}
750 Err(oneshot::error::TryRecvError::Closed) => {
751 self.host_rx = None;
752 }
753 }
754 }
755 }
756
757 fn focus_index(&self) -> usize {
758 Panel::ALL
759 .iter()
760 .position(|p| p == &self.focus)
761 .unwrap_or(0)
762 }
763
764 fn list_len(&self, idx: usize) -> usize {
765 match &self.shapes[idx] {
766 Some(Shape::List(items)) => items
767 .iter()
768 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
769 .count(),
770 _ => 0,
771 }
772 }
773
774 pub fn selection(&self, idx: usize) -> usize {
776 self.selected[idx].min(self.list_len(idx).saturating_sub(1))
777 }
778
779 pub fn select_next(&mut self) {
780 let idx = self.focus_index();
781 let len = self.list_len(idx);
782 if len > 0 {
783 self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
784 }
785 }
786
787 pub fn select_prev(&mut self) {
788 let idx = self.focus_index();
789 self.selected[idx] = self.selection(idx).saturating_sub(1);
790 }
791
792 fn selected_item(&self) -> Option<&crate::shape::ListItem> {
795 let idx = self.focus_index();
796 match &self.shapes[idx] {
797 Some(Shape::List(items)) => items
798 .iter()
799 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
800 .nth(self.selection(idx)),
801 _ => None,
802 }
803 }
804
805 pub fn filter_start(&mut self) {
807 let idx = self.focus_index();
808 self.filters[idx].clear();
809 self.selected[idx] = 0;
810 self.filter_entry = true;
811 }
812
813 pub fn filter_push(&mut self, c: char) {
814 let idx = self.focus_index();
815 self.filters[idx].push(c);
816 self.selected[idx] = 0;
817 }
818
819 pub fn filter_pop(&mut self) {
820 let idx = self.focus_index();
821 self.filters[idx].pop();
822 self.selected[idx] = 0;
823 }
824
825 pub fn filter_accept(&mut self) {
827 self.filter_entry = false;
828 }
829
830 pub fn filter_clear(&mut self) {
831 let idx = self.focus_index();
832 self.filters[idx].clear();
833 self.selected[idx] = 0;
834 self.filter_entry = false;
835 }
836
837 pub fn active_filter(&self) -> &str {
839 &self.filters[self.focus_index()]
840 }
841
842 pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
843 let Some(item) = self.selected_item() else {
844 return;
845 };
846 let Some(id) = item.id.clone() else {
847 return;
848 };
849 let kind = match &item.status {
850 Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
851 _ => None,
852 };
853 let section = match self.focus {
854 Panel::Dashboards => "Contents",
855 Panel::Catalog => "Columns",
856 Panel::Warehouses => "Recent queries",
857 _ => "Recent activity",
858 };
859 self.detail = Some(Detail {
860 panel: self.focus,
861 name: item.name.clone(),
862 id: id.clone(),
863 kind,
864 section,
865 data: None,
866 show_raw: false,
867 scroll: 0,
868 });
869
870 let (tx, rx) = oneshot::channel();
871 self.detail_rx = Some(rx);
872 let cli = Arc::clone(cli);
873 let kind = self.detail.as_ref().unwrap().kind.clone();
874 if kind.as_deref() == Some("FILE") {
876 if let Some(d) = &mut self.detail {
877 d.section = "File head";
878 }
879 tokio::spawn(async move {
880 let data = fetchers::catalog::file_peek(&cli, &id).await;
881 let _ = tx.send(data);
882 });
883 return;
884 }
885 let group = match &kind {
886 Some(k) if k == "VOLUME" => "volumes",
887 _ => self.focus.cli_group(),
888 };
889 let warehouse = match &kind {
892 Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
893 _ => None,
894 };
895 tokio::spawn(async move {
896 let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
897 let _ = tx.send(data);
898 });
899 }
900
901 pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
904 if self.focus != Panel::Catalog {
905 return false;
906 }
907 let Some(item) = self.selected_item() else {
908 return self.uc_path.is_empty(); };
910 if self.uc_path.len() >= 2 {
913 let drillable =
914 matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
915 if !drillable {
916 return false;
917 }
918 }
919 self.uc_path.push(item.name.clone());
920 self.refresh_catalog(cli);
921 true
922 }
923
924 pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
926 if self.focus != Panel::Catalog || self.uc_path.is_empty() {
927 return false;
928 }
929 self.uc_path.pop();
930 self.refresh_catalog(cli);
931 true
932 }
933
934 fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
935 self.shapes[5] = None;
936 self.selected[5] = 0;
937 self.filters[5].clear();
939 let (tx, rx) = oneshot::channel();
940 self.uc_rx = Some(rx);
941 let cli = Arc::clone(cli);
942 let path = self.uc_path.clone();
943 tokio::spawn(async move {
944 let result = fetchers::catalog::fetch(&cli, &path)
945 .await
946 .map_err(|e| format!("{e:#}"));
947 let _ = tx.send(result);
948 });
949 }
950
951 pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
954 let idx = match kind {
955 "cluster" => 0,
956 "job" => 1,
957 "warehouse" => 3,
958 _ => return None,
959 };
960 match &self.shapes[idx] {
961 Some(Shape::List(items)) => items
962 .iter()
963 .find(|i| i.id.as_deref() == Some(id))
964 .map(|i| i.name.clone()),
965 _ => None,
966 }
967 }
968
969 pub fn warehouses(&self) -> Vec<(String, String, bool)> {
971 let Some(Shape::List(items)) = &self.shapes[3] else {
972 return Vec::new();
973 };
974 items
975 .iter()
976 .filter_map(|i| {
977 let id = i.id.clone()?;
978 Some((i.name.clone(), id, matches!(i.status, Status::Running)))
979 })
980 .collect()
981 }
982
983 pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
987 if self.focus != Panel::Catalog {
988 return;
989 }
990 let Some(item) = self.selected_item() else {
991 return;
992 };
993 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
994 return;
995 }
996 let Some(full_name) = item.id.clone() else {
997 return;
998 };
999 let warehouses = self.warehouses();
1000 if warehouses.is_empty() {
1001 self.flash = Some((
1002 "✗ no SQL warehouse available for previews".to_string(),
1003 Instant::now(),
1004 ));
1005 return;
1006 }
1007 if !force_pick {
1008 if let Some((id, name)) = self.preview_warehouse.clone() {
1009 self.start_preview_query(cli, full_name, id, name);
1010 return;
1011 }
1012 if let [(name, id, _)] = warehouses.as_slice() {
1013 self.preview_warehouse = Some((id.clone(), name.clone()));
1014 self.start_preview_query(cli, full_name, id.clone(), name.clone());
1015 return;
1016 }
1017 }
1018 let index = self
1020 .preview_warehouse
1021 .as_ref()
1022 .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1023 .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1024 .unwrap_or(0);
1025 self.wh_picker = Some(WhPicker {
1026 index,
1027 target: PickTarget::Preview(full_name),
1028 });
1029 }
1030
1031 pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1033 let warehouses = self.warehouses();
1034 if warehouses.is_empty() {
1035 self.flash = Some((
1036 "✗ no SQL warehouse available to query system tables".to_string(),
1037 Instant::now(),
1038 ));
1039 return;
1040 }
1041 if let Some((id, name)) = self.preview_warehouse.clone() {
1042 self.start_cost_query(cli, id, name);
1043 return;
1044 }
1045 if let [(name, id, _)] = warehouses.as_slice() {
1046 self.preview_warehouse = Some((id.clone(), name.clone()));
1047 self.start_cost_query(cli, id.clone(), name.clone());
1048 return;
1049 }
1050 let index = warehouses
1051 .iter()
1052 .position(|(_, _, running)| *running)
1053 .unwrap_or(0);
1054 self.wh_picker = Some(WhPicker {
1055 index,
1056 target: PickTarget::Cost,
1057 });
1058 }
1059
1060 fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1061 self.cost = Some(CostView {
1062 warehouse: name,
1063 data: None,
1064 });
1065 let (tx, rx) = oneshot::channel();
1066 self.cost_rx = Some(rx);
1067 let cli = Arc::clone(cli);
1068 let host = self.host.clone();
1069 let cached_ws = self.workspace_id.clone();
1070 tokio::spawn(async move {
1071 let ws = match (cached_ws, host) {
1073 (Some(w), _) => Some(w),
1074 (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1075 (None, None) => None,
1076 };
1077 let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1078 let _ = tx.send((result, ws));
1079 });
1080 }
1081
1082 pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1085 if self.focus != Panel::Catalog {
1086 return;
1087 }
1088 let Some(item) = self.selected_item() else {
1089 return;
1090 };
1091 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1092 return;
1093 }
1094 let Some(full_name) = item.id.clone() else {
1095 return;
1096 };
1097 let warehouses = self.warehouses();
1098 if warehouses.is_empty() {
1099 self.flash = Some((
1100 "✗ no SQL warehouse available to query lineage".to_string(),
1101 Instant::now(),
1102 ));
1103 return;
1104 }
1105 if let Some((id, _)) = self.preview_warehouse.clone() {
1106 self.start_lineage_query(cli, full_name, id);
1107 return;
1108 }
1109 if let [(name, id, _)] = warehouses.as_slice() {
1110 self.preview_warehouse = Some((id.clone(), name.clone()));
1111 let id = id.clone();
1112 self.start_lineage_query(cli, full_name, id);
1113 return;
1114 }
1115 let index = warehouses
1116 .iter()
1117 .position(|(_, _, running)| *running)
1118 .unwrap_or(0);
1119 self.wh_picker = Some(WhPicker {
1120 index,
1121 target: PickTarget::Lineage(full_name),
1122 });
1123 }
1124
1125 fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1126 self.detail = Some(Detail {
1127 panel: Panel::Catalog,
1128 name: full_name.clone(),
1129 id: full_name.clone(),
1130 kind: None,
1131 section: "Lineage",
1132 data: None,
1133 show_raw: false,
1134 scroll: 0,
1135 });
1136 let (tx, rx) = oneshot::channel();
1137 self.detail_rx = Some(rx);
1138 let cli = Arc::clone(cli);
1139 tokio::spawn(async move {
1140 let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1141 let _ = tx.send(data);
1142 });
1143 }
1144
1145 pub fn close_cost(&mut self) {
1146 self.cost = None;
1147 self.cost_rx = None;
1148 }
1149
1150 fn selected_table_fqn(&self) -> Option<String> {
1152 if self.focus != Panel::Catalog {
1153 return None;
1154 }
1155 let item = self.selected_item()?;
1156 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1157 return None;
1158 }
1159 item.id.clone()
1160 }
1161
1162 pub fn open_sql(&mut self) {
1165 if self.sql.is_none() {
1166 let input = self
1167 .selected_table_fqn()
1168 .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1169 .unwrap_or_default();
1170 self.sql = Some(SqlConsole {
1171 cursor: input.chars().count(),
1172 input,
1173 warehouse: String::new(),
1174 running: false,
1175 data: None,
1176 last_sql: String::new(),
1177 scroll: 0,
1178 });
1179 }
1180 }
1181
1182 pub fn close_sql(&mut self) {
1183 self.sql = None;
1184 self.sql_rx = None;
1185 self.hist_idx = None;
1186 self.hist_draft.clear();
1187 self.hist_search = None;
1188 }
1189
1190 pub fn sql_input(&self) -> Option<String> {
1192 self.sql.as_ref().map(|c| c.input.clone())
1193 }
1194
1195 pub fn sql_set_input(&mut self, s: &str) {
1197 if let Some(console) = &mut self.sql {
1198 console.input = s.to_string();
1199 console.cursor = console.input.chars().count();
1200 }
1201 }
1202
1203 pub fn hist_search_current(&self) -> Option<&String> {
1205 let (query, nth) = self.hist_search.as_ref()?;
1206 self.sql_history
1207 .iter()
1208 .rev()
1209 .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
1210 .nth(*nth)
1211 }
1212
1213 pub fn hist_search_start(&mut self) {
1214 if self.sql.is_some() {
1215 self.hist_search = Some((String::new(), 0));
1216 }
1217 }
1218
1219 pub fn hist_search_push(&mut self, c: char) {
1220 if let Some((query, nth)) = &mut self.hist_search {
1221 query.push(c);
1222 *nth = 0;
1223 }
1224 }
1225
1226 pub fn hist_search_pop(&mut self) {
1227 if let Some((query, nth)) = &mut self.hist_search {
1228 query.pop();
1229 *nth = 0;
1230 }
1231 }
1232
1233 pub fn hist_search_older(&mut self) {
1235 let Some((query, nth)) = &self.hist_search else {
1236 return;
1237 };
1238 let q = query.to_lowercase();
1239 let matches = self
1240 .sql_history
1241 .iter()
1242 .filter(|h| h.to_lowercase().contains(&q))
1243 .count();
1244 if nth + 1 < matches {
1245 if let Some((_, n)) = &mut self.hist_search {
1246 *n += 1;
1247 }
1248 }
1249 }
1250
1251 pub fn hist_search_accept(&mut self) {
1252 if let Some(stmt) = self.hist_search_current().cloned() {
1253 self.sql_set_input(&stmt);
1254 }
1255 self.hist_search = None;
1256 }
1257
1258 pub fn hist_search_cancel(&mut self) {
1259 self.hist_search = None;
1260 }
1261
1262 pub fn sql_push(&mut self, c: char) {
1263 if let Some(console) = &mut self.sql {
1264 let at = byte_at(&console.input, console.cursor);
1265 console.input.insert(at, c);
1266 console.cursor += 1;
1267 }
1268 }
1269
1270 pub fn sql_pop(&mut self) {
1272 if let Some(console) = &mut self.sql {
1273 if console.cursor > 0 {
1274 let at = byte_at(&console.input, console.cursor - 1);
1275 console.input.remove(at);
1276 console.cursor -= 1;
1277 }
1278 }
1279 }
1280
1281 pub fn sql_delete(&mut self) {
1283 if let Some(console) = &mut self.sql {
1284 if console.cursor < console.input.chars().count() {
1285 let at = byte_at(&console.input, console.cursor);
1286 console.input.remove(at);
1287 }
1288 }
1289 }
1290
1291 pub fn sql_left(&mut self) {
1292 if let Some(console) = &mut self.sql {
1293 console.cursor = console.cursor.saturating_sub(1);
1294 }
1295 }
1296
1297 pub fn sql_right(&mut self) {
1298 if let Some(console) = &mut self.sql {
1299 console.cursor = (console.cursor + 1).min(console.input.chars().count());
1300 }
1301 }
1302
1303 pub fn sql_hist_prev(&mut self) {
1305 let Some(console) = &mut self.sql else {
1306 return;
1307 };
1308 if self.sql_history.is_empty() {
1309 return;
1310 }
1311 let idx = match self.hist_idx {
1312 None => {
1313 self.hist_draft = console.input.clone();
1314 self.sql_history.len() - 1
1315 }
1316 Some(i) => i.saturating_sub(1),
1317 };
1318 self.hist_idx = Some(idx);
1319 console.input = self.sql_history[idx].clone();
1320 console.cursor = console.input.chars().count();
1321 }
1322
1323 pub fn sql_hist_next(&mut self) {
1325 let Some(console) = &mut self.sql else {
1326 return;
1327 };
1328 let Some(idx) = self.hist_idx else {
1329 return;
1330 };
1331 if idx + 1 < self.sql_history.len() {
1332 self.hist_idx = Some(idx + 1);
1333 console.input = self.sql_history[idx + 1].clone();
1334 } else {
1335 self.hist_idx = None;
1336 console.input = self.hist_draft.clone();
1337 }
1338 console.cursor = console.input.chars().count();
1339 }
1340
1341 pub fn sql_home(&mut self) {
1342 if let Some(console) = &mut self.sql {
1343 console.cursor = 0;
1344 }
1345 }
1346
1347 pub fn sql_end(&mut self) {
1348 if let Some(console) = &mut self.sql {
1349 console.cursor = console.input.chars().count();
1350 }
1351 }
1352
1353 pub fn sql_scroll(&mut self, delta: i32) {
1354 if let Some(console) = &mut self.sql {
1355 let max = match &console.data {
1356 Some(Ok(t)) => t.rows.len().saturating_sub(1),
1357 _ => 0,
1358 };
1359 console.scroll = if delta < 0 {
1360 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
1361 } else {
1362 (console.scroll + delta as usize).min(max)
1363 };
1364 }
1365 }
1366
1367 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
1369 let Some(console) = &self.sql else {
1370 return;
1371 };
1372 if console.running {
1373 return;
1374 }
1375 let query = console.input.trim().to_string();
1376 if query.is_empty() {
1377 return;
1378 }
1379 if self.sql_history.last() != Some(&query) {
1382 self.sql_history.push(query.clone());
1383 save_history(&self.sql_history);
1384 }
1385 self.hist_idx = None;
1386 self.hist_draft.clear();
1387 let warehouses = self.warehouses();
1388 if warehouses.is_empty() {
1389 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
1390 return;
1391 }
1392 if let Some((id, name)) = self.preview_warehouse.clone() {
1393 self.start_sql_query(cli, query, id, name);
1394 return;
1395 }
1396 if let [(name, id, _)] = warehouses.as_slice() {
1397 self.preview_warehouse = Some((id.clone(), name.clone()));
1398 self.start_sql_query(cli, query, id.clone(), name.clone());
1399 return;
1400 }
1401 let index = warehouses
1402 .iter()
1403 .position(|(_, _, running)| *running)
1404 .unwrap_or(0);
1405 self.wh_picker = Some(WhPicker {
1406 index,
1407 target: PickTarget::Sql(query),
1408 });
1409 }
1410
1411 fn start_sql_query(
1412 &mut self,
1413 cli: &Arc<DatabricksCli>,
1414 query: String,
1415 id: String,
1416 name: String,
1417 ) {
1418 if let Some(console) = &mut self.sql {
1419 console.running = true;
1420 console.warehouse = name;
1421 console.scroll = 0;
1422 console.last_sql = query.clone();
1423 }
1424 let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
1426 self.sql_stmt = Some(std::sync::Arc::clone(&handle));
1427 let (tx, rx) = oneshot::channel();
1428 self.sql_rx = Some(rx);
1429 let cli = Arc::clone(cli);
1430 tokio::spawn(async move {
1431 let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
1432 let _ = tx.send(result);
1433 });
1434 }
1435
1436 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
1439 let stamp = std::time::SystemTime::now()
1440 .duration_since(std::time::UNIX_EPOCH)
1441 .map(|d| d.as_secs())
1442 .unwrap_or(0);
1443 let slug: String = label
1444 .chars()
1445 .map(|c| if c.is_alphanumeric() { c } else { '-' })
1446 .collect::<String>()
1447 .trim_matches('-')
1448 .chars()
1449 .take(40)
1450 .collect();
1451 let name = format!("databricks-{slug}-{stamp}.csv");
1452 let msg = match std::fs::write(&name, data.to_csv()) {
1453 Ok(()) => {
1454 let cwd = std::env::current_dir()
1455 .map(|d| d.display().to_string())
1456 .unwrap_or_default();
1457 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
1458 }
1459 Err(e) => format!("✗ export failed: {e}"),
1460 };
1461 self.flash = Some((msg, Instant::now()));
1462 }
1463
1464 pub fn sql_export(&mut self) {
1466 if let Some(SqlConsole {
1467 data: Some(Ok(data)),
1468 last_sql,
1469 ..
1470 }) = &self.sql
1471 {
1472 let (label, data) = (last_sql.clone(), data.clone());
1473 self.export_csv(&label, &data);
1474 }
1475 }
1476
1477 pub fn preview_export(&mut self) {
1479 if let Some(Preview {
1480 data: Some(Ok(data)),
1481 name,
1482 ..
1483 }) = &self.preview
1484 {
1485 let (label, data) = (name.clone(), data.clone());
1486 self.export_csv(&label, &data);
1487 }
1488 }
1489
1490 pub fn poll_sql(&mut self) -> bool {
1491 let Some(rx) = &mut self.sql_rx else {
1492 return false;
1493 };
1494 match rx.try_recv() {
1495 Ok(result) => {
1496 if let Err(e) = &result {
1499 if e != "statement canceled" {
1500 self.preview_warehouse = None;
1501 }
1502 }
1503 if let Some(console) = &mut self.sql {
1504 console.running = false;
1505 console.data = Some(result);
1506 }
1507 self.sql_rx = None;
1508 self.sql_stmt = None;
1509 true
1510 }
1511 Err(oneshot::error::TryRecvError::Empty) => false,
1512 Err(oneshot::error::TryRecvError::Closed) => {
1513 if let Some(console) = &mut self.sql {
1514 console.running = false;
1515 }
1516 self.sql_rx = None;
1517 true
1518 }
1519 }
1520 }
1521
1522 pub fn poll_cost(&mut self) -> bool {
1523 let Some(rx) = &mut self.cost_rx else {
1524 return false;
1525 };
1526 match rx.try_recv() {
1527 Ok((result, ws)) => {
1528 if result.is_err() {
1529 self.preview_warehouse = None;
1530 }
1531 if ws.is_some() {
1532 self.workspace_id = ws;
1533 }
1534 if let Some(cv) = &mut self.cost {
1535 cv.data = Some(result);
1536 }
1537 self.cost_rx = None;
1538 true
1539 }
1540 Err(oneshot::error::TryRecvError::Empty) => false,
1541 Err(oneshot::error::TryRecvError::Closed) => {
1542 self.cost_rx = None;
1543 true
1544 }
1545 }
1546 }
1547
1548 pub fn wh_picker_next(&mut self) {
1549 let len = self.warehouses().len();
1550 if let Some(p) = &mut self.wh_picker {
1551 p.index = (p.index + 1).min(len.saturating_sub(1));
1552 }
1553 }
1554
1555 pub fn wh_picker_prev(&mut self) {
1556 if let Some(p) = &mut self.wh_picker {
1557 p.index = p.index.saturating_sub(1);
1558 }
1559 }
1560
1561 pub fn wh_picker_cancel(&mut self) {
1562 self.wh_picker = None;
1563 }
1564
1565 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
1567 let Some(picker) = self.wh_picker.take() else {
1568 return;
1569 };
1570 let warehouses = self.warehouses();
1571 let Some((name, id, _)) = warehouses.get(picker.index) else {
1572 return;
1573 };
1574 self.preview_warehouse = Some((id.clone(), name.clone()));
1575 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
1577 self.config
1578 .warehouses
1579 .insert(profile, (id.clone(), name.clone()));
1580 self.config.save();
1581 match picker.target {
1582 PickTarget::Preview(table) => {
1583 self.start_preview_query(cli, table, id.clone(), name.clone())
1584 }
1585 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
1586 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
1587 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
1588 }
1589 }
1590
1591 fn start_preview_query(
1592 &mut self,
1593 cli: &Arc<DatabricksCli>,
1594 full_name: String,
1595 warehouse_id: String,
1596 warehouse_name: String,
1597 ) {
1598 self.preview = Some(Preview {
1599 name: full_name.clone(),
1600 warehouse: warehouse_name,
1601 warehouse_id: warehouse_id.clone(),
1602 data: None,
1603 scroll: 0,
1604 });
1605 let (tx, rx) = oneshot::channel();
1606 self.preview_rx = Some(rx);
1607 let cli = Arc::clone(cli);
1608 tokio::spawn(async move {
1609 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
1610 let _ = tx.send(result);
1611 });
1612 }
1613
1614 pub fn close_preview(&mut self) {
1615 self.preview = None;
1616 self.preview_rx = None;
1617 }
1618
1619 pub fn poll_preview(&mut self) -> bool {
1620 let Some(rx) = &mut self.preview_rx else {
1621 return false;
1622 };
1623 match rx.try_recv() {
1624 Ok(result) => {
1625 if result.is_err() {
1627 self.preview_warehouse = None;
1628 }
1629 if let Some(pv) = &mut self.preview {
1630 pv.data = Some(result);
1631 }
1632 self.preview_rx = None;
1633 true
1634 }
1635 Err(oneshot::error::TryRecvError::Empty) => false,
1636 Err(oneshot::error::TryRecvError::Closed) => {
1637 self.preview_rx = None;
1638 true
1639 }
1640 }
1641 }
1642
1643 pub fn preview_scroll(&mut self, delta: i32) {
1644 if let Some(pv) = &mut self.preview {
1645 let max = match &pv.data {
1646 Some(Ok(t)) => t.rows.len().saturating_sub(1),
1647 _ => 0,
1648 };
1649 pv.scroll = if delta < 0 {
1650 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
1651 } else {
1652 (pv.scroll + delta as usize).min(max)
1653 };
1654 }
1655 }
1656
1657 pub fn poll_uc(&mut self) -> bool {
1658 let Some(rx) = &mut self.uc_rx else {
1659 return false;
1660 };
1661 match rx.try_recv() {
1662 Ok(result) => {
1663 self.shapes[5] = Some(match result {
1664 Ok(shape) => shape,
1665 Err(e) => Shape::Text(format!("✗ {e}")),
1666 });
1667 self.updated_at[5] = Some(Instant::now());
1668 self.uc_rx = None;
1669 true
1670 }
1671 Err(oneshot::error::TryRecvError::Empty) => false,
1672 Err(oneshot::error::TryRecvError::Closed) => {
1673 self.uc_rx = None;
1674 true
1675 }
1676 }
1677 }
1678
1679 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
1682 let Some(item) = self.selected_item() else {
1683 return;
1684 };
1685 let Some(id) = item.id.clone() else {
1686 return;
1687 };
1688 let (uc, object_type): (bool, &'static str) = match self.focus {
1689 Panel::Catalog => match &item.status {
1690 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
1691 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
1692 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
1693 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
1694 _ => return,
1695 },
1696 Panel::Clusters => (false, "clusters"),
1697 Panel::Jobs => (false, "jobs"),
1698 Panel::Pipelines => (false, "pipelines"),
1699 Panel::Warehouses => (false, "warehouses"),
1700 Panel::Dashboards => (false, "dashboards"),
1701 };
1702 self.detail = Some(Detail {
1703 panel: self.focus,
1704 name: item.name.clone(),
1705 id: id.clone(),
1706 kind: None,
1707 section: "Access",
1708 data: None,
1709 show_raw: false,
1710 scroll: 0,
1711 });
1712 let (tx, rx) = oneshot::channel();
1713 self.detail_rx = Some(rx);
1714 let cli = Arc::clone(cli);
1715 tokio::spawn(async move {
1716 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
1717 let _ = tx.send(data);
1718 });
1719 }
1720
1721 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
1724 let Some(d) = &self.detail else {
1725 return;
1726 };
1727 let panel = d.panel;
1728 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
1729 return;
1730 }
1731 let owner_id = d.id.clone();
1732 self.run_view = Some(RunView {
1733 panel,
1734 owner_name: d.name.clone(),
1735 owner_id: owner_id.clone(),
1736 runs: Vec::new(),
1737 idx: 0,
1738 data: None,
1739 show_raw: false,
1740 scroll: 0,
1741 live: false,
1742 fetched_at: Instant::now(),
1743 });
1744 let (tx, rx) = oneshot::channel();
1745 self.run_rx = Some(rx);
1746 let cli = Arc::clone(cli);
1747 tokio::spawn(async move {
1748 let result = async {
1749 let runs = if panel == Panel::Jobs {
1750 fetchers::runs::list(&cli, &owner_id).await?
1751 } else {
1752 fetchers::updates::list(&cli, &owner_id).await?
1753 };
1754 let Some((run_id, _, _)) = runs.first().cloned() else {
1755 return Err("no runs recorded yet".to_string());
1756 };
1757 let (data, live) = if panel == Panel::Jobs {
1758 fetchers::runs::fetch(&cli, &run_id).await
1759 } else {
1760 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
1761 };
1762 Ok((runs, data, live))
1763 }
1764 .await;
1765 let _ = tx.send(RunUpdate::Opened(result));
1766 });
1767 }
1768
1769 pub fn close_run(&mut self) {
1770 self.run_view = None;
1771 self.run_rx = None;
1772 }
1773
1774 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
1776 if self.run_rx.is_some() {
1777 return;
1778 }
1779 let Some(rv) = &mut self.run_view else {
1780 return;
1781 };
1782 if rv.runs.is_empty() {
1783 return;
1784 }
1785 let new = if delta < 0 {
1786 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
1787 } else {
1788 (rv.idx + delta as usize).min(rv.runs.len() - 1)
1789 };
1790 if new == rv.idx {
1791 return;
1792 }
1793 rv.idx = new;
1794 rv.data = None;
1795 rv.scroll = 0;
1796 rv.show_raw = false;
1797 let run_id = rv.runs[new].0.clone();
1798 self.start_run_fetch(cli, run_id);
1799 }
1800
1801 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
1802 let Some(rv) = &self.run_view else {
1803 return;
1804 };
1805 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
1806 let (tx, rx) = oneshot::channel();
1807 self.run_rx = Some(rx);
1808 let cli = Arc::clone(cli);
1809 tokio::spawn(async move {
1810 let (data, live) = if panel == Panel::Jobs {
1811 fetchers::runs::fetch(&cli, &run_id).await
1812 } else {
1813 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
1814 };
1815 let _ = tx.send(RunUpdate::Detail(data, live));
1816 });
1817 }
1818
1819 pub fn run_toggle_raw(&mut self) {
1820 if let Some(rv) = &mut self.run_view {
1821 rv.show_raw = !rv.show_raw;
1822 rv.scroll = 0;
1823 }
1824 }
1825
1826 pub fn run_scroll(&mut self, delta: i32) {
1827 if let Some(rv) = &mut self.run_view {
1828 rv.scroll = if delta < 0 {
1829 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
1830 } else {
1831 rv.scroll.saturating_add(delta as u16)
1832 };
1833 }
1834 }
1835
1836 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1839 if let Some(rx) = &mut self.run_rx {
1840 match rx.try_recv() {
1841 Ok(update) => {
1842 self.run_rx = None;
1843 if let Some(rv) = &mut self.run_view {
1844 match update {
1845 RunUpdate::Opened(Ok((runs, data, live))) => {
1846 rv.runs = runs;
1847 rv.idx = 0;
1848 rv.data = Some(data);
1849 rv.live = live;
1850 }
1851 RunUpdate::Opened(Err(e)) => {
1852 rv.data = Some(DetailData {
1853 summary: Vec::new(),
1854 activity: Vec::new(),
1855 raw: format!("✗ {e}"),
1856 });
1857 rv.live = false;
1858 }
1859 RunUpdate::Detail(data, live) => {
1860 rv.data = Some(data);
1861 rv.live = live;
1862 }
1863 }
1864 rv.fetched_at = Instant::now();
1865 }
1866 true
1867 }
1868 Err(oneshot::error::TryRecvError::Empty) => false,
1869 Err(oneshot::error::TryRecvError::Closed) => {
1870 self.run_rx = None;
1871 true
1872 }
1873 }
1874 } else if let Some(rv) = &self.run_view {
1875 if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
1876 if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
1877 self.start_run_fetch(cli, run_id);
1878 }
1879 }
1880 false
1881 } else {
1882 false
1883 }
1884 }
1885
1886 pub fn close_detail(&mut self) {
1887 self.detail = None;
1888 self.detail_rx = None;
1889 }
1890
1891 pub fn toggle_raw(&mut self) {
1892 if let Some(d) = &mut self.detail {
1893 d.show_raw = !d.show_raw;
1894 d.scroll = 0;
1895 }
1896 }
1897
1898 pub fn poll_detail(&mut self) -> bool {
1900 let Some(rx) = &mut self.detail_rx else {
1901 return false;
1902 };
1903 match rx.try_recv() {
1904 Ok(data) => {
1905 if let Some(d) = &mut self.detail {
1906 d.data = Some(data);
1907 }
1908 self.detail_rx = None;
1909 true
1910 }
1911 Err(oneshot::error::TryRecvError::Empty) => false,
1912 Err(oneshot::error::TryRecvError::Closed) => {
1913 self.detail_rx = None;
1914 true
1915 }
1916 }
1917 }
1918
1919 pub fn detail_scroll(&mut self, delta: i32) {
1920 if let Some(d) = &mut self.detail {
1921 let max = match &d.data {
1922 Some(data) if d.show_raw => data.raw.lines().count(),
1923 Some(data) => data.summary.len() + data.activity.len() + 3,
1924 None => 0,
1925 } as u16;
1926 d.scroll = if delta < 0 {
1927 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
1928 } else {
1929 (d.scroll + delta as u16).min(max.saturating_sub(1))
1930 };
1931 }
1932 }
1933
1934 pub fn request_action(&mut self) {
1937 if matches!(self.focus, Panel::Dashboards | Panel::Catalog) {
1939 return;
1940 }
1941 let Some(item) = self.selected_item() else {
1942 return;
1943 };
1944 let Some(id) = item.id.clone() else {
1945 return;
1946 };
1947 let name = item.name.clone();
1948 let active = matches!(
1949 item.status,
1950 Status::Running | Status::Pending | Status::Success
1951 );
1952 let group = self.focus.cli_group();
1953 let (verb, action): (&str, &str) = match self.focus {
1954 Panel::Jobs => ("Run", "run-now"),
1955 Panel::Clusters if active => ("Stop", "delete"),
1956 Panel::Pipelines if active => ("Stop", "stop"),
1957 Panel::Pipelines => ("Start update for", "start-update"),
1958 _ if active => ("Stop", "stop"),
1959 _ => ("Start", "start"),
1960 };
1961 self.confirm = Some(Confirm {
1962 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
1963 args: vec![group.to_string(), action.to_string(), id],
1964 });
1965 }
1966
1967 pub fn request_run_cancel(&mut self) {
1969 let Some(rv) = &self.run_view else {
1970 return;
1971 };
1972 if !rv.live {
1973 self.flash = Some((
1974 "✗ nothing to cancel — this run already finished".to_string(),
1975 Instant::now(),
1976 ));
1977 return;
1978 }
1979 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
1980 return;
1981 };
1982 let (message, args) = if rv.panel == Panel::Jobs {
1983 (
1984 format!("Cancel run {run_id} of “{}”?", rv.owner_name),
1985 vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
1986 )
1987 } else {
1988 (
1989 format!("Stop “{}” (cancels the active update)?", rv.owner_name),
1990 vec![
1991 "pipelines".to_string(),
1992 "stop".to_string(),
1993 rv.owner_id.clone(),
1994 ],
1995 )
1996 };
1997 self.confirm = Some(Confirm { message, args });
1998 }
1999
2000 pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
2003 let id = self
2004 .sql_stmt
2005 .as_ref()
2006 .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
2007 let Some(id) = id else {
2008 self.flash = Some((
2009 "✗ statement not submitted yet — try again in a moment".to_string(),
2010 Instant::now(),
2011 ));
2012 return;
2013 };
2014 let cli = Arc::clone(cli);
2015 tokio::spawn(async move {
2016 let path = format!("/api/2.0/sql/statements/{id}/cancel");
2017 let _ = cli.run_action(&["api", "post", &path]).await;
2018 });
2019 self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
2020 }
2021
2022 pub fn cancel_confirm(&mut self) {
2023 self.confirm = None;
2024 }
2025
2026 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
2027 let Some(c) = self.confirm.take() else {
2028 return;
2029 };
2030 let base = c.message.trim_end_matches('?').to_string();
2031 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
2032
2033 let (tx, rx) = oneshot::channel();
2034 self.action_rx = Some(rx);
2035 let cli = Arc::clone(cli);
2036 tokio::spawn(async move {
2037 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
2038 let result = match cli.run_action(&args).await {
2039 Ok(()) => Ok(format!("✓ {base} — done")),
2040 Err(e) => Err(format!("✗ {e:#}")),
2041 };
2042 let _ = tx.send(result);
2043 });
2044 }
2045
2046 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2048 let Some(rx) = &mut self.action_rx else {
2049 return false;
2050 };
2051 match rx.try_recv() {
2052 Ok(result) => {
2053 let ok = result.is_ok();
2054 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
2055 self.action_rx = None;
2056 if ok {
2057 self.start_refresh(cli);
2058 }
2059 true
2060 }
2061 Err(oneshot::error::TryRecvError::Empty) => false,
2062 Err(oneshot::error::TryRecvError::Closed) => {
2063 self.action_rx = None;
2064 true
2065 }
2066 }
2067 }
2068
2069 pub fn expire_flash(&mut self) -> bool {
2071 if let Some((_, since)) = &self.flash {
2072 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
2073 self.flash = None;
2074 return true;
2075 }
2076 }
2077 false
2078 }
2079
2080 pub fn open_in_browser(&self) {
2082 let Some(host) = &self.host else {
2083 return;
2084 };
2085 let (panel, id) = match &self.detail {
2086 Some(d) => (d.panel, Some(d.id.clone())),
2087 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
2088 };
2089 let Some(id) = id else {
2090 return;
2091 };
2092 let path = match panel {
2093 Panel::Clusters => format!("compute/clusters/{id}"),
2094 Panel::Jobs => format!("jobs/{id}"),
2095 Panel::Pipelines => format!("pipelines/{id}"),
2096 Panel::Warehouses => format!("sql/warehouses/{id}"),
2097 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
2098 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
2099 };
2100 let url = format!("{}/{}", host.trim_end_matches('/'), path);
2101 #[cfg(target_os = "macos")]
2102 let opener = "open";
2103 #[cfg(not(target_os = "macos"))]
2104 let opener = "xdg-open";
2105 let _ = std::process::Command::new(opener).arg(url).spawn();
2106 }
2107
2108 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
2110 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
2111 for shape in self.shapes.iter().flatten() {
2112 if let Shape::List(items) = shape {
2113 for item in items {
2114 match item.status {
2115 Status::Running | Status::Success => ok += 1,
2116 Status::Pending => pending += 1,
2117 Status::Failed => failed += 1,
2118 Status::Stopped => idle += 1,
2119 Status::Unknown(_) => {}
2120 }
2121 }
2122 }
2123 }
2124 (ok, pending, failed, idle)
2125 }
2126
2127 pub fn last_refresh_age(&self) -> Duration {
2128 self.last_refresh.elapsed()
2129 }
2130
2131 pub fn spinner(&self) -> &'static str {
2132 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
2133 }
2134
2135 pub fn spinner_frame(&self) -> usize {
2136 self.spinner_frame
2137 }
2138
2139 pub fn busy(&self) -> bool {
2142 self.loading
2143 || self.detail_rx.is_some()
2144 || self.action_rx.is_some()
2145 || self.preview_rx.is_some()
2146 || self.cost_rx.is_some()
2147 || self.sql_rx.is_some()
2148 || self.run_rx.is_some()
2149 }
2150
2151 pub fn tick_spinner(&mut self) {
2152 self.spinner_frame = self.spinner_frame.wrapping_add(1);
2153 }
2154
2155 pub fn toggle_zoom(&mut self) {
2156 self.zoomed = !self.zoomed;
2157 }
2158
2159 pub fn focus_next(&mut self) {
2160 let idx = Panel::ALL
2161 .iter()
2162 .position(|p| p == &self.focus)
2163 .unwrap_or(0);
2164 self.focus = Panel::ALL[(idx + 1) % Panel::ALL.len()];
2165 }
2166
2167 pub fn focus_prev(&mut self) {
2168 let idx = Panel::ALL
2169 .iter()
2170 .position(|p| p == &self.focus)
2171 .unwrap_or(0);
2172 self.focus = Panel::ALL[(idx + Panel::ALL.len() - 1) % Panel::ALL.len()];
2173 }
2174
2175 pub fn needs_refresh(&self) -> bool {
2176 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
2177 }
2178
2179 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
2180 if self.loading {
2181 return;
2182 }
2183 self.loading = true;
2184 self.error = None;
2185 self.last_refresh = Instant::now();
2186
2187 let (tx, rx) = mpsc::unbounded_channel();
2188 self.pending = Some(rx);
2189 self.in_flight = 7;
2190
2191 macro_rules! spawn_fetch {
2194 ($update:expr, $fetch:path) => {{
2195 let cli = Arc::clone(cli);
2196 let tx = tx.clone();
2197 tokio::spawn(async move {
2198 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
2199 let _ = tx.send($update(result));
2200 });
2201 }};
2202 }
2203
2204 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
2205 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
2206 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
2207 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
2208 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
2209 spawn_fetch!(
2210 |s: Result<Shape, String>| Update::Badge(s.ok()),
2211 fetchers::current_user::fetch
2212 );
2213 {
2214 let cli = Arc::clone(cli);
2215 let tx = tx.clone();
2216 let path = self.uc_path.clone();
2217 tokio::spawn(async move {
2218 let result = fetchers::catalog::fetch(&cli, &path)
2219 .await
2220 .map_err(|e| format!("{e:#}"));
2221 let _ = tx.send(Update::Panel(5, result));
2222 });
2223 }
2224 }
2225
2226 pub fn poll_refresh(&mut self) -> bool {
2228 let Some(rx) = &mut self.pending else {
2229 return false;
2230 };
2231 let mut changed = false;
2232 let mut updated_panes: Vec<usize> = Vec::new();
2233 loop {
2234 match rx.try_recv() {
2235 Ok(Update::Panel(i, result)) => {
2236 match result {
2237 Ok(mut shape) => {
2238 if i != 5 {
2242 if let Shape::List(items) = &mut shape {
2243 items.sort_by_key(|it| {
2244 (it.status.rank(), it.history.is_empty())
2245 });
2246 }
2247 }
2248 self.shapes[i] = Some(shape);
2249 self.updated_at[i] = Some(Instant::now());
2250 updated_panes.push(i);
2251 }
2252 Err(e) => {
2255 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
2256 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
2257 }
2258 }
2259 }
2260 self.in_flight -= 1;
2261 changed = true;
2262 }
2263 Ok(Update::Badge(badge)) => {
2264 if badge.is_some() {
2265 self.user_badge = badge;
2266 }
2267 self.in_flight -= 1;
2268 changed = true;
2269 }
2270 Err(mpsc::error::TryRecvError::Empty) => break,
2271 Err(mpsc::error::TryRecvError::Disconnected) => {
2272 self.in_flight = 0;
2273 break;
2274 }
2275 }
2276 }
2277 for i in updated_panes {
2278 self.alert_new_failures(i);
2279 }
2280 if self.in_flight == 0 {
2281 self.loading = false;
2282 self.pending = None;
2283 changed = true;
2284 }
2285 changed
2286 }
2287}