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