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 Secrets,
79}
80
81impl Panel {
82 pub const ALL: &'static [Panel] = &[
83 Panel::Clusters,
84 Panel::Jobs,
85 Panel::Pipelines,
86 Panel::Warehouses,
87 Panel::Dashboards,
88 Panel::Catalog,
89 Panel::Secrets,
90 ];
91
92 pub fn title(&self) -> &'static str {
93 match self {
94 Panel::Clusters => "Compute",
95 Panel::Jobs => "Lakeflow Jobs",
96 Panel::Pipelines => "Lakeflow Pipelines",
97 Panel::Warehouses => "SQL Warehouses",
98 Panel::Dashboards => "AI/BI Dashboards",
99 Panel::Catalog => "Unity Catalog",
100 Panel::Secrets => "Secret Scopes",
101 }
102 }
103
104 pub fn icon(&self) -> &'static str {
105 match self {
106 Panel::Clusters => "⌬",
107 Panel::Jobs => "⟳",
108 Panel::Pipelines => "⋙",
109 Panel::Warehouses => "⌁",
110 Panel::Dashboards => "▦",
111 Panel::Catalog => "⧉",
112 Panel::Secrets => "◈",
113 }
114 }
115
116 pub fn id(&self) -> &'static str {
118 match self {
119 Panel::Clusters => "compute",
120 Panel::Jobs => "jobs",
121 Panel::Pipelines => "pipelines",
122 Panel::Warehouses => "warehouses",
123 Panel::Dashboards => "dashboards",
124 Panel::Catalog => "catalog",
125 Panel::Secrets => "secrets",
126 }
127 }
128
129 pub fn cli_group(&self) -> &'static str {
131 match self {
132 Panel::Clusters => "clusters",
133 Panel::Jobs => "jobs",
134 Panel::Pipelines => "pipelines",
135 Panel::Warehouses => "warehouses",
136 Panel::Dashboards => "lakeview",
137 Panel::Catalog => "tables",
138 Panel::Secrets => "secrets",
139 }
140 }
141}
142
143pub struct Detail {
144 pub panel: Panel,
145 pub name: String,
146 pub id: String,
147 pub kind: Option<String>,
149 pub section: &'static str,
151 pub data: Option<DetailData>,
153 pub show_raw: bool,
155 pub scroll: u16,
156}
157
158pub struct Preview {
160 pub name: String,
161 pub warehouse: String,
163 pub warehouse_id: String,
164 pub data: Option<Result<crate::shape::TableData, String>>,
166 pub scroll: usize,
168 pub col: usize,
170 pub filter: String,
173 pub filter_entry: bool,
174 pub record: bool,
176 pub rscroll: u16,
178}
179
180impl Preview {
181 pub fn visible_cols(&self) -> Vec<usize> {
183 let Some(Ok(t)) = &self.data else {
184 return Vec::new();
185 };
186 let q = self.filter.to_lowercase();
187 (0..t.headers.len())
188 .filter(|&i| q.is_empty() || t.headers[i].to_lowercase().contains(&q))
189 .collect()
190 }
191}
192
193enum PickTarget {
195 Preview(String),
196 Cost,
197 Lineage(String),
198 Sql(String),
199}
200
201pub struct SqlConsole {
203 pub input: String,
204 pub cursor: usize,
206 pub warehouse: String,
208 pub running: bool,
209 pub data: Option<Result<crate::shape::TableData, String>>,
210 pub last_sql: String,
212 pub scroll: usize,
213 pub col: usize,
215}
216
217fn history_path() -> Option<std::path::PathBuf> {
219 let home = std::env::var_os("HOME")?;
220 Some(
221 std::path::PathBuf::from(home)
222 .join(".config")
223 .join("databricks-tui")
224 .join("history"),
225 )
226}
227
228fn load_history() -> Vec<String> {
229 history_path()
230 .and_then(|p| std::fs::read_to_string(p).ok())
231 .map(|s| {
232 s.lines()
233 .filter(|l| !l.trim().is_empty())
234 .map(str::to_string)
235 .collect()
236 })
237 .unwrap_or_default()
238}
239
240fn save_history(history: &[String]) {
241 let Some(path) = history_path() else {
242 return;
243 };
244 if let Some(dir) = path.parent() {
245 let _ = std::fs::create_dir_all(dir);
246 crate::config::restrict(dir, 0o700);
247 }
248 let tail: Vec<&str> = history
250 .iter()
251 .rev()
252 .take(200)
253 .rev()
254 .map(String::as_str)
255 .collect();
256 let _ = std::fs::write(&path, tail.join("\n") + "\n");
258 crate::config::restrict(&path, 0o600);
259}
260
261fn subsequence(haystack: &str, needle: &str) -> bool {
263 let mut chars = haystack.chars();
264 needle.chars().all(|n| chars.any(|h| h == n))
265}
266
267fn byte_at(input: &str, cursor: usize) -> usize {
269 input
270 .char_indices()
271 .nth(cursor)
272 .map(|(i, _)| i)
273 .unwrap_or(input.len())
274}
275
276pub struct WhPicker {
278 pub index: usize,
279 target: PickTarget,
280}
281
282pub struct CostView {
284 pub warehouse: String,
285 pub data: Option<Result<fetchers::cost::CostData, String>>,
286}
287
288pub struct RunView {
291 pub panel: Panel,
293 pub owner_name: String,
294 owner_id: String,
296 pub runs: Vec<(String, Status, String)>,
298 pub idx: usize,
300 pub data: Option<DetailData>,
301 pub show_raw: bool,
302 pub scroll: u16,
303 pub live: bool,
305 pub output: Option<String>,
307 pub show_output: bool,
308 fetched_at: Instant,
309}
310
311type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
313
314enum RunUpdate {
315 Opened(Result<RunOpened, String>),
316 Detail(DetailData, bool),
317 Output(String),
318}
319
320pub struct Problem {
322 pub panel: usize,
323 pub name: String,
324 pub status: Status,
325 pub note: String,
326}
327
328pub struct Problems {
330 pub items: Vec<Problem>,
331 pub index: usize,
332}
333
334pub struct Confirm {
336 pub message: String,
337 args: Vec<String>,
338}
339
340enum Update {
341 Panel(usize, Result<Shape, String>),
342 Badge(Option<Shape>),
343}
344
345const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
346
347pub struct App {
348 pub focus: Panel,
349 pub theme: ThemeMode,
350 pub zoomed: bool,
351 pub shapes: Vec<Option<Shape>>,
352 pub user_badge: Option<Shape>,
353 pub error: Option<String>,
354 pub refresh_interval: Duration,
355 last_refresh: Instant,
356 pub loading: bool,
357 pub detail: Option<Detail>,
358 pub confirm: Option<Confirm>,
359 pub flash: Option<(String, Instant)>,
360 pub selected: [usize; 7],
361 pub host: Option<String>,
362 pub profiles: Vec<String>,
364 pub profile: Option<String>,
365 pub picker: Option<usize>,
367 pub problems: Option<Problems>,
369 pub uc_path: Vec<String>,
371 uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
372 pub preview: Option<Preview>,
373 preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
374 pub wh_picker: Option<WhPicker>,
375 pub preview_warehouse: Option<(String, String)>,
377 pub cost: Option<CostView>,
378 #[allow(clippy::type_complexity)]
379 cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
380 workspace_id: Option<String>,
383 pub sql: Option<SqlConsole>,
384 sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
385 sql_history: Vec<String>,
387 hist_idx: Option<usize>,
389 hist_draft: String,
391 pub hist_search: Option<(String, usize)>,
393 pub run_view: Option<RunView>,
394 run_rx: Option<oneshot::Receiver<RunUpdate>>,
395 pending: Option<mpsc::UnboundedReceiver<Update>>,
396 detail_rx: Option<oneshot::Receiver<DetailData>>,
397 action_rx: Option<oneshot::Receiver<Result<String, String>>>,
398 host_rx: Option<oneshot::Receiver<Option<String>>>,
399 in_flight: usize,
400 spinner_frame: usize,
401 pub splash_until: Option<Instant>,
403 pub updated_at: [Option<Instant>; 7],
405 pub filters: [String; 7],
407 pub filter_entry: bool,
409 pub config: crate::config::Config,
411 failed_seen: [Option<std::collections::HashSet<String>>; 7],
414 pub jump: Option<Jump>,
416 pub pane_order: Vec<usize>,
418 pub hidden: [bool; 7],
420 pub pane_cfg: Option<usize>,
422 pub help: bool,
424 pub help_scroll: u16,
426 sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
428 pub secret_scope: Option<String>,
430 secrets_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
431 pub secret_form: Option<SecretForm>,
433}
434
435pub struct Jump {
437 pub query: String,
438 pub index: usize,
439}
440
441pub struct SecretForm {
443 pub scope: Option<String>,
445 pub key: String,
446 pub value: String,
447 pub stage: u8,
449}
450
451impl App {
452 pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
453 let mut app = Self {
454 focus: Panel::Clusters,
455 theme,
456 zoomed: false,
457 shapes: vec![None; 7],
458 user_badge: None,
459 error: None,
460 refresh_interval: Duration::from_secs(refresh_secs),
461 last_refresh: Instant::now()
462 .checked_sub(Duration::from_secs(refresh_secs + 1))
463 .unwrap_or(Instant::now()),
464 loading: false,
465 detail: None,
466 confirm: None,
467 flash: None,
468 selected: [0; 7],
469 host: None,
470 profiles: Vec::new(),
471 profile: None,
472 picker: None,
473 problems: None,
474 uc_path: Vec::new(),
475 uc_rx: None,
476 preview: None,
477 preview_rx: None,
478 wh_picker: None,
479 preview_warehouse: None,
480 cost: None,
481 cost_rx: None,
482 workspace_id: None,
483 sql: None,
484 sql_rx: None,
485 sql_history: load_history(),
486 hist_idx: None,
487 hist_draft: String::new(),
488 hist_search: None,
489 run_view: None,
490 run_rx: None,
491 pending: None,
492 detail_rx: None,
493 action_rx: None,
494 host_rx: None,
495 in_flight: 0,
496 spinner_frame: 0,
497 splash_until: Some(Instant::now() + Duration::from_millis(1600)),
498 updated_at: [None; 7],
499 filters: Default::default(),
500 filter_entry: false,
501 config: crate::config::Config::load(),
502 failed_seen: Default::default(),
503 jump: None,
504 pane_order: (0..7).collect(),
505 hidden: [false; 7],
506 pane_cfg: None,
507 help: false,
508 help_scroll: 0,
509 sql_stmt: None,
510 secret_scope: None,
511 secrets_rx: None,
512 secret_form: None,
513 };
514 app.load_pane_prefs();
515 app
516 }
517
518 fn load_pane_prefs(&mut self) {
520 let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
521 let mut order: Vec<usize> = self
522 .config
523 .pane_order
524 .iter()
525 .filter_map(|id| idx_of(id))
526 .collect();
527 for i in 0..7 {
528 if !order.contains(&i) {
529 order.push(i);
530 }
531 }
532 self.pane_order = order;
533 for id in &self.config.hidden_panes {
534 if let Some(i) = idx_of(id) {
535 self.hidden[i] = true;
536 }
537 }
538 self.ensure_focus_visible();
539 }
540
541 fn persist_panes(&mut self) {
542 self.config.pane_order = self
543 .pane_order
544 .iter()
545 .map(|&i| Panel::ALL[i].id().to_string())
546 .collect();
547 self.config.hidden_panes = (0..7)
548 .filter(|&i| self.hidden[i])
549 .map(|i| Panel::ALL[i].id().to_string())
550 .collect();
551 self.config.save();
552 }
553
554 pub fn visible_panes(&self) -> Vec<usize> {
556 self.pane_order
557 .iter()
558 .copied()
559 .filter(|&i| !self.hidden[i])
560 .collect()
561 }
562
563 fn ensure_focus_visible(&mut self) {
565 let visible = self.visible_panes();
566 let focus_idx = Panel::ALL
567 .iter()
568 .position(|p| p == &self.focus)
569 .unwrap_or(0);
570 if !visible.contains(&focus_idx) {
571 if let Some(&first) = visible.first() {
572 self.focus = Panel::ALL[first];
573 }
574 }
575 }
576
577 fn reveal_pane(&mut self, idx: usize) {
579 if self.hidden[idx] {
580 self.hidden[idx] = false;
581 self.persist_panes();
582 }
583 }
584
585 pub fn open_pane_cfg(&mut self) {
586 self.pane_cfg = Some(0);
587 }
588
589 pub fn pane_cfg_next(&mut self) {
590 if let Some(i) = self.pane_cfg {
591 self.pane_cfg = Some((i + 1).min(6));
592 }
593 }
594
595 pub fn pane_cfg_prev(&mut self) {
596 if let Some(i) = self.pane_cfg {
597 self.pane_cfg = Some(i.saturating_sub(1));
598 }
599 }
600
601 pub fn pane_cfg_toggle(&mut self) {
604 let Some(pos) = self.pane_cfg else {
605 return;
606 };
607 let idx = self.pane_order[pos];
608 if !self.hidden[idx] && self.visible_panes().len() == 1 {
609 self.flash = Some((
610 "✗ at least one pane has to stay visible".to_string(),
611 Instant::now(),
612 ));
613 return;
614 }
615 self.hidden[idx] = !self.hidden[idx];
616 self.ensure_focus_visible();
617 self.persist_panes();
618 }
619
620 pub fn pane_cfg_move(&mut self, delta: i32) {
622 let Some(pos) = self.pane_cfg else {
623 return;
624 };
625 let new = if delta < 0 {
626 pos.saturating_sub(1)
627 } else {
628 (pos + 1).min(6)
629 };
630 if new != pos {
631 self.pane_order.swap(pos, new);
632 self.pane_cfg = Some(new);
633 self.persist_panes();
634 }
635 }
636
637 fn alert_new_failures(&mut self, idx: usize) {
640 if idx >= 5 {
642 return;
643 }
644 let Some(Shape::List(items)) = &self.shapes[idx] else {
645 return;
646 };
647 let failed: std::collections::HashSet<String> = items
648 .iter()
649 .filter(|it| {
650 matches!(it.status, Status::Failed)
651 || it
652 .history
653 .last()
654 .is_some_and(|s| matches!(s, Status::Failed))
655 })
656 .map(|it| it.name.clone())
657 .collect();
658 if let Some(prev) = &self.failed_seen[idx] {
659 let mut newly: Vec<&String> = failed.difference(prev).collect();
660 if !newly.is_empty() {
661 newly.sort();
662 let extra = if newly.len() > 1 {
663 format!(" (+{} more)", newly.len() - 1)
664 } else {
665 String::new()
666 };
667 self.flash = Some((
668 format!("✗ {}{extra} just failed — ! to inspect", newly[0]),
669 Instant::now(),
670 ));
671 print!("\x07");
673 let _ = std::io::Write::flush(&mut std::io::stdout());
674 }
675 }
676 self.failed_seen[idx] = Some(failed);
677 }
678
679 pub fn persist_theme(&mut self) {
681 self.config.theme = Some(self.theme.id().to_string());
682 self.config.save();
683 }
684
685 pub fn restore_warehouse_pref(&mut self) {
687 let profile = self.profile.as_deref().unwrap_or("DEFAULT");
688 self.preview_warehouse = self.config.warehouses.get(profile).cloned();
689 }
690
691 pub fn splash_active(&self) -> bool {
692 self.splash_until
693 .map(|t| Instant::now() < t)
694 .unwrap_or(false)
695 }
696
697 pub fn dismiss_splash(&mut self) {
698 self.splash_until = None;
699 }
700
701 pub fn any_fresh(&self) -> bool {
703 self.updated_at
704 .iter()
705 .flatten()
706 .any(|t| t.elapsed() < Duration::from_millis(1200))
707 }
708
709 pub fn open_picker(&mut self) {
710 if self.profiles.is_empty() {
711 return;
712 }
713 let current = self
714 .profile
715 .as_deref()
716 .and_then(|p| self.profiles.iter().position(|n| n == p))
717 .unwrap_or(0);
718 self.picker = Some(current);
719 }
720
721 pub fn picker_next(&mut self) {
722 if let Some(i) = self.picker {
723 self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
724 }
725 }
726
727 pub fn picker_prev(&mut self) {
728 if let Some(i) = self.picker {
729 self.picker = Some(i.saturating_sub(1));
730 }
731 }
732
733 pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
735 let idx = self.picker.take()?;
736 let name = self.profiles.get(idx)?.clone();
737 let profile_arg = if name == "DEFAULT" {
738 None
739 } else {
740 Some(name.clone())
741 };
742 self.profile = Some(name);
743
744 self.shapes = vec![None; 7];
746 self.user_badge = None;
747 self.host = None;
748 self.selected = [0; 7];
749 self.detail = None;
750 self.detail_rx = None;
751 self.confirm = None;
752 self.problems = None;
753 self.uc_path.clear();
754 self.uc_rx = None;
755 self.secret_scope = None;
756 self.secrets_rx = None;
757 self.secret_form = None;
758 self.preview = None;
759 self.preview_rx = None;
760 self.wh_picker = None;
761 self.preview_warehouse = None;
762 self.cost = None;
763 self.cost_rx = None;
764 self.workspace_id = None;
765 self.sql = None;
766 self.sql_rx = None;
767 self.run_view = None;
768 self.run_rx = None;
769 self.pending = None;
770 self.in_flight = 0;
771 self.loading = false;
772 self.zoomed = false;
773 self.filters = Default::default();
774 self.filter_entry = false;
775 self.failed_seen = Default::default();
776 self.jump = None;
777 self.sql_stmt = None;
778 self.restore_warehouse_pref();
779
780 Some(Arc::new(DatabricksCli::new(profile_arg)))
781 }
782
783 pub fn open_jump(&mut self) {
784 self.jump = Some(Jump {
785 query: String::new(),
786 index: 0,
787 });
788 }
789
790 pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
794 let Some(jump) = &self.jump else {
795 return Vec::new();
796 };
797 let q = jump.query.to_lowercase();
798 let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
799 for (i, shape) in self.shapes.iter().enumerate() {
800 let Some(Shape::List(items)) = shape else {
801 continue;
802 };
803 for it in items {
804 let name = it.name.to_lowercase();
805 let rank = if q.is_empty() || name.contains(&q) {
806 0
807 } else if subsequence(&name, &q) {
808 1
809 } else {
810 continue;
811 };
812 scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
813 }
814 }
815 scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
816 scored
817 .into_iter()
818 .take(12)
819 .map(|(_, i, name, label)| (i, name, label))
820 .collect()
821 }
822
823 pub fn jump_push(&mut self, c: char) {
824 if let Some(j) = &mut self.jump {
825 j.query.push(c);
826 j.index = 0;
827 }
828 }
829
830 pub fn jump_pop(&mut self) {
831 if let Some(j) = &mut self.jump {
832 j.query.pop();
833 j.index = 0;
834 }
835 }
836
837 pub fn jump_next(&mut self) {
838 let len = self.jump_matches().len();
839 if let Some(j) = &mut self.jump {
840 j.index = (j.index + 1).min(len.saturating_sub(1));
841 }
842 }
843
844 pub fn jump_prev(&mut self) {
845 if let Some(j) = &mut self.jump {
846 j.index = j.index.saturating_sub(1);
847 }
848 }
849
850 pub fn jump_go(&mut self) {
852 let matches = self.jump_matches();
853 let Some(jump) = self.jump.take() else {
854 return;
855 };
856 let Some((panel_idx, name, _)) = matches.get(jump.index) else {
857 return;
858 };
859 self.reveal_pane(*panel_idx);
860 self.focus = Panel::ALL[*panel_idx];
861 self.filters[*panel_idx].clear();
862 if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
863 if let Some(pos) = items.iter().position(|i| &i.name == name) {
864 self.selected[*panel_idx] = pos;
865 }
866 }
867 }
868
869 pub fn open_problems(&mut self) {
872 let mut items = Vec::new();
873 for (i, shape) in self.shapes.iter().enumerate() {
874 let Some(Shape::List(list)) = shape else {
875 continue;
876 };
877 for it in list {
878 let failed_now = matches!(it.status, Status::Failed);
879 let failed_last = it
880 .history
881 .last()
882 .is_some_and(|s| matches!(s, Status::Failed));
883 if failed_now || failed_last {
884 let note = if failed_now {
885 it.detail.clone().unwrap_or_default()
886 } else {
887 "latest run failed".to_string()
888 };
889 items.push(Problem {
890 panel: i,
891 name: it.name.clone(),
892 status: it.status.clone(),
893 note,
894 });
895 }
896 }
897 }
898 self.problems = Some(Problems { items, index: 0 });
899 }
900
901 pub fn problems_next(&mut self) {
902 if let Some(pr) = &mut self.problems {
903 pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
904 }
905 }
906
907 pub fn problems_prev(&mut self) {
908 if let Some(pr) = &mut self.problems {
909 pr.index = pr.index.saturating_sub(1);
910 }
911 }
912
913 pub fn problems_jump(&mut self) {
915 let Some(pr) = self.problems.take() else {
916 return;
917 };
918 let Some(problem) = pr.items.get(pr.index) else {
919 return;
920 };
921 self.reveal_pane(problem.panel);
922 self.focus = Panel::ALL[problem.panel];
923 self.filters[problem.panel].clear();
925 if let Some(Shape::List(list)) = &self.shapes[problem.panel] {
926 if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
927 self.selected[problem.panel] = pos;
928 }
929 }
930 }
931
932 pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
935 let (tx, rx) = oneshot::channel();
936 self.host_rx = Some(rx);
937 let cli = Arc::clone(cli);
938 tokio::spawn(async move {
939 let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
940 json["details"]["host"]
941 .as_str()
942 .or_else(|| json["host"].as_str())
943 .map(str::to_string)
944 });
945 let _ = tx.send(host);
946 });
947 }
948
949 pub fn poll_host(&mut self) {
950 if let Some(rx) = &mut self.host_rx {
951 match rx.try_recv() {
952 Ok(host) => {
953 self.host = host;
954 self.host_rx = None;
955 }
956 Err(oneshot::error::TryRecvError::Empty) => {}
957 Err(oneshot::error::TryRecvError::Closed) => {
958 self.host_rx = None;
959 }
960 }
961 }
962 }
963
964 fn focus_index(&self) -> usize {
965 Panel::ALL
966 .iter()
967 .position(|p| p == &self.focus)
968 .unwrap_or(0)
969 }
970
971 fn list_len(&self, idx: usize) -> usize {
972 match &self.shapes[idx] {
973 Some(Shape::List(items)) => items
974 .iter()
975 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
976 .count(),
977 _ => 0,
978 }
979 }
980
981 pub fn selection(&self, idx: usize) -> usize {
983 self.selected[idx].min(self.list_len(idx).saturating_sub(1))
984 }
985
986 pub fn select_next(&mut self) {
987 let idx = self.focus_index();
988 let len = self.list_len(idx);
989 if len > 0 {
990 self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
991 }
992 }
993
994 pub fn select_prev(&mut self) {
995 let idx = self.focus_index();
996 self.selected[idx] = self.selection(idx).saturating_sub(1);
997 }
998
999 fn selected_item(&self) -> Option<&crate::shape::ListItem> {
1002 let idx = self.focus_index();
1003 match &self.shapes[idx] {
1004 Some(Shape::List(items)) => items
1005 .iter()
1006 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1007 .nth(self.selection(idx)),
1008 _ => None,
1009 }
1010 }
1011
1012 pub fn filter_start(&mut self) {
1014 let idx = self.focus_index();
1015 self.filters[idx].clear();
1016 self.selected[idx] = 0;
1017 self.filter_entry = true;
1018 }
1019
1020 pub fn filter_push(&mut self, c: char) {
1021 let idx = self.focus_index();
1022 self.filters[idx].push(c);
1023 self.selected[idx] = 0;
1024 }
1025
1026 pub fn filter_pop(&mut self) {
1027 let idx = self.focus_index();
1028 self.filters[idx].pop();
1029 self.selected[idx] = 0;
1030 }
1031
1032 pub fn filter_accept(&mut self) {
1034 self.filter_entry = false;
1035 }
1036
1037 pub fn filter_clear(&mut self) {
1038 let idx = self.focus_index();
1039 self.filters[idx].clear();
1040 self.selected[idx] = 0;
1041 self.filter_entry = false;
1042 }
1043
1044 pub fn active_filter(&self) -> &str {
1046 &self.filters[self.focus_index()]
1047 }
1048
1049 pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
1050 let Some(item) = self.selected_item() else {
1051 return;
1052 };
1053 let Some(id) = item.id.clone() else {
1054 return;
1055 };
1056 let kind = match &item.status {
1057 Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1058 _ => None,
1059 };
1060 let section = match self.focus {
1061 Panel::Dashboards => "Contents",
1062 Panel::Catalog => "Columns",
1063 Panel::Warehouses => "Recent queries",
1064 _ => "Recent activity",
1065 };
1066 self.detail = Some(Detail {
1067 panel: self.focus,
1068 name: item.name.clone(),
1069 id: id.clone(),
1070 kind,
1071 section,
1072 data: None,
1073 show_raw: false,
1074 scroll: 0,
1075 });
1076
1077 let (tx, rx) = oneshot::channel();
1078 self.detail_rx = Some(rx);
1079 let cli = Arc::clone(cli);
1080 let kind = self.detail.as_ref().unwrap().kind.clone();
1081 if kind.as_deref() == Some("FILE") {
1083 if let Some(d) = &mut self.detail {
1084 d.section = "File head";
1085 }
1086 tokio::spawn(async move {
1087 let data = fetchers::catalog::file_peek(&cli, &id).await;
1088 let _ = tx.send(data);
1089 });
1090 return;
1091 }
1092 let group = match &kind {
1093 Some(k) if k == "VOLUME" => "volumes",
1094 _ => self.focus.cli_group(),
1095 };
1096 let warehouse = match &kind {
1099 Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1100 _ => None,
1101 };
1102 tokio::spawn(async move {
1103 let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1104 let _ = tx.send(data);
1105 });
1106 }
1107
1108 pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1111 if self.focus != Panel::Catalog {
1112 return false;
1113 }
1114 let Some(item) = self.selected_item() else {
1115 return self.uc_path.is_empty(); };
1117 if self.uc_path.len() >= 2 {
1120 let drillable =
1121 matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1122 if !drillable {
1123 return false;
1124 }
1125 }
1126 self.uc_path.push(item.name.clone());
1127 self.refresh_catalog(cli);
1128 true
1129 }
1130
1131 pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1133 if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1134 return false;
1135 }
1136 self.uc_path.pop();
1137 self.refresh_catalog(cli);
1138 true
1139 }
1140
1141 fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1142 self.shapes[5] = None;
1143 self.selected[5] = 0;
1144 self.filters[5].clear();
1146 let (tx, rx) = oneshot::channel();
1147 self.uc_rx = Some(rx);
1148 let cli = Arc::clone(cli);
1149 let path = self.uc_path.clone();
1150 tokio::spawn(async move {
1151 let result = fetchers::catalog::fetch(&cli, &path)
1152 .await
1153 .map_err(|e| format!("{e:#}"));
1154 let _ = tx.send(result);
1155 });
1156 }
1157
1158 pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1162 if self.focus != Panel::Secrets {
1163 return false;
1164 }
1165 if self.secret_scope.is_some() {
1167 return true;
1168 }
1169 let Some(item) = self.selected_item() else {
1170 return true; };
1172 self.secret_scope = Some(item.name.clone());
1173 self.refresh_secrets(cli);
1174 true
1175 }
1176
1177 pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1179 if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1180 return false;
1181 }
1182 self.secret_scope = None;
1183 self.refresh_secrets(cli);
1184 true
1185 }
1186
1187 fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1188 let idx = 6;
1189 self.shapes[idx] = None;
1190 self.selected[idx] = 0;
1191 self.filters[idx].clear();
1192 let (tx, rx) = oneshot::channel();
1193 self.secrets_rx = Some(rx);
1194 let cli = Arc::clone(cli);
1195 let scope = self.secret_scope.clone();
1196 tokio::spawn(async move {
1197 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1198 .await
1199 .map_err(|e| format!("{e:#}"));
1200 let _ = tx.send(result);
1201 });
1202 }
1203
1204 pub fn poll_secrets(&mut self) -> bool {
1205 let Some(rx) = &mut self.secrets_rx else {
1206 return false;
1207 };
1208 match rx.try_recv() {
1209 Ok(result) => {
1210 self.shapes[6] = Some(match result {
1211 Ok(shape) => shape,
1212 Err(e) => Shape::Text(format!("✗ {e}")),
1213 });
1214 self.updated_at[6] = Some(Instant::now());
1215 self.secrets_rx = None;
1216 true
1217 }
1218 Err(oneshot::error::TryRecvError::Empty) => false,
1219 Err(oneshot::error::TryRecvError::Closed) => {
1220 self.secrets_rx = None;
1221 true
1222 }
1223 }
1224 }
1225
1226 pub fn open_secret_form(&mut self) {
1229 if self.focus != Panel::Secrets {
1230 return;
1231 }
1232 self.secret_form = Some(SecretForm {
1233 scope: self.secret_scope.clone(),
1234 key: String::new(),
1235 value: String::new(),
1236 stage: 0,
1237 });
1238 }
1239
1240 pub fn secret_form_push(&mut self, c: char) {
1241 if let Some(form) = &mut self.secret_form {
1242 if form.stage == 0 {
1243 form.key.push(c);
1244 } else {
1245 form.value.push(c);
1246 }
1247 }
1248 }
1249
1250 pub fn secret_form_pop(&mut self) {
1251 if let Some(form) = &mut self.secret_form {
1252 if form.stage == 0 {
1253 form.key.pop();
1254 } else {
1255 form.value.pop();
1256 }
1257 }
1258 }
1259
1260 pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1262 let Some(form) = &mut self.secret_form else {
1263 return;
1264 };
1265 if form.key.trim().is_empty() {
1266 return;
1267 }
1268 match (form.scope.clone(), form.stage) {
1269 (None, _) => {
1271 let name = form.key.trim().to_string();
1272 self.secret_form = None;
1273 self.run_secret_action(
1274 cli,
1275 format!("Create scope “{name}”"),
1276 vec!["secrets".into(), "create-scope".into(), name],
1277 );
1278 }
1279 (Some(_), 0) => form.stage = 1,
1281 (Some(scope), _) => {
1282 let key = form.key.trim().to_string();
1283 let value = form.value.clone();
1284 self.secret_form = None;
1285 self.run_secret_action(
1286 cli,
1287 format!("Put secret “{key}” in “{scope}”"),
1288 vec![
1289 "secrets".into(),
1290 "put-secret".into(),
1291 scope,
1292 key,
1293 "--string-value".into(),
1294 value,
1295 ],
1296 );
1297 }
1298 }
1299 }
1300
1301 fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1304 self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1305 let (tx, rx) = oneshot::channel();
1306 self.action_rx = Some(rx);
1307 let cli = Arc::clone(cli);
1308 tokio::spawn(async move {
1309 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1310 let result = match cli.run_action(&arg_refs).await {
1311 Ok(()) => Ok(format!("✓ {label} — done")),
1312 Err(e) => Err(format!("✗ {e:#}")),
1313 };
1314 let _ = tx.send(result);
1315 });
1316 }
1317
1318 pub fn request_secret_delete(&mut self) {
1320 if self.focus != Panel::Secrets {
1321 return;
1322 }
1323 let Some(item) = self.selected_item() else {
1324 return;
1325 };
1326 let name = item.name.clone();
1327 let (message, args) = match &self.secret_scope {
1328 None => (
1329 format!("Delete scope “{name}” and all its secrets?"),
1330 vec!["secrets".to_string(), "delete-scope".to_string(), name],
1331 ),
1332 Some(scope) => (
1333 format!("Delete secret “{name}” from “{scope}”?"),
1334 vec![
1335 "secrets".to_string(),
1336 "delete-secret".to_string(),
1337 scope.clone(),
1338 name,
1339 ],
1340 ),
1341 };
1342 self.confirm = Some(Confirm { message, args });
1343 }
1344
1345 fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1347 let scope = match &self.secret_scope {
1348 Some(s) => Some(s.clone()),
1349 None => self.selected_item().map(|i| i.name.clone()),
1350 };
1351 let Some(scope) = scope else {
1352 return;
1353 };
1354 self.detail = Some(Detail {
1355 panel: Panel::Secrets,
1356 name: scope.clone(),
1357 id: scope.clone(),
1358 kind: None,
1359 section: "Access",
1360 data: None,
1361 show_raw: false,
1362 scroll: 0,
1363 });
1364 let (tx, rx) = oneshot::channel();
1365 self.detail_rx = Some(rx);
1366 let cli = Arc::clone(cli);
1367 tokio::spawn(async move {
1368 let acl_args = ["secrets", "list-acls", &scope];
1369 let data = match cli.run(&acl_args).await {
1370 Ok(json) => {
1371 let raw =
1372 serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1373 let acls = json
1375 .as_array()
1376 .cloned()
1377 .or_else(|| json["items"].as_array().cloned())
1378 .unwrap_or_default();
1379 let activity: Vec<(Status, String)> = acls
1380 .iter()
1381 .map(|a| {
1382 let principal = a["principal"].as_str().unwrap_or("?");
1383 let perm = a["permission"].as_str().unwrap_or("?");
1384 let status = if perm == "MANAGE" {
1385 Status::Success
1386 } else {
1387 Status::Unknown(String::new())
1388 };
1389 (status, format!("{principal} · {perm}"))
1390 })
1391 .collect();
1392 DetailData {
1393 summary: vec![("Scope".to_string(), scope.clone())],
1394 activity,
1395 raw,
1396 }
1397 }
1398 Err(e) => DetailData {
1399 summary: Vec::new(),
1400 activity: Vec::new(),
1401 raw: format!("{e:#}"),
1402 },
1403 };
1404 let _ = tx.send(data);
1405 });
1406 }
1407
1408 pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1411 let idx = match kind {
1412 "cluster" => 0,
1413 "job" => 1,
1414 "warehouse" => 3,
1415 _ => return None,
1416 };
1417 match &self.shapes[idx] {
1418 Some(Shape::List(items)) => items
1419 .iter()
1420 .find(|i| i.id.as_deref() == Some(id))
1421 .map(|i| i.name.clone()),
1422 _ => None,
1423 }
1424 }
1425
1426 pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1428 let Some(Shape::List(items)) = &self.shapes[3] else {
1429 return Vec::new();
1430 };
1431 items
1432 .iter()
1433 .filter_map(|i| {
1434 let id = i.id.clone()?;
1435 Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1436 })
1437 .collect()
1438 }
1439
1440 pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1444 if self.focus != Panel::Catalog {
1445 return;
1446 }
1447 let Some(item) = self.selected_item() else {
1448 return;
1449 };
1450 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1451 return;
1452 }
1453 let Some(full_name) = item.id.clone() else {
1454 return;
1455 };
1456 let warehouses = self.warehouses();
1457 if warehouses.is_empty() {
1458 self.flash = Some((
1459 "✗ no SQL warehouse available for previews".to_string(),
1460 Instant::now(),
1461 ));
1462 return;
1463 }
1464 if !force_pick {
1465 if let Some((id, name)) = self.preview_warehouse.clone() {
1466 self.start_preview_query(cli, full_name, id, name);
1467 return;
1468 }
1469 if let [(name, id, _)] = warehouses.as_slice() {
1470 self.preview_warehouse = Some((id.clone(), name.clone()));
1471 self.start_preview_query(cli, full_name, id.clone(), name.clone());
1472 return;
1473 }
1474 }
1475 let index = self
1477 .preview_warehouse
1478 .as_ref()
1479 .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1480 .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1481 .unwrap_or(0);
1482 self.wh_picker = Some(WhPicker {
1483 index,
1484 target: PickTarget::Preview(full_name),
1485 });
1486 }
1487
1488 pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1490 let warehouses = self.warehouses();
1491 if warehouses.is_empty() {
1492 self.flash = Some((
1493 "✗ no SQL warehouse available to query system tables".to_string(),
1494 Instant::now(),
1495 ));
1496 return;
1497 }
1498 if let Some((id, name)) = self.preview_warehouse.clone() {
1499 self.start_cost_query(cli, id, name);
1500 return;
1501 }
1502 if let [(name, id, _)] = warehouses.as_slice() {
1503 self.preview_warehouse = Some((id.clone(), name.clone()));
1504 self.start_cost_query(cli, id.clone(), name.clone());
1505 return;
1506 }
1507 let index = warehouses
1508 .iter()
1509 .position(|(_, _, running)| *running)
1510 .unwrap_or(0);
1511 self.wh_picker = Some(WhPicker {
1512 index,
1513 target: PickTarget::Cost,
1514 });
1515 }
1516
1517 fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1518 self.cost = Some(CostView {
1519 warehouse: name,
1520 data: None,
1521 });
1522 let (tx, rx) = oneshot::channel();
1523 self.cost_rx = Some(rx);
1524 let cli = Arc::clone(cli);
1525 let host = self.host.clone();
1526 let cached_ws = self.workspace_id.clone();
1527 tokio::spawn(async move {
1528 let ws = match (cached_ws, host) {
1530 (Some(w), _) => Some(w),
1531 (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1532 (None, None) => None,
1533 };
1534 let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1535 let _ = tx.send((result, ws));
1536 });
1537 }
1538
1539 pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1542 if self.focus != Panel::Catalog {
1543 return;
1544 }
1545 let Some(item) = self.selected_item() else {
1546 return;
1547 };
1548 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1549 return;
1550 }
1551 let Some(full_name) = item.id.clone() else {
1552 return;
1553 };
1554 let warehouses = self.warehouses();
1555 if warehouses.is_empty() {
1556 self.flash = Some((
1557 "✗ no SQL warehouse available to query lineage".to_string(),
1558 Instant::now(),
1559 ));
1560 return;
1561 }
1562 if let Some((id, _)) = self.preview_warehouse.clone() {
1563 self.start_lineage_query(cli, full_name, id);
1564 return;
1565 }
1566 if let [(name, id, _)] = warehouses.as_slice() {
1567 self.preview_warehouse = Some((id.clone(), name.clone()));
1568 let id = id.clone();
1569 self.start_lineage_query(cli, full_name, id);
1570 return;
1571 }
1572 let index = warehouses
1573 .iter()
1574 .position(|(_, _, running)| *running)
1575 .unwrap_or(0);
1576 self.wh_picker = Some(WhPicker {
1577 index,
1578 target: PickTarget::Lineage(full_name),
1579 });
1580 }
1581
1582 fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1583 self.detail = Some(Detail {
1584 panel: Panel::Catalog,
1585 name: full_name.clone(),
1586 id: full_name.clone(),
1587 kind: None,
1588 section: "Lineage",
1589 data: None,
1590 show_raw: false,
1591 scroll: 0,
1592 });
1593 let (tx, rx) = oneshot::channel();
1594 self.detail_rx = Some(rx);
1595 let cli = Arc::clone(cli);
1596 tokio::spawn(async move {
1597 let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1598 let _ = tx.send(data);
1599 });
1600 }
1601
1602 pub fn close_cost(&mut self) {
1603 self.cost = None;
1604 self.cost_rx = None;
1605 }
1606
1607 fn selected_table_fqn(&self) -> Option<String> {
1609 if self.focus != Panel::Catalog {
1610 return None;
1611 }
1612 let item = self.selected_item()?;
1613 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1614 return None;
1615 }
1616 item.id.clone()
1617 }
1618
1619 pub fn open_sql(&mut self) {
1622 if self.sql.is_none() {
1623 let input = self
1624 .selected_table_fqn()
1625 .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1626 .unwrap_or_default();
1627 self.sql = Some(SqlConsole {
1628 cursor: input.chars().count(),
1629 input,
1630 warehouse: String::new(),
1631 running: false,
1632 data: None,
1633 last_sql: String::new(),
1634 scroll: 0,
1635 col: 0,
1636 });
1637 }
1638 }
1639
1640 pub fn close_sql(&mut self) {
1641 self.sql = None;
1642 self.sql_rx = None;
1643 self.hist_idx = None;
1644 self.hist_draft.clear();
1645 self.hist_search = None;
1646 }
1647
1648 pub fn sql_input(&self) -> Option<String> {
1650 self.sql.as_ref().map(|c| c.input.clone())
1651 }
1652
1653 pub fn sql_set_input(&mut self, s: &str) {
1655 if let Some(console) = &mut self.sql {
1656 console.input = s.to_string();
1657 console.cursor = console.input.chars().count();
1658 }
1659 }
1660
1661 pub fn hist_search_current(&self) -> Option<&String> {
1663 let (query, nth) = self.hist_search.as_ref()?;
1664 self.sql_history
1665 .iter()
1666 .rev()
1667 .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
1668 .nth(*nth)
1669 }
1670
1671 pub fn hist_search_start(&mut self) {
1672 if self.sql.is_some() {
1673 self.hist_search = Some((String::new(), 0));
1674 }
1675 }
1676
1677 pub fn hist_search_push(&mut self, c: char) {
1678 if let Some((query, nth)) = &mut self.hist_search {
1679 query.push(c);
1680 *nth = 0;
1681 }
1682 }
1683
1684 pub fn hist_search_pop(&mut self) {
1685 if let Some((query, nth)) = &mut self.hist_search {
1686 query.pop();
1687 *nth = 0;
1688 }
1689 }
1690
1691 pub fn hist_search_older(&mut self) {
1693 let Some((query, nth)) = &self.hist_search else {
1694 return;
1695 };
1696 let q = query.to_lowercase();
1697 let matches = self
1698 .sql_history
1699 .iter()
1700 .filter(|h| h.to_lowercase().contains(&q))
1701 .count();
1702 if nth + 1 < matches {
1703 if let Some((_, n)) = &mut self.hist_search {
1704 *n += 1;
1705 }
1706 }
1707 }
1708
1709 pub fn hist_search_accept(&mut self) {
1710 if let Some(stmt) = self.hist_search_current().cloned() {
1711 self.sql_set_input(&stmt);
1712 }
1713 self.hist_search = None;
1714 }
1715
1716 pub fn hist_search_cancel(&mut self) {
1717 self.hist_search = None;
1718 }
1719
1720 pub fn sql_push(&mut self, c: char) {
1721 if let Some(console) = &mut self.sql {
1722 let at = byte_at(&console.input, console.cursor);
1723 console.input.insert(at, c);
1724 console.cursor += 1;
1725 }
1726 }
1727
1728 pub fn sql_pop(&mut self) {
1730 if let Some(console) = &mut self.sql {
1731 if console.cursor > 0 {
1732 let at = byte_at(&console.input, console.cursor - 1);
1733 console.input.remove(at);
1734 console.cursor -= 1;
1735 }
1736 }
1737 }
1738
1739 pub fn sql_delete(&mut self) {
1741 if let Some(console) = &mut self.sql {
1742 if console.cursor < console.input.chars().count() {
1743 let at = byte_at(&console.input, console.cursor);
1744 console.input.remove(at);
1745 }
1746 }
1747 }
1748
1749 pub fn sql_left(&mut self) {
1750 if let Some(console) = &mut self.sql {
1751 console.cursor = console.cursor.saturating_sub(1);
1752 }
1753 }
1754
1755 pub fn sql_right(&mut self) {
1756 if let Some(console) = &mut self.sql {
1757 console.cursor = (console.cursor + 1).min(console.input.chars().count());
1758 }
1759 }
1760
1761 pub fn sql_hist_prev(&mut self) {
1763 let Some(console) = &mut self.sql else {
1764 return;
1765 };
1766 if self.sql_history.is_empty() {
1767 return;
1768 }
1769 let idx = match self.hist_idx {
1770 None => {
1771 self.hist_draft = console.input.clone();
1772 self.sql_history.len() - 1
1773 }
1774 Some(i) => i.saturating_sub(1),
1775 };
1776 self.hist_idx = Some(idx);
1777 console.input = self.sql_history[idx].clone();
1778 console.cursor = console.input.chars().count();
1779 }
1780
1781 pub fn sql_hist_next(&mut self) {
1783 let Some(console) = &mut self.sql else {
1784 return;
1785 };
1786 let Some(idx) = self.hist_idx else {
1787 return;
1788 };
1789 if idx + 1 < self.sql_history.len() {
1790 self.hist_idx = Some(idx + 1);
1791 console.input = self.sql_history[idx + 1].clone();
1792 } else {
1793 self.hist_idx = None;
1794 console.input = self.hist_draft.clone();
1795 }
1796 console.cursor = console.input.chars().count();
1797 }
1798
1799 pub fn sql_home(&mut self) {
1800 if let Some(console) = &mut self.sql {
1801 console.cursor = 0;
1802 }
1803 }
1804
1805 pub fn sql_end(&mut self) {
1806 if let Some(console) = &mut self.sql {
1807 console.cursor = console.input.chars().count();
1808 }
1809 }
1810
1811 pub fn sql_scroll(&mut self, delta: i32) {
1812 if let Some(console) = &mut self.sql {
1813 let max = match &console.data {
1814 Some(Ok(t)) => t.rows.len().saturating_sub(1),
1815 _ => 0,
1816 };
1817 console.scroll = if delta < 0 {
1818 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
1819 } else {
1820 (console.scroll + delta as usize).min(max)
1821 };
1822 }
1823 }
1824
1825 pub fn sql_cols(&mut self, delta: i32) {
1827 if let Some(console) = &mut self.sql {
1828 let n = match &console.data {
1829 Some(Ok(t)) => t.headers.len(),
1830 _ => 0,
1831 };
1832 console.col = if delta < 0 {
1833 console.col.saturating_sub(1)
1834 } else {
1835 (console.col + 1).min(n.saturating_sub(1))
1836 };
1837 }
1838 }
1839
1840 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
1842 let Some(console) = &self.sql else {
1843 return;
1844 };
1845 if console.running {
1846 return;
1847 }
1848 let query = console.input.trim().to_string();
1849 if query.is_empty() {
1850 return;
1851 }
1852 if self.sql_history.last() != Some(&query) {
1855 self.sql_history.push(query.clone());
1856 save_history(&self.sql_history);
1857 }
1858 self.hist_idx = None;
1859 self.hist_draft.clear();
1860 let warehouses = self.warehouses();
1861 if warehouses.is_empty() {
1862 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
1863 return;
1864 }
1865 if let Some((id, name)) = self.preview_warehouse.clone() {
1866 self.start_sql_query(cli, query, id, name);
1867 return;
1868 }
1869 if let [(name, id, _)] = warehouses.as_slice() {
1870 self.preview_warehouse = Some((id.clone(), name.clone()));
1871 self.start_sql_query(cli, query, id.clone(), name.clone());
1872 return;
1873 }
1874 let index = warehouses
1875 .iter()
1876 .position(|(_, _, running)| *running)
1877 .unwrap_or(0);
1878 self.wh_picker = Some(WhPicker {
1879 index,
1880 target: PickTarget::Sql(query),
1881 });
1882 }
1883
1884 fn start_sql_query(
1885 &mut self,
1886 cli: &Arc<DatabricksCli>,
1887 query: String,
1888 id: String,
1889 name: String,
1890 ) {
1891 if let Some(console) = &mut self.sql {
1892 console.running = true;
1893 console.warehouse = name;
1894 console.scroll = 0;
1895 console.last_sql = query.clone();
1896 }
1897 let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
1899 self.sql_stmt = Some(std::sync::Arc::clone(&handle));
1900 let (tx, rx) = oneshot::channel();
1901 self.sql_rx = Some(rx);
1902 let cli = Arc::clone(cli);
1903 tokio::spawn(async move {
1904 let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
1905 let _ = tx.send(result);
1906 });
1907 }
1908
1909 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
1912 let stamp = std::time::SystemTime::now()
1913 .duration_since(std::time::UNIX_EPOCH)
1914 .map(|d| d.as_secs())
1915 .unwrap_or(0);
1916 let slug: String = label
1917 .chars()
1918 .map(|c| if c.is_alphanumeric() { c } else { '-' })
1919 .collect::<String>()
1920 .trim_matches('-')
1921 .chars()
1922 .take(40)
1923 .collect();
1924 let name = format!("databricks-{slug}-{stamp}.csv");
1925 let msg = match std::fs::write(&name, data.to_csv()) {
1926 Ok(()) => {
1927 let cwd = std::env::current_dir()
1928 .map(|d| d.display().to_string())
1929 .unwrap_or_default();
1930 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
1931 }
1932 Err(e) => format!("✗ export failed: {e}"),
1933 };
1934 self.flash = Some((msg, Instant::now()));
1935 }
1936
1937 pub fn sql_export(&mut self) {
1939 if let Some(SqlConsole {
1940 data: Some(Ok(data)),
1941 last_sql,
1942 ..
1943 }) = &self.sql
1944 {
1945 let (label, data) = (last_sql.clone(), data.clone());
1946 self.export_csv(&label, &data);
1947 }
1948 }
1949
1950 pub fn preview_h(&mut self, delta: i32) {
1953 let Some(pv) = &mut self.preview else {
1954 return;
1955 };
1956 if pv.record {
1957 let max = match &pv.data {
1958 Some(Ok(t)) => t.rows.len().saturating_sub(1),
1959 _ => 0,
1960 };
1961 pv.scroll = if delta < 0 {
1962 pv.scroll.saturating_sub(1)
1963 } else {
1964 (pv.scroll + 1).min(max)
1965 };
1966 pv.rscroll = 0;
1967 } else {
1968 let cols = pv.visible_cols().len();
1969 pv.col = if delta < 0 {
1970 pv.col.saturating_sub(1)
1971 } else {
1972 (pv.col + 1).min(cols.saturating_sub(1))
1973 };
1974 }
1975 }
1976
1977 pub fn preview_toggle_record(&mut self) {
1979 if let Some(pv) = &mut self.preview {
1980 if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
1981 pv.record = !pv.record;
1982 pv.rscroll = 0;
1983 }
1984 }
1985 }
1986
1987 pub fn preview_filter_start(&mut self) {
1988 if let Some(pv) = &mut self.preview {
1989 pv.filter.clear();
1990 pv.filter_entry = true;
1991 pv.col = 0;
1992 pv.rscroll = 0;
1993 }
1994 }
1995
1996 pub fn preview_filter_push(&mut self, c: char) {
1997 if let Some(pv) = &mut self.preview {
1998 pv.filter.push(c);
1999 pv.col = 0;
2000 pv.rscroll = 0;
2001 }
2002 }
2003
2004 pub fn preview_filter_pop(&mut self) {
2005 if let Some(pv) = &mut self.preview {
2006 pv.filter.pop();
2007 pv.col = 0;
2008 }
2009 }
2010
2011 pub fn preview_filter_accept(&mut self) {
2012 if let Some(pv) = &mut self.preview {
2013 pv.filter_entry = false;
2014 }
2015 }
2016
2017 pub fn preview_filter_clear(&mut self) {
2018 if let Some(pv) = &mut self.preview {
2019 pv.filter.clear();
2020 pv.filter_entry = false;
2021 pv.col = 0;
2022 pv.rscroll = 0;
2023 }
2024 }
2025
2026 pub fn preview_export(&mut self) {
2028 if let Some(Preview {
2029 data: Some(Ok(data)),
2030 name,
2031 ..
2032 }) = &self.preview
2033 {
2034 let (label, data) = (name.clone(), data.clone());
2035 self.export_csv(&label, &data);
2036 }
2037 }
2038
2039 pub fn poll_sql(&mut self) -> bool {
2040 let Some(rx) = &mut self.sql_rx else {
2041 return false;
2042 };
2043 match rx.try_recv() {
2044 Ok(result) => {
2045 if let Err(e) = &result {
2048 if e != "statement canceled" {
2049 self.preview_warehouse = None;
2050 }
2051 }
2052 if let Some(console) = &mut self.sql {
2053 console.running = false;
2054 console.data = Some(result);
2055 console.col = 0;
2056 }
2057 self.sql_rx = None;
2058 self.sql_stmt = None;
2059 true
2060 }
2061 Err(oneshot::error::TryRecvError::Empty) => false,
2062 Err(oneshot::error::TryRecvError::Closed) => {
2063 if let Some(console) = &mut self.sql {
2064 console.running = false;
2065 }
2066 self.sql_rx = None;
2067 true
2068 }
2069 }
2070 }
2071
2072 pub fn poll_cost(&mut self) -> bool {
2073 let Some(rx) = &mut self.cost_rx else {
2074 return false;
2075 };
2076 match rx.try_recv() {
2077 Ok((result, ws)) => {
2078 if result.is_err() {
2079 self.preview_warehouse = None;
2080 }
2081 if ws.is_some() {
2082 self.workspace_id = ws;
2083 }
2084 if let Some(cv) = &mut self.cost {
2085 cv.data = Some(result);
2086 }
2087 self.cost_rx = None;
2088 true
2089 }
2090 Err(oneshot::error::TryRecvError::Empty) => false,
2091 Err(oneshot::error::TryRecvError::Closed) => {
2092 self.cost_rx = None;
2093 true
2094 }
2095 }
2096 }
2097
2098 pub fn wh_picker_next(&mut self) {
2099 let len = self.warehouses().len();
2100 if let Some(p) = &mut self.wh_picker {
2101 p.index = (p.index + 1).min(len.saturating_sub(1));
2102 }
2103 }
2104
2105 pub fn wh_picker_prev(&mut self) {
2106 if let Some(p) = &mut self.wh_picker {
2107 p.index = p.index.saturating_sub(1);
2108 }
2109 }
2110
2111 pub fn wh_picker_cancel(&mut self) {
2112 self.wh_picker = None;
2113 }
2114
2115 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
2117 let Some(picker) = self.wh_picker.take() else {
2118 return;
2119 };
2120 let warehouses = self.warehouses();
2121 let Some((name, id, _)) = warehouses.get(picker.index) else {
2122 return;
2123 };
2124 self.preview_warehouse = Some((id.clone(), name.clone()));
2125 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
2127 self.config
2128 .warehouses
2129 .insert(profile, (id.clone(), name.clone()));
2130 self.config.save();
2131 match picker.target {
2132 PickTarget::Preview(table) => {
2133 self.start_preview_query(cli, table, id.clone(), name.clone())
2134 }
2135 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
2136 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
2137 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
2138 }
2139 }
2140
2141 fn start_preview_query(
2142 &mut self,
2143 cli: &Arc<DatabricksCli>,
2144 full_name: String,
2145 warehouse_id: String,
2146 warehouse_name: String,
2147 ) {
2148 self.preview = Some(Preview {
2149 name: full_name.clone(),
2150 warehouse: warehouse_name,
2151 warehouse_id: warehouse_id.clone(),
2152 data: None,
2153 scroll: 0,
2154 col: 0,
2155 filter: String::new(),
2156 filter_entry: false,
2157 record: false,
2158 rscroll: 0,
2159 });
2160 let (tx, rx) = oneshot::channel();
2161 self.preview_rx = Some(rx);
2162 let cli = Arc::clone(cli);
2163 tokio::spawn(async move {
2164 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
2165 let _ = tx.send(result);
2166 });
2167 }
2168
2169 pub fn close_preview(&mut self) {
2170 self.preview = None;
2171 self.preview_rx = None;
2172 }
2173
2174 pub fn poll_preview(&mut self) -> bool {
2175 let Some(rx) = &mut self.preview_rx else {
2176 return false;
2177 };
2178 match rx.try_recv() {
2179 Ok(result) => {
2180 if result.is_err() {
2182 self.preview_warehouse = None;
2183 }
2184 if let Some(pv) = &mut self.preview {
2185 pv.data = Some(result);
2186 }
2187 self.preview_rx = None;
2188 true
2189 }
2190 Err(oneshot::error::TryRecvError::Empty) => false,
2191 Err(oneshot::error::TryRecvError::Closed) => {
2192 self.preview_rx = None;
2193 true
2194 }
2195 }
2196 }
2197
2198 pub fn preview_scroll(&mut self, delta: i32) {
2199 if let Some(pv) = &mut self.preview {
2200 if pv.record {
2202 let max = pv.visible_cols().len().saturating_sub(1) as u16;
2203 pv.rscroll = if delta < 0 {
2204 pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
2205 } else {
2206 pv.rscroll.saturating_add(delta as u16).min(max)
2207 };
2208 return;
2209 }
2210 let max = match &pv.data {
2211 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2212 _ => 0,
2213 };
2214 pv.scroll = if delta < 0 {
2215 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
2216 } else {
2217 (pv.scroll + delta as usize).min(max)
2218 };
2219 }
2220 }
2221
2222 pub fn poll_uc(&mut self) -> bool {
2223 let Some(rx) = &mut self.uc_rx else {
2224 return false;
2225 };
2226 match rx.try_recv() {
2227 Ok(result) => {
2228 self.shapes[5] = Some(match result {
2229 Ok(shape) => shape,
2230 Err(e) => Shape::Text(format!("✗ {e}")),
2231 });
2232 self.updated_at[5] = Some(Instant::now());
2233 self.uc_rx = None;
2234 true
2235 }
2236 Err(oneshot::error::TryRecvError::Empty) => false,
2237 Err(oneshot::error::TryRecvError::Closed) => {
2238 self.uc_rx = None;
2239 true
2240 }
2241 }
2242 }
2243
2244 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
2247 if self.focus == Panel::Secrets {
2248 return self.open_secret_acls(cli);
2249 }
2250 let Some(item) = self.selected_item() else {
2251 return;
2252 };
2253 let Some(id) = item.id.clone() else {
2254 return;
2255 };
2256 let (uc, object_type): (bool, &'static str) = match self.focus {
2257 Panel::Catalog => match &item.status {
2258 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
2259 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
2260 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
2261 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
2262 _ => return,
2263 },
2264 Panel::Clusters => (false, "clusters"),
2265 Panel::Jobs => (false, "jobs"),
2266 Panel::Pipelines => (false, "pipelines"),
2267 Panel::Warehouses => (false, "warehouses"),
2268 Panel::Dashboards => (false, "dashboards"),
2269 Panel::Secrets => return,
2271 };
2272 self.detail = Some(Detail {
2273 panel: self.focus,
2274 name: item.name.clone(),
2275 id: id.clone(),
2276 kind: None,
2277 section: "Access",
2278 data: None,
2279 show_raw: false,
2280 scroll: 0,
2281 });
2282 let (tx, rx) = oneshot::channel();
2283 self.detail_rx = Some(rx);
2284 let cli = Arc::clone(cli);
2285 tokio::spawn(async move {
2286 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
2287 let _ = tx.send(data);
2288 });
2289 }
2290
2291 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
2294 let Some(d) = &self.detail else {
2295 return;
2296 };
2297 let panel = d.panel;
2298 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
2299 return;
2300 }
2301 let owner_id = d.id.clone();
2302 self.run_view = Some(RunView {
2303 panel,
2304 owner_name: d.name.clone(),
2305 owner_id: owner_id.clone(),
2306 runs: Vec::new(),
2307 idx: 0,
2308 data: None,
2309 show_raw: false,
2310 scroll: 0,
2311 live: false,
2312 output: None,
2313 show_output: false,
2314 fetched_at: Instant::now(),
2315 });
2316 let (tx, rx) = oneshot::channel();
2317 self.run_rx = Some(rx);
2318 let cli = Arc::clone(cli);
2319 tokio::spawn(async move {
2320 let result = async {
2321 let runs = if panel == Panel::Jobs {
2322 fetchers::runs::list(&cli, &owner_id).await?
2323 } else {
2324 fetchers::updates::list(&cli, &owner_id).await?
2325 };
2326 let Some((run_id, _, _)) = runs.first().cloned() else {
2327 return Err("no runs recorded yet".to_string());
2328 };
2329 let (data, live) = if panel == Panel::Jobs {
2330 fetchers::runs::fetch(&cli, &run_id).await
2331 } else {
2332 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2333 };
2334 Ok((runs, data, live))
2335 }
2336 .await;
2337 let _ = tx.send(RunUpdate::Opened(result));
2338 });
2339 }
2340
2341 pub fn close_run(&mut self) {
2342 self.run_view = None;
2343 self.run_rx = None;
2344 }
2345
2346 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
2348 if self.run_rx.is_some() {
2349 return;
2350 }
2351 let Some(rv) = &mut self.run_view else {
2352 return;
2353 };
2354 if rv.runs.is_empty() {
2355 return;
2356 }
2357 let new = if delta < 0 {
2358 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
2359 } else {
2360 (rv.idx + delta as usize).min(rv.runs.len() - 1)
2361 };
2362 if new == rv.idx {
2363 return;
2364 }
2365 rv.idx = new;
2366 rv.data = None;
2367 rv.scroll = 0;
2368 rv.show_raw = false;
2369 rv.output = None;
2370 rv.show_output = false;
2371 let run_id = rv.runs[new].0.clone();
2372 self.start_run_fetch(cli, run_id);
2373 }
2374
2375 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
2376 let Some(rv) = &self.run_view else {
2377 return;
2378 };
2379 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
2380 let (tx, rx) = oneshot::channel();
2381 self.run_rx = Some(rx);
2382 let cli = Arc::clone(cli);
2383 tokio::spawn(async move {
2384 let (data, live) = if panel == Panel::Jobs {
2385 fetchers::runs::fetch(&cli, &run_id).await
2386 } else {
2387 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2388 };
2389 let _ = tx.send(RunUpdate::Detail(data, live));
2390 });
2391 }
2392
2393 pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
2396 let Some(rv) = &mut self.run_view else {
2397 return;
2398 };
2399 if rv.panel != Panel::Jobs {
2400 self.flash = Some((
2401 "✗ output view is for job runs — pipeline events are already inline".to_string(),
2402 Instant::now(),
2403 ));
2404 return;
2405 }
2406 if rv.show_output {
2407 rv.show_output = false;
2408 rv.scroll = 0;
2409 return;
2410 }
2411 if rv.output.is_none() && self.run_rx.is_some() {
2412 self.flash = Some((
2413 "⏳ run still loading — try again in a moment".to_string(),
2414 Instant::now(),
2415 ));
2416 return;
2417 }
2418 rv.show_output = true;
2419 rv.scroll = 0;
2420 if rv.output.is_some() {
2421 return;
2422 }
2423 let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
2424 return;
2425 };
2426 let (tx, rx) = oneshot::channel();
2427 self.run_rx = Some(rx);
2428 let cli = Arc::clone(cli);
2429 tokio::spawn(async move {
2430 let text = fetchers::runs::full_output(&cli, &run_id).await;
2431 let _ = tx.send(RunUpdate::Output(text));
2432 });
2433 }
2434
2435 pub fn request_run_repair(&mut self) {
2437 let Some(rv) = &self.run_view else {
2438 return;
2439 };
2440 if rv.panel != Panel::Jobs {
2441 self.flash = Some((
2442 "✗ repair applies to job runs only".to_string(),
2443 Instant::now(),
2444 ));
2445 return;
2446 }
2447 if rv.live {
2448 self.flash = Some((
2449 "✗ run is still executing — cancel it first (s)".to_string(),
2450 Instant::now(),
2451 ));
2452 return;
2453 }
2454 let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
2455 return;
2456 };
2457 if matches!(status, Status::Success) {
2458 self.flash = Some((
2459 "✗ run succeeded — nothing to repair".to_string(),
2460 Instant::now(),
2461 ));
2462 return;
2463 }
2464 self.confirm = Some(Confirm {
2465 message: format!(
2466 "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
2467 rv.owner_name
2468 ),
2469 args: vec![
2470 "jobs".to_string(),
2471 "repair-run".to_string(),
2472 run_id.clone(),
2473 "--rerun-all-failed-tasks".to_string(),
2474 ],
2475 });
2476 }
2477
2478 pub fn run_toggle_raw(&mut self) {
2479 if let Some(rv) = &mut self.run_view {
2480 rv.show_raw = !rv.show_raw;
2481 rv.scroll = 0;
2482 }
2483 }
2484
2485 pub fn run_scroll(&mut self, delta: i32) {
2486 if let Some(rv) = &mut self.run_view {
2487 rv.scroll = if delta < 0 {
2488 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
2489 } else {
2490 rv.scroll.saturating_add(delta as u16)
2491 };
2492 }
2493 }
2494
2495 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2498 if let Some(rx) = &mut self.run_rx {
2499 match rx.try_recv() {
2500 Ok(update) => {
2501 self.run_rx = None;
2502 if let Some(rv) = &mut self.run_view {
2503 match update {
2504 RunUpdate::Opened(Ok((runs, data, live))) => {
2505 rv.runs = runs;
2506 rv.idx = 0;
2507 rv.data = Some(data);
2508 rv.live = live;
2509 }
2510 RunUpdate::Opened(Err(e)) => {
2511 rv.data = Some(DetailData {
2512 summary: Vec::new(),
2513 activity: Vec::new(),
2514 raw: format!("✗ {e}"),
2515 });
2516 rv.live = false;
2517 }
2518 RunUpdate::Detail(data, live) => {
2519 rv.data = Some(data);
2520 rv.live = live;
2521 }
2522 RunUpdate::Output(text) => {
2523 rv.output = Some(text);
2524 }
2525 }
2526 rv.fetched_at = Instant::now();
2527 }
2528 true
2529 }
2530 Err(oneshot::error::TryRecvError::Empty) => false,
2531 Err(oneshot::error::TryRecvError::Closed) => {
2532 self.run_rx = None;
2533 true
2534 }
2535 }
2536 } else if let Some(rv) = &self.run_view {
2537 if rv.live
2539 && !rv.show_output
2540 && rv.data.is_some()
2541 && rv.fetched_at.elapsed() >= Duration::from_secs(5)
2542 {
2543 if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
2544 self.start_run_fetch(cli, run_id);
2545 }
2546 }
2547 false
2548 } else {
2549 false
2550 }
2551 }
2552
2553 pub fn close_detail(&mut self) {
2554 self.detail = None;
2555 self.detail_rx = None;
2556 }
2557
2558 pub fn toggle_raw(&mut self) {
2559 if let Some(d) = &mut self.detail {
2560 d.show_raw = !d.show_raw;
2561 d.scroll = 0;
2562 }
2563 }
2564
2565 pub fn poll_detail(&mut self) -> bool {
2567 let Some(rx) = &mut self.detail_rx else {
2568 return false;
2569 };
2570 match rx.try_recv() {
2571 Ok(data) => {
2572 if let Some(d) = &mut self.detail {
2573 d.data = Some(data);
2574 }
2575 self.detail_rx = None;
2576 true
2577 }
2578 Err(oneshot::error::TryRecvError::Empty) => false,
2579 Err(oneshot::error::TryRecvError::Closed) => {
2580 self.detail_rx = None;
2581 true
2582 }
2583 }
2584 }
2585
2586 pub fn detail_scroll(&mut self, delta: i32) {
2587 if let Some(d) = &mut self.detail {
2588 let max = match &d.data {
2589 Some(data) if d.show_raw => data.raw.lines().count(),
2590 Some(data) => data.summary.len() + data.activity.len() + 3,
2591 None => 0,
2592 } as u16;
2593 d.scroll = if delta < 0 {
2594 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
2595 } else {
2596 (d.scroll + delta as u16).min(max.saturating_sub(1))
2597 };
2598 }
2599 }
2600
2601 pub fn request_action(&mut self) {
2604 if matches!(
2606 self.focus,
2607 Panel::Dashboards | Panel::Catalog | Panel::Secrets
2608 ) {
2609 return;
2610 }
2611 let Some(item) = self.selected_item() else {
2612 return;
2613 };
2614 let Some(id) = item.id.clone() else {
2615 return;
2616 };
2617 let name = item.name.clone();
2618 let active = matches!(
2619 item.status,
2620 Status::Running | Status::Pending | Status::Success
2621 );
2622 let group = self.focus.cli_group();
2623 let (verb, action): (&str, &str) = match self.focus {
2624 Panel::Jobs => ("Run", "run-now"),
2625 Panel::Clusters if active => ("Stop", "delete"),
2626 Panel::Pipelines if active => ("Stop", "stop"),
2627 Panel::Pipelines => ("Start update for", "start-update"),
2628 _ if active => ("Stop", "stop"),
2629 _ => ("Start", "start"),
2630 };
2631 self.confirm = Some(Confirm {
2632 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
2633 args: vec![group.to_string(), action.to_string(), id],
2634 });
2635 }
2636
2637 pub fn request_run_cancel(&mut self) {
2639 let Some(rv) = &self.run_view else {
2640 return;
2641 };
2642 if !rv.live {
2643 self.flash = Some((
2644 "✗ nothing to cancel — this run already finished".to_string(),
2645 Instant::now(),
2646 ));
2647 return;
2648 }
2649 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
2650 return;
2651 };
2652 let (message, args) = if rv.panel == Panel::Jobs {
2653 (
2654 format!("Cancel run {run_id} of “{}”?", rv.owner_name),
2655 vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
2656 )
2657 } else {
2658 (
2659 format!("Stop “{}” (cancels the active update)?", rv.owner_name),
2660 vec![
2661 "pipelines".to_string(),
2662 "stop".to_string(),
2663 rv.owner_id.clone(),
2664 ],
2665 )
2666 };
2667 self.confirm = Some(Confirm { message, args });
2668 }
2669
2670 pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
2673 let id = self
2674 .sql_stmt
2675 .as_ref()
2676 .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
2677 let Some(id) = id else {
2678 self.flash = Some((
2679 "✗ statement not submitted yet — try again in a moment".to_string(),
2680 Instant::now(),
2681 ));
2682 return;
2683 };
2684 let cli = Arc::clone(cli);
2685 tokio::spawn(async move {
2686 let path = format!("/api/2.0/sql/statements/{id}/cancel");
2687 let _ = cli.run_action(&["api", "post", &path]).await;
2688 });
2689 self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
2690 }
2691
2692 pub fn cancel_confirm(&mut self) {
2693 self.confirm = None;
2694 }
2695
2696 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
2697 let Some(c) = self.confirm.take() else {
2698 return;
2699 };
2700 let base = c.message.trim_end_matches('?').to_string();
2701 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
2702
2703 let (tx, rx) = oneshot::channel();
2704 self.action_rx = Some(rx);
2705 let cli = Arc::clone(cli);
2706 tokio::spawn(async move {
2707 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
2708 let result = match cli.run_action(&args).await {
2709 Ok(()) => Ok(format!("✓ {base} — done")),
2710 Err(e) => Err(format!("✗ {e:#}")),
2711 };
2712 let _ = tx.send(result);
2713 });
2714 }
2715
2716 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2718 let Some(rx) = &mut self.action_rx else {
2719 return false;
2720 };
2721 match rx.try_recv() {
2722 Ok(result) => {
2723 let ok = result.is_ok();
2724 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
2725 self.action_rx = None;
2726 if ok {
2727 self.start_refresh(cli);
2728 if self.run_rx.is_none() {
2731 let current = self
2732 .run_view
2733 .as_ref()
2734 .and_then(|rv| rv.runs.get(rv.idx).cloned());
2735 if let Some((run_id, _, _)) = current {
2736 self.start_run_fetch(cli, run_id);
2737 }
2738 }
2739 }
2740 true
2741 }
2742 Err(oneshot::error::TryRecvError::Empty) => false,
2743 Err(oneshot::error::TryRecvError::Closed) => {
2744 self.action_rx = None;
2745 true
2746 }
2747 }
2748 }
2749
2750 pub fn expire_flash(&mut self) -> bool {
2752 if let Some((_, since)) = &self.flash {
2753 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
2754 self.flash = None;
2755 return true;
2756 }
2757 }
2758 false
2759 }
2760
2761 pub fn open_in_browser(&self) {
2763 let Some(host) = &self.host else {
2764 return;
2765 };
2766 let (panel, id) = match &self.detail {
2767 Some(d) => (d.panel, Some(d.id.clone())),
2768 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
2769 };
2770 let Some(id) = id else {
2771 return;
2772 };
2773 let path = match panel {
2774 Panel::Clusters => format!("compute/clusters/{id}"),
2775 Panel::Jobs => format!("jobs/{id}"),
2776 Panel::Pipelines => format!("pipelines/{id}"),
2777 Panel::Warehouses => format!("sql/warehouses/{id}"),
2778 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
2779 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
2780 Panel::Secrets => return,
2782 };
2783 let url = format!("{}/{}", host.trim_end_matches('/'), path);
2784 #[cfg(target_os = "macos")]
2785 let opener = "open";
2786 #[cfg(not(target_os = "macos"))]
2787 let opener = "xdg-open";
2788 let _ = std::process::Command::new(opener).arg(url).spawn();
2789 }
2790
2791 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
2793 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
2794 for shape in self.shapes.iter().flatten() {
2795 if let Shape::List(items) = shape {
2796 for item in items {
2797 match item.status {
2798 Status::Running | Status::Success => ok += 1,
2799 Status::Pending => pending += 1,
2800 Status::Failed => failed += 1,
2801 Status::Stopped => idle += 1,
2802 Status::Unknown(_) => {}
2803 }
2804 }
2805 }
2806 }
2807 (ok, pending, failed, idle)
2808 }
2809
2810 pub fn last_refresh_age(&self) -> Duration {
2811 self.last_refresh.elapsed()
2812 }
2813
2814 pub fn spinner(&self) -> &'static str {
2815 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
2816 }
2817
2818 pub fn spinner_frame(&self) -> usize {
2819 self.spinner_frame
2820 }
2821
2822 pub fn busy(&self) -> bool {
2825 self.loading
2826 || self.detail_rx.is_some()
2827 || self.action_rx.is_some()
2828 || self.preview_rx.is_some()
2829 || self.cost_rx.is_some()
2830 || self.sql_rx.is_some()
2831 || self.run_rx.is_some()
2832 }
2833
2834 pub fn tick_spinner(&mut self) {
2835 self.spinner_frame = self.spinner_frame.wrapping_add(1);
2836 }
2837
2838 pub fn toggle_zoom(&mut self) {
2839 self.zoomed = !self.zoomed;
2840 }
2841
2842 pub fn focus_next(&mut self) {
2843 self.cycle_focus(1);
2844 }
2845
2846 pub fn focus_prev(&mut self) {
2847 self.cycle_focus(-1);
2848 }
2849
2850 fn cycle_focus(&mut self, delta: i32) {
2852 let visible = self.visible_panes();
2853 if visible.is_empty() {
2854 return;
2855 }
2856 let focus_idx = Panel::ALL
2857 .iter()
2858 .position(|p| p == &self.focus)
2859 .unwrap_or(0);
2860 let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
2861 let n = visible.len() as i32;
2862 let next = ((pos as i32 + delta) % n + n) % n;
2863 self.focus = Panel::ALL[visible[next as usize]];
2864 }
2865
2866 pub fn needs_refresh(&self) -> bool {
2867 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
2868 }
2869
2870 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
2871 if self.loading {
2872 return;
2873 }
2874 self.loading = true;
2875 self.error = None;
2876 self.last_refresh = Instant::now();
2877
2878 let (tx, rx) = mpsc::unbounded_channel();
2879 self.pending = Some(rx);
2880 self.in_flight = 8;
2881
2882 macro_rules! spawn_fetch {
2885 ($update:expr, $fetch:path) => {{
2886 let cli = Arc::clone(cli);
2887 let tx = tx.clone();
2888 tokio::spawn(async move {
2889 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
2890 let _ = tx.send($update(result));
2891 });
2892 }};
2893 }
2894
2895 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
2896 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
2897 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
2898 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
2899 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
2900 spawn_fetch!(
2901 |s: Result<Shape, String>| Update::Badge(s.ok()),
2902 fetchers::current_user::fetch
2903 );
2904 {
2905 let cli = Arc::clone(cli);
2906 let tx = tx.clone();
2907 let path = self.uc_path.clone();
2908 tokio::spawn(async move {
2909 let result = fetchers::catalog::fetch(&cli, &path)
2910 .await
2911 .map_err(|e| format!("{e:#}"));
2912 let _ = tx.send(Update::Panel(5, result));
2913 });
2914 }
2915 {
2916 let cli = Arc::clone(cli);
2917 let tx = tx.clone();
2918 let scope = self.secret_scope.clone();
2919 tokio::spawn(async move {
2920 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
2921 .await
2922 .map_err(|e| format!("{e:#}"));
2923 let _ = tx.send(Update::Panel(6, result));
2924 });
2925 }
2926 }
2927
2928 pub fn poll_refresh(&mut self) -> bool {
2930 let Some(rx) = &mut self.pending else {
2931 return false;
2932 };
2933 let mut changed = false;
2934 let mut updated_panes: Vec<usize> = Vec::new();
2935 loop {
2936 match rx.try_recv() {
2937 Ok(Update::Panel(i, result)) => {
2938 match result {
2939 Ok(mut shape) => {
2940 if i != 5 {
2944 if let Shape::List(items) = &mut shape {
2945 items.sort_by_key(|it| {
2946 (it.status.rank(), it.history.is_empty())
2947 });
2948 }
2949 }
2950 self.shapes[i] = Some(shape);
2951 self.updated_at[i] = Some(Instant::now());
2952 updated_panes.push(i);
2953 }
2954 Err(e) => {
2957 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
2958 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
2959 }
2960 }
2961 }
2962 self.in_flight -= 1;
2963 changed = true;
2964 }
2965 Ok(Update::Badge(badge)) => {
2966 if badge.is_some() {
2967 self.user_badge = badge;
2968 }
2969 self.in_flight -= 1;
2970 changed = true;
2971 }
2972 Err(mpsc::error::TryRecvError::Empty) => break,
2973 Err(mpsc::error::TryRecvError::Disconnected) => {
2974 self.in_flight = 0;
2975 break;
2976 }
2977 }
2978 }
2979 for i in updated_panes {
2980 self.alert_new_failures(i);
2981 }
2982 if self.in_flight == 0 {
2983 self.loading = false;
2984 self.pending = None;
2985 changed = true;
2986 }
2987 changed
2988 }
2989}