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 {
1130 if self.focus != Panel::Secrets || self.secret_scope.is_some() {
1131 return false;
1132 }
1133 let Some(item) = self.selected_item() else {
1134 return true; };
1136 self.secret_scope = Some(item.name.clone());
1137 self.refresh_secrets(cli);
1138 true
1139 }
1140
1141 pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1143 if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1144 return false;
1145 }
1146 self.secret_scope = None;
1147 self.refresh_secrets(cli);
1148 true
1149 }
1150
1151 fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1152 let idx = 6;
1153 self.shapes[idx] = None;
1154 self.selected[idx] = 0;
1155 self.filters[idx].clear();
1156 let (tx, rx) = oneshot::channel();
1157 self.secrets_rx = Some(rx);
1158 let cli = Arc::clone(cli);
1159 let scope = self.secret_scope.clone();
1160 tokio::spawn(async move {
1161 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1162 .await
1163 .map_err(|e| format!("{e:#}"));
1164 let _ = tx.send(result);
1165 });
1166 }
1167
1168 pub fn poll_secrets(&mut self) -> bool {
1169 let Some(rx) = &mut self.secrets_rx else {
1170 return false;
1171 };
1172 match rx.try_recv() {
1173 Ok(result) => {
1174 self.shapes[6] = Some(match result {
1175 Ok(shape) => shape,
1176 Err(e) => Shape::Text(format!("✗ {e}")),
1177 });
1178 self.updated_at[6] = Some(Instant::now());
1179 self.secrets_rx = None;
1180 true
1181 }
1182 Err(oneshot::error::TryRecvError::Empty) => false,
1183 Err(oneshot::error::TryRecvError::Closed) => {
1184 self.secrets_rx = None;
1185 true
1186 }
1187 }
1188 }
1189
1190 pub fn open_secret_form(&mut self) {
1193 if self.focus != Panel::Secrets {
1194 return;
1195 }
1196 self.secret_form = Some(SecretForm {
1197 scope: self.secret_scope.clone(),
1198 key: String::new(),
1199 value: String::new(),
1200 stage: 0,
1201 });
1202 }
1203
1204 pub fn secret_form_push(&mut self, c: char) {
1205 if let Some(form) = &mut self.secret_form {
1206 if form.stage == 0 {
1207 form.key.push(c);
1208 } else {
1209 form.value.push(c);
1210 }
1211 }
1212 }
1213
1214 pub fn secret_form_pop(&mut self) {
1215 if let Some(form) = &mut self.secret_form {
1216 if form.stage == 0 {
1217 form.key.pop();
1218 } else {
1219 form.value.pop();
1220 }
1221 }
1222 }
1223
1224 pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1226 let Some(form) = &mut self.secret_form else {
1227 return;
1228 };
1229 if form.key.trim().is_empty() {
1230 return;
1231 }
1232 match (form.scope.clone(), form.stage) {
1233 (None, _) => {
1235 let name = form.key.trim().to_string();
1236 self.secret_form = None;
1237 self.run_secret_action(
1238 cli,
1239 format!("Create scope “{name}”"),
1240 vec!["secrets".into(), "create-scope".into(), name],
1241 );
1242 }
1243 (Some(_), 0) => form.stage = 1,
1245 (Some(scope), _) => {
1246 let key = form.key.trim().to_string();
1247 let value = form.value.clone();
1248 self.secret_form = None;
1249 self.run_secret_action(
1250 cli,
1251 format!("Put secret “{key}” in “{scope}”"),
1252 vec![
1253 "secrets".into(),
1254 "put-secret".into(),
1255 scope,
1256 key,
1257 "--string-value".into(),
1258 value,
1259 ],
1260 );
1261 }
1262 }
1263 }
1264
1265 fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1268 self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1269 let (tx, rx) = oneshot::channel();
1270 self.action_rx = Some(rx);
1271 let cli = Arc::clone(cli);
1272 tokio::spawn(async move {
1273 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1274 let result = match cli.run_action(&arg_refs).await {
1275 Ok(()) => Ok(format!("✓ {label} — done")),
1276 Err(e) => Err(format!("✗ {e:#}")),
1277 };
1278 let _ = tx.send(result);
1279 });
1280 }
1281
1282 pub fn request_secret_delete(&mut self) {
1284 if self.focus != Panel::Secrets {
1285 return;
1286 }
1287 let Some(item) = self.selected_item() else {
1288 return;
1289 };
1290 let name = item.name.clone();
1291 let (message, args) = match &self.secret_scope {
1292 None => (
1293 format!("Delete scope “{name}” and all its secrets?"),
1294 vec!["secrets".to_string(), "delete-scope".to_string(), name],
1295 ),
1296 Some(scope) => (
1297 format!("Delete secret “{name}” from “{scope}”?"),
1298 vec![
1299 "secrets".to_string(),
1300 "delete-secret".to_string(),
1301 scope.clone(),
1302 name,
1303 ],
1304 ),
1305 };
1306 self.confirm = Some(Confirm { message, args });
1307 }
1308
1309 fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1311 let scope = match &self.secret_scope {
1312 Some(s) => Some(s.clone()),
1313 None => self.selected_item().map(|i| i.name.clone()),
1314 };
1315 let Some(scope) = scope else {
1316 return;
1317 };
1318 self.detail = Some(Detail {
1319 panel: Panel::Secrets,
1320 name: scope.clone(),
1321 id: scope.clone(),
1322 kind: None,
1323 section: "Access",
1324 data: None,
1325 show_raw: false,
1326 scroll: 0,
1327 });
1328 let (tx, rx) = oneshot::channel();
1329 self.detail_rx = Some(rx);
1330 let cli = Arc::clone(cli);
1331 tokio::spawn(async move {
1332 let acl_args = ["secrets", "list-acls", &scope];
1333 let data = match cli.run(&acl_args).await {
1334 Ok(json) => {
1335 let raw =
1336 serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1337 let acls = json
1339 .as_array()
1340 .cloned()
1341 .or_else(|| json["items"].as_array().cloned())
1342 .unwrap_or_default();
1343 let activity: Vec<(Status, String)> = acls
1344 .iter()
1345 .map(|a| {
1346 let principal = a["principal"].as_str().unwrap_or("?");
1347 let perm = a["permission"].as_str().unwrap_or("?");
1348 let status = if perm == "MANAGE" {
1349 Status::Success
1350 } else {
1351 Status::Unknown(String::new())
1352 };
1353 (status, format!("{principal} · {perm}"))
1354 })
1355 .collect();
1356 DetailData {
1357 summary: vec![("Scope".to_string(), scope.clone())],
1358 activity,
1359 raw,
1360 }
1361 }
1362 Err(e) => DetailData {
1363 summary: Vec::new(),
1364 activity: Vec::new(),
1365 raw: format!("{e:#}"),
1366 },
1367 };
1368 let _ = tx.send(data);
1369 });
1370 }
1371
1372 pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1375 let idx = match kind {
1376 "cluster" => 0,
1377 "job" => 1,
1378 "warehouse" => 3,
1379 _ => return None,
1380 };
1381 match &self.shapes[idx] {
1382 Some(Shape::List(items)) => items
1383 .iter()
1384 .find(|i| i.id.as_deref() == Some(id))
1385 .map(|i| i.name.clone()),
1386 _ => None,
1387 }
1388 }
1389
1390 pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1392 let Some(Shape::List(items)) = &self.shapes[3] else {
1393 return Vec::new();
1394 };
1395 items
1396 .iter()
1397 .filter_map(|i| {
1398 let id = i.id.clone()?;
1399 Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1400 })
1401 .collect()
1402 }
1403
1404 pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1408 if self.focus != Panel::Catalog {
1409 return;
1410 }
1411 let Some(item) = self.selected_item() else {
1412 return;
1413 };
1414 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1415 return;
1416 }
1417 let Some(full_name) = item.id.clone() else {
1418 return;
1419 };
1420 let warehouses = self.warehouses();
1421 if warehouses.is_empty() {
1422 self.flash = Some((
1423 "✗ no SQL warehouse available for previews".to_string(),
1424 Instant::now(),
1425 ));
1426 return;
1427 }
1428 if !force_pick {
1429 if let Some((id, name)) = self.preview_warehouse.clone() {
1430 self.start_preview_query(cli, full_name, id, name);
1431 return;
1432 }
1433 if let [(name, id, _)] = warehouses.as_slice() {
1434 self.preview_warehouse = Some((id.clone(), name.clone()));
1435 self.start_preview_query(cli, full_name, id.clone(), name.clone());
1436 return;
1437 }
1438 }
1439 let index = self
1441 .preview_warehouse
1442 .as_ref()
1443 .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1444 .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1445 .unwrap_or(0);
1446 self.wh_picker = Some(WhPicker {
1447 index,
1448 target: PickTarget::Preview(full_name),
1449 });
1450 }
1451
1452 pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1454 let warehouses = self.warehouses();
1455 if warehouses.is_empty() {
1456 self.flash = Some((
1457 "✗ no SQL warehouse available to query system tables".to_string(),
1458 Instant::now(),
1459 ));
1460 return;
1461 }
1462 if let Some((id, name)) = self.preview_warehouse.clone() {
1463 self.start_cost_query(cli, id, name);
1464 return;
1465 }
1466 if let [(name, id, _)] = warehouses.as_slice() {
1467 self.preview_warehouse = Some((id.clone(), name.clone()));
1468 self.start_cost_query(cli, id.clone(), name.clone());
1469 return;
1470 }
1471 let index = warehouses
1472 .iter()
1473 .position(|(_, _, running)| *running)
1474 .unwrap_or(0);
1475 self.wh_picker = Some(WhPicker {
1476 index,
1477 target: PickTarget::Cost,
1478 });
1479 }
1480
1481 fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1482 self.cost = Some(CostView {
1483 warehouse: name,
1484 data: None,
1485 });
1486 let (tx, rx) = oneshot::channel();
1487 self.cost_rx = Some(rx);
1488 let cli = Arc::clone(cli);
1489 let host = self.host.clone();
1490 let cached_ws = self.workspace_id.clone();
1491 tokio::spawn(async move {
1492 let ws = match (cached_ws, host) {
1494 (Some(w), _) => Some(w),
1495 (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1496 (None, None) => None,
1497 };
1498 let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1499 let _ = tx.send((result, ws));
1500 });
1501 }
1502
1503 pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1506 if self.focus != Panel::Catalog {
1507 return;
1508 }
1509 let Some(item) = self.selected_item() else {
1510 return;
1511 };
1512 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1513 return;
1514 }
1515 let Some(full_name) = item.id.clone() else {
1516 return;
1517 };
1518 let warehouses = self.warehouses();
1519 if warehouses.is_empty() {
1520 self.flash = Some((
1521 "✗ no SQL warehouse available to query lineage".to_string(),
1522 Instant::now(),
1523 ));
1524 return;
1525 }
1526 if let Some((id, _)) = self.preview_warehouse.clone() {
1527 self.start_lineage_query(cli, full_name, id);
1528 return;
1529 }
1530 if let [(name, id, _)] = warehouses.as_slice() {
1531 self.preview_warehouse = Some((id.clone(), name.clone()));
1532 let id = id.clone();
1533 self.start_lineage_query(cli, full_name, id);
1534 return;
1535 }
1536 let index = warehouses
1537 .iter()
1538 .position(|(_, _, running)| *running)
1539 .unwrap_or(0);
1540 self.wh_picker = Some(WhPicker {
1541 index,
1542 target: PickTarget::Lineage(full_name),
1543 });
1544 }
1545
1546 fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1547 self.detail = Some(Detail {
1548 panel: Panel::Catalog,
1549 name: full_name.clone(),
1550 id: full_name.clone(),
1551 kind: None,
1552 section: "Lineage",
1553 data: None,
1554 show_raw: false,
1555 scroll: 0,
1556 });
1557 let (tx, rx) = oneshot::channel();
1558 self.detail_rx = Some(rx);
1559 let cli = Arc::clone(cli);
1560 tokio::spawn(async move {
1561 let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1562 let _ = tx.send(data);
1563 });
1564 }
1565
1566 pub fn close_cost(&mut self) {
1567 self.cost = None;
1568 self.cost_rx = None;
1569 }
1570
1571 fn selected_table_fqn(&self) -> Option<String> {
1573 if self.focus != Panel::Catalog {
1574 return None;
1575 }
1576 let item = self.selected_item()?;
1577 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1578 return None;
1579 }
1580 item.id.clone()
1581 }
1582
1583 pub fn open_sql(&mut self) {
1586 if self.sql.is_none() {
1587 let input = self
1588 .selected_table_fqn()
1589 .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1590 .unwrap_or_default();
1591 self.sql = Some(SqlConsole {
1592 cursor: input.chars().count(),
1593 input,
1594 warehouse: String::new(),
1595 running: false,
1596 data: None,
1597 last_sql: String::new(),
1598 scroll: 0,
1599 });
1600 }
1601 }
1602
1603 pub fn close_sql(&mut self) {
1604 self.sql = None;
1605 self.sql_rx = None;
1606 self.hist_idx = None;
1607 self.hist_draft.clear();
1608 self.hist_search = None;
1609 }
1610
1611 pub fn sql_input(&self) -> Option<String> {
1613 self.sql.as_ref().map(|c| c.input.clone())
1614 }
1615
1616 pub fn sql_set_input(&mut self, s: &str) {
1618 if let Some(console) = &mut self.sql {
1619 console.input = s.to_string();
1620 console.cursor = console.input.chars().count();
1621 }
1622 }
1623
1624 pub fn hist_search_current(&self) -> Option<&String> {
1626 let (query, nth) = self.hist_search.as_ref()?;
1627 self.sql_history
1628 .iter()
1629 .rev()
1630 .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
1631 .nth(*nth)
1632 }
1633
1634 pub fn hist_search_start(&mut self) {
1635 if self.sql.is_some() {
1636 self.hist_search = Some((String::new(), 0));
1637 }
1638 }
1639
1640 pub fn hist_search_push(&mut self, c: char) {
1641 if let Some((query, nth)) = &mut self.hist_search {
1642 query.push(c);
1643 *nth = 0;
1644 }
1645 }
1646
1647 pub fn hist_search_pop(&mut self) {
1648 if let Some((query, nth)) = &mut self.hist_search {
1649 query.pop();
1650 *nth = 0;
1651 }
1652 }
1653
1654 pub fn hist_search_older(&mut self) {
1656 let Some((query, nth)) = &self.hist_search else {
1657 return;
1658 };
1659 let q = query.to_lowercase();
1660 let matches = self
1661 .sql_history
1662 .iter()
1663 .filter(|h| h.to_lowercase().contains(&q))
1664 .count();
1665 if nth + 1 < matches {
1666 if let Some((_, n)) = &mut self.hist_search {
1667 *n += 1;
1668 }
1669 }
1670 }
1671
1672 pub fn hist_search_accept(&mut self) {
1673 if let Some(stmt) = self.hist_search_current().cloned() {
1674 self.sql_set_input(&stmt);
1675 }
1676 self.hist_search = None;
1677 }
1678
1679 pub fn hist_search_cancel(&mut self) {
1680 self.hist_search = None;
1681 }
1682
1683 pub fn sql_push(&mut self, c: char) {
1684 if let Some(console) = &mut self.sql {
1685 let at = byte_at(&console.input, console.cursor);
1686 console.input.insert(at, c);
1687 console.cursor += 1;
1688 }
1689 }
1690
1691 pub fn sql_pop(&mut self) {
1693 if let Some(console) = &mut self.sql {
1694 if console.cursor > 0 {
1695 let at = byte_at(&console.input, console.cursor - 1);
1696 console.input.remove(at);
1697 console.cursor -= 1;
1698 }
1699 }
1700 }
1701
1702 pub fn sql_delete(&mut self) {
1704 if let Some(console) = &mut self.sql {
1705 if console.cursor < console.input.chars().count() {
1706 let at = byte_at(&console.input, console.cursor);
1707 console.input.remove(at);
1708 }
1709 }
1710 }
1711
1712 pub fn sql_left(&mut self) {
1713 if let Some(console) = &mut self.sql {
1714 console.cursor = console.cursor.saturating_sub(1);
1715 }
1716 }
1717
1718 pub fn sql_right(&mut self) {
1719 if let Some(console) = &mut self.sql {
1720 console.cursor = (console.cursor + 1).min(console.input.chars().count());
1721 }
1722 }
1723
1724 pub fn sql_hist_prev(&mut self) {
1726 let Some(console) = &mut self.sql else {
1727 return;
1728 };
1729 if self.sql_history.is_empty() {
1730 return;
1731 }
1732 let idx = match self.hist_idx {
1733 None => {
1734 self.hist_draft = console.input.clone();
1735 self.sql_history.len() - 1
1736 }
1737 Some(i) => i.saturating_sub(1),
1738 };
1739 self.hist_idx = Some(idx);
1740 console.input = self.sql_history[idx].clone();
1741 console.cursor = console.input.chars().count();
1742 }
1743
1744 pub fn sql_hist_next(&mut self) {
1746 let Some(console) = &mut self.sql else {
1747 return;
1748 };
1749 let Some(idx) = self.hist_idx else {
1750 return;
1751 };
1752 if idx + 1 < self.sql_history.len() {
1753 self.hist_idx = Some(idx + 1);
1754 console.input = self.sql_history[idx + 1].clone();
1755 } else {
1756 self.hist_idx = None;
1757 console.input = self.hist_draft.clone();
1758 }
1759 console.cursor = console.input.chars().count();
1760 }
1761
1762 pub fn sql_home(&mut self) {
1763 if let Some(console) = &mut self.sql {
1764 console.cursor = 0;
1765 }
1766 }
1767
1768 pub fn sql_end(&mut self) {
1769 if let Some(console) = &mut self.sql {
1770 console.cursor = console.input.chars().count();
1771 }
1772 }
1773
1774 pub fn sql_scroll(&mut self, delta: i32) {
1775 if let Some(console) = &mut self.sql {
1776 let max = match &console.data {
1777 Some(Ok(t)) => t.rows.len().saturating_sub(1),
1778 _ => 0,
1779 };
1780 console.scroll = if delta < 0 {
1781 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
1782 } else {
1783 (console.scroll + delta as usize).min(max)
1784 };
1785 }
1786 }
1787
1788 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
1790 let Some(console) = &self.sql else {
1791 return;
1792 };
1793 if console.running {
1794 return;
1795 }
1796 let query = console.input.trim().to_string();
1797 if query.is_empty() {
1798 return;
1799 }
1800 if self.sql_history.last() != Some(&query) {
1803 self.sql_history.push(query.clone());
1804 save_history(&self.sql_history);
1805 }
1806 self.hist_idx = None;
1807 self.hist_draft.clear();
1808 let warehouses = self.warehouses();
1809 if warehouses.is_empty() {
1810 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
1811 return;
1812 }
1813 if let Some((id, name)) = self.preview_warehouse.clone() {
1814 self.start_sql_query(cli, query, id, name);
1815 return;
1816 }
1817 if let [(name, id, _)] = warehouses.as_slice() {
1818 self.preview_warehouse = Some((id.clone(), name.clone()));
1819 self.start_sql_query(cli, query, id.clone(), name.clone());
1820 return;
1821 }
1822 let index = warehouses
1823 .iter()
1824 .position(|(_, _, running)| *running)
1825 .unwrap_or(0);
1826 self.wh_picker = Some(WhPicker {
1827 index,
1828 target: PickTarget::Sql(query),
1829 });
1830 }
1831
1832 fn start_sql_query(
1833 &mut self,
1834 cli: &Arc<DatabricksCli>,
1835 query: String,
1836 id: String,
1837 name: String,
1838 ) {
1839 if let Some(console) = &mut self.sql {
1840 console.running = true;
1841 console.warehouse = name;
1842 console.scroll = 0;
1843 console.last_sql = query.clone();
1844 }
1845 let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
1847 self.sql_stmt = Some(std::sync::Arc::clone(&handle));
1848 let (tx, rx) = oneshot::channel();
1849 self.sql_rx = Some(rx);
1850 let cli = Arc::clone(cli);
1851 tokio::spawn(async move {
1852 let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
1853 let _ = tx.send(result);
1854 });
1855 }
1856
1857 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
1860 let stamp = std::time::SystemTime::now()
1861 .duration_since(std::time::UNIX_EPOCH)
1862 .map(|d| d.as_secs())
1863 .unwrap_or(0);
1864 let slug: String = label
1865 .chars()
1866 .map(|c| if c.is_alphanumeric() { c } else { '-' })
1867 .collect::<String>()
1868 .trim_matches('-')
1869 .chars()
1870 .take(40)
1871 .collect();
1872 let name = format!("databricks-{slug}-{stamp}.csv");
1873 let msg = match std::fs::write(&name, data.to_csv()) {
1874 Ok(()) => {
1875 let cwd = std::env::current_dir()
1876 .map(|d| d.display().to_string())
1877 .unwrap_or_default();
1878 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
1879 }
1880 Err(e) => format!("✗ export failed: {e}"),
1881 };
1882 self.flash = Some((msg, Instant::now()));
1883 }
1884
1885 pub fn sql_export(&mut self) {
1887 if let Some(SqlConsole {
1888 data: Some(Ok(data)),
1889 last_sql,
1890 ..
1891 }) = &self.sql
1892 {
1893 let (label, data) = (last_sql.clone(), data.clone());
1894 self.export_csv(&label, &data);
1895 }
1896 }
1897
1898 pub fn preview_export(&mut self) {
1900 if let Some(Preview {
1901 data: Some(Ok(data)),
1902 name,
1903 ..
1904 }) = &self.preview
1905 {
1906 let (label, data) = (name.clone(), data.clone());
1907 self.export_csv(&label, &data);
1908 }
1909 }
1910
1911 pub fn poll_sql(&mut self) -> bool {
1912 let Some(rx) = &mut self.sql_rx else {
1913 return false;
1914 };
1915 match rx.try_recv() {
1916 Ok(result) => {
1917 if let Err(e) = &result {
1920 if e != "statement canceled" {
1921 self.preview_warehouse = None;
1922 }
1923 }
1924 if let Some(console) = &mut self.sql {
1925 console.running = false;
1926 console.data = Some(result);
1927 }
1928 self.sql_rx = None;
1929 self.sql_stmt = None;
1930 true
1931 }
1932 Err(oneshot::error::TryRecvError::Empty) => false,
1933 Err(oneshot::error::TryRecvError::Closed) => {
1934 if let Some(console) = &mut self.sql {
1935 console.running = false;
1936 }
1937 self.sql_rx = None;
1938 true
1939 }
1940 }
1941 }
1942
1943 pub fn poll_cost(&mut self) -> bool {
1944 let Some(rx) = &mut self.cost_rx else {
1945 return false;
1946 };
1947 match rx.try_recv() {
1948 Ok((result, ws)) => {
1949 if result.is_err() {
1950 self.preview_warehouse = None;
1951 }
1952 if ws.is_some() {
1953 self.workspace_id = ws;
1954 }
1955 if let Some(cv) = &mut self.cost {
1956 cv.data = Some(result);
1957 }
1958 self.cost_rx = None;
1959 true
1960 }
1961 Err(oneshot::error::TryRecvError::Empty) => false,
1962 Err(oneshot::error::TryRecvError::Closed) => {
1963 self.cost_rx = None;
1964 true
1965 }
1966 }
1967 }
1968
1969 pub fn wh_picker_next(&mut self) {
1970 let len = self.warehouses().len();
1971 if let Some(p) = &mut self.wh_picker {
1972 p.index = (p.index + 1).min(len.saturating_sub(1));
1973 }
1974 }
1975
1976 pub fn wh_picker_prev(&mut self) {
1977 if let Some(p) = &mut self.wh_picker {
1978 p.index = p.index.saturating_sub(1);
1979 }
1980 }
1981
1982 pub fn wh_picker_cancel(&mut self) {
1983 self.wh_picker = None;
1984 }
1985
1986 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
1988 let Some(picker) = self.wh_picker.take() else {
1989 return;
1990 };
1991 let warehouses = self.warehouses();
1992 let Some((name, id, _)) = warehouses.get(picker.index) else {
1993 return;
1994 };
1995 self.preview_warehouse = Some((id.clone(), name.clone()));
1996 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
1998 self.config
1999 .warehouses
2000 .insert(profile, (id.clone(), name.clone()));
2001 self.config.save();
2002 match picker.target {
2003 PickTarget::Preview(table) => {
2004 self.start_preview_query(cli, table, id.clone(), name.clone())
2005 }
2006 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
2007 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
2008 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
2009 }
2010 }
2011
2012 fn start_preview_query(
2013 &mut self,
2014 cli: &Arc<DatabricksCli>,
2015 full_name: String,
2016 warehouse_id: String,
2017 warehouse_name: String,
2018 ) {
2019 self.preview = Some(Preview {
2020 name: full_name.clone(),
2021 warehouse: warehouse_name,
2022 warehouse_id: warehouse_id.clone(),
2023 data: None,
2024 scroll: 0,
2025 });
2026 let (tx, rx) = oneshot::channel();
2027 self.preview_rx = Some(rx);
2028 let cli = Arc::clone(cli);
2029 tokio::spawn(async move {
2030 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
2031 let _ = tx.send(result);
2032 });
2033 }
2034
2035 pub fn close_preview(&mut self) {
2036 self.preview = None;
2037 self.preview_rx = None;
2038 }
2039
2040 pub fn poll_preview(&mut self) -> bool {
2041 let Some(rx) = &mut self.preview_rx else {
2042 return false;
2043 };
2044 match rx.try_recv() {
2045 Ok(result) => {
2046 if result.is_err() {
2048 self.preview_warehouse = None;
2049 }
2050 if let Some(pv) = &mut self.preview {
2051 pv.data = Some(result);
2052 }
2053 self.preview_rx = None;
2054 true
2055 }
2056 Err(oneshot::error::TryRecvError::Empty) => false,
2057 Err(oneshot::error::TryRecvError::Closed) => {
2058 self.preview_rx = None;
2059 true
2060 }
2061 }
2062 }
2063
2064 pub fn preview_scroll(&mut self, delta: i32) {
2065 if let Some(pv) = &mut self.preview {
2066 let max = match &pv.data {
2067 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2068 _ => 0,
2069 };
2070 pv.scroll = if delta < 0 {
2071 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
2072 } else {
2073 (pv.scroll + delta as usize).min(max)
2074 };
2075 }
2076 }
2077
2078 pub fn poll_uc(&mut self) -> bool {
2079 let Some(rx) = &mut self.uc_rx else {
2080 return false;
2081 };
2082 match rx.try_recv() {
2083 Ok(result) => {
2084 self.shapes[5] = Some(match result {
2085 Ok(shape) => shape,
2086 Err(e) => Shape::Text(format!("✗ {e}")),
2087 });
2088 self.updated_at[5] = Some(Instant::now());
2089 self.uc_rx = None;
2090 true
2091 }
2092 Err(oneshot::error::TryRecvError::Empty) => false,
2093 Err(oneshot::error::TryRecvError::Closed) => {
2094 self.uc_rx = None;
2095 true
2096 }
2097 }
2098 }
2099
2100 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
2103 if self.focus == Panel::Secrets {
2104 return self.open_secret_acls(cli);
2105 }
2106 let Some(item) = self.selected_item() else {
2107 return;
2108 };
2109 let Some(id) = item.id.clone() else {
2110 return;
2111 };
2112 let (uc, object_type): (bool, &'static str) = match self.focus {
2113 Panel::Catalog => match &item.status {
2114 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
2115 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
2116 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
2117 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
2118 _ => return,
2119 },
2120 Panel::Clusters => (false, "clusters"),
2121 Panel::Jobs => (false, "jobs"),
2122 Panel::Pipelines => (false, "pipelines"),
2123 Panel::Warehouses => (false, "warehouses"),
2124 Panel::Dashboards => (false, "dashboards"),
2125 Panel::Secrets => return,
2127 };
2128 self.detail = Some(Detail {
2129 panel: self.focus,
2130 name: item.name.clone(),
2131 id: id.clone(),
2132 kind: None,
2133 section: "Access",
2134 data: None,
2135 show_raw: false,
2136 scroll: 0,
2137 });
2138 let (tx, rx) = oneshot::channel();
2139 self.detail_rx = Some(rx);
2140 let cli = Arc::clone(cli);
2141 tokio::spawn(async move {
2142 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
2143 let _ = tx.send(data);
2144 });
2145 }
2146
2147 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
2150 let Some(d) = &self.detail else {
2151 return;
2152 };
2153 let panel = d.panel;
2154 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
2155 return;
2156 }
2157 let owner_id = d.id.clone();
2158 self.run_view = Some(RunView {
2159 panel,
2160 owner_name: d.name.clone(),
2161 owner_id: owner_id.clone(),
2162 runs: Vec::new(),
2163 idx: 0,
2164 data: None,
2165 show_raw: false,
2166 scroll: 0,
2167 live: false,
2168 fetched_at: Instant::now(),
2169 });
2170 let (tx, rx) = oneshot::channel();
2171 self.run_rx = Some(rx);
2172 let cli = Arc::clone(cli);
2173 tokio::spawn(async move {
2174 let result = async {
2175 let runs = if panel == Panel::Jobs {
2176 fetchers::runs::list(&cli, &owner_id).await?
2177 } else {
2178 fetchers::updates::list(&cli, &owner_id).await?
2179 };
2180 let Some((run_id, _, _)) = runs.first().cloned() else {
2181 return Err("no runs recorded yet".to_string());
2182 };
2183 let (data, live) = if panel == Panel::Jobs {
2184 fetchers::runs::fetch(&cli, &run_id).await
2185 } else {
2186 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2187 };
2188 Ok((runs, data, live))
2189 }
2190 .await;
2191 let _ = tx.send(RunUpdate::Opened(result));
2192 });
2193 }
2194
2195 pub fn close_run(&mut self) {
2196 self.run_view = None;
2197 self.run_rx = None;
2198 }
2199
2200 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
2202 if self.run_rx.is_some() {
2203 return;
2204 }
2205 let Some(rv) = &mut self.run_view else {
2206 return;
2207 };
2208 if rv.runs.is_empty() {
2209 return;
2210 }
2211 let new = if delta < 0 {
2212 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
2213 } else {
2214 (rv.idx + delta as usize).min(rv.runs.len() - 1)
2215 };
2216 if new == rv.idx {
2217 return;
2218 }
2219 rv.idx = new;
2220 rv.data = None;
2221 rv.scroll = 0;
2222 rv.show_raw = false;
2223 let run_id = rv.runs[new].0.clone();
2224 self.start_run_fetch(cli, run_id);
2225 }
2226
2227 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
2228 let Some(rv) = &self.run_view else {
2229 return;
2230 };
2231 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
2232 let (tx, rx) = oneshot::channel();
2233 self.run_rx = Some(rx);
2234 let cli = Arc::clone(cli);
2235 tokio::spawn(async move {
2236 let (data, live) = if panel == Panel::Jobs {
2237 fetchers::runs::fetch(&cli, &run_id).await
2238 } else {
2239 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2240 };
2241 let _ = tx.send(RunUpdate::Detail(data, live));
2242 });
2243 }
2244
2245 pub fn run_toggle_raw(&mut self) {
2246 if let Some(rv) = &mut self.run_view {
2247 rv.show_raw = !rv.show_raw;
2248 rv.scroll = 0;
2249 }
2250 }
2251
2252 pub fn run_scroll(&mut self, delta: i32) {
2253 if let Some(rv) = &mut self.run_view {
2254 rv.scroll = if delta < 0 {
2255 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
2256 } else {
2257 rv.scroll.saturating_add(delta as u16)
2258 };
2259 }
2260 }
2261
2262 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2265 if let Some(rx) = &mut self.run_rx {
2266 match rx.try_recv() {
2267 Ok(update) => {
2268 self.run_rx = None;
2269 if let Some(rv) = &mut self.run_view {
2270 match update {
2271 RunUpdate::Opened(Ok((runs, data, live))) => {
2272 rv.runs = runs;
2273 rv.idx = 0;
2274 rv.data = Some(data);
2275 rv.live = live;
2276 }
2277 RunUpdate::Opened(Err(e)) => {
2278 rv.data = Some(DetailData {
2279 summary: Vec::new(),
2280 activity: Vec::new(),
2281 raw: format!("✗ {e}"),
2282 });
2283 rv.live = false;
2284 }
2285 RunUpdate::Detail(data, live) => {
2286 rv.data = Some(data);
2287 rv.live = live;
2288 }
2289 }
2290 rv.fetched_at = Instant::now();
2291 }
2292 true
2293 }
2294 Err(oneshot::error::TryRecvError::Empty) => false,
2295 Err(oneshot::error::TryRecvError::Closed) => {
2296 self.run_rx = None;
2297 true
2298 }
2299 }
2300 } else if let Some(rv) = &self.run_view {
2301 if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
2302 if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
2303 self.start_run_fetch(cli, run_id);
2304 }
2305 }
2306 false
2307 } else {
2308 false
2309 }
2310 }
2311
2312 pub fn close_detail(&mut self) {
2313 self.detail = None;
2314 self.detail_rx = None;
2315 }
2316
2317 pub fn toggle_raw(&mut self) {
2318 if let Some(d) = &mut self.detail {
2319 d.show_raw = !d.show_raw;
2320 d.scroll = 0;
2321 }
2322 }
2323
2324 pub fn poll_detail(&mut self) -> bool {
2326 let Some(rx) = &mut self.detail_rx else {
2327 return false;
2328 };
2329 match rx.try_recv() {
2330 Ok(data) => {
2331 if let Some(d) = &mut self.detail {
2332 d.data = Some(data);
2333 }
2334 self.detail_rx = None;
2335 true
2336 }
2337 Err(oneshot::error::TryRecvError::Empty) => false,
2338 Err(oneshot::error::TryRecvError::Closed) => {
2339 self.detail_rx = None;
2340 true
2341 }
2342 }
2343 }
2344
2345 pub fn detail_scroll(&mut self, delta: i32) {
2346 if let Some(d) = &mut self.detail {
2347 let max = match &d.data {
2348 Some(data) if d.show_raw => data.raw.lines().count(),
2349 Some(data) => data.summary.len() + data.activity.len() + 3,
2350 None => 0,
2351 } as u16;
2352 d.scroll = if delta < 0 {
2353 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
2354 } else {
2355 (d.scroll + delta as u16).min(max.saturating_sub(1))
2356 };
2357 }
2358 }
2359
2360 pub fn request_action(&mut self) {
2363 if matches!(
2365 self.focus,
2366 Panel::Dashboards | Panel::Catalog | Panel::Secrets
2367 ) {
2368 return;
2369 }
2370 let Some(item) = self.selected_item() else {
2371 return;
2372 };
2373 let Some(id) = item.id.clone() else {
2374 return;
2375 };
2376 let name = item.name.clone();
2377 let active = matches!(
2378 item.status,
2379 Status::Running | Status::Pending | Status::Success
2380 );
2381 let group = self.focus.cli_group();
2382 let (verb, action): (&str, &str) = match self.focus {
2383 Panel::Jobs => ("Run", "run-now"),
2384 Panel::Clusters if active => ("Stop", "delete"),
2385 Panel::Pipelines if active => ("Stop", "stop"),
2386 Panel::Pipelines => ("Start update for", "start-update"),
2387 _ if active => ("Stop", "stop"),
2388 _ => ("Start", "start"),
2389 };
2390 self.confirm = Some(Confirm {
2391 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
2392 args: vec![group.to_string(), action.to_string(), id],
2393 });
2394 }
2395
2396 pub fn request_run_cancel(&mut self) {
2398 let Some(rv) = &self.run_view else {
2399 return;
2400 };
2401 if !rv.live {
2402 self.flash = Some((
2403 "✗ nothing to cancel — this run already finished".to_string(),
2404 Instant::now(),
2405 ));
2406 return;
2407 }
2408 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
2409 return;
2410 };
2411 let (message, args) = if rv.panel == Panel::Jobs {
2412 (
2413 format!("Cancel run {run_id} of “{}”?", rv.owner_name),
2414 vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
2415 )
2416 } else {
2417 (
2418 format!("Stop “{}” (cancels the active update)?", rv.owner_name),
2419 vec![
2420 "pipelines".to_string(),
2421 "stop".to_string(),
2422 rv.owner_id.clone(),
2423 ],
2424 )
2425 };
2426 self.confirm = Some(Confirm { message, args });
2427 }
2428
2429 pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
2432 let id = self
2433 .sql_stmt
2434 .as_ref()
2435 .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
2436 let Some(id) = id else {
2437 self.flash = Some((
2438 "✗ statement not submitted yet — try again in a moment".to_string(),
2439 Instant::now(),
2440 ));
2441 return;
2442 };
2443 let cli = Arc::clone(cli);
2444 tokio::spawn(async move {
2445 let path = format!("/api/2.0/sql/statements/{id}/cancel");
2446 let _ = cli.run_action(&["api", "post", &path]).await;
2447 });
2448 self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
2449 }
2450
2451 pub fn cancel_confirm(&mut self) {
2452 self.confirm = None;
2453 }
2454
2455 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
2456 let Some(c) = self.confirm.take() else {
2457 return;
2458 };
2459 let base = c.message.trim_end_matches('?').to_string();
2460 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
2461
2462 let (tx, rx) = oneshot::channel();
2463 self.action_rx = Some(rx);
2464 let cli = Arc::clone(cli);
2465 tokio::spawn(async move {
2466 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
2467 let result = match cli.run_action(&args).await {
2468 Ok(()) => Ok(format!("✓ {base} — done")),
2469 Err(e) => Err(format!("✗ {e:#}")),
2470 };
2471 let _ = tx.send(result);
2472 });
2473 }
2474
2475 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2477 let Some(rx) = &mut self.action_rx else {
2478 return false;
2479 };
2480 match rx.try_recv() {
2481 Ok(result) => {
2482 let ok = result.is_ok();
2483 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
2484 self.action_rx = None;
2485 if ok {
2486 self.start_refresh(cli);
2487 }
2488 true
2489 }
2490 Err(oneshot::error::TryRecvError::Empty) => false,
2491 Err(oneshot::error::TryRecvError::Closed) => {
2492 self.action_rx = None;
2493 true
2494 }
2495 }
2496 }
2497
2498 pub fn expire_flash(&mut self) -> bool {
2500 if let Some((_, since)) = &self.flash {
2501 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
2502 self.flash = None;
2503 return true;
2504 }
2505 }
2506 false
2507 }
2508
2509 pub fn open_in_browser(&self) {
2511 let Some(host) = &self.host else {
2512 return;
2513 };
2514 let (panel, id) = match &self.detail {
2515 Some(d) => (d.panel, Some(d.id.clone())),
2516 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
2517 };
2518 let Some(id) = id else {
2519 return;
2520 };
2521 let path = match panel {
2522 Panel::Clusters => format!("compute/clusters/{id}"),
2523 Panel::Jobs => format!("jobs/{id}"),
2524 Panel::Pipelines => format!("pipelines/{id}"),
2525 Panel::Warehouses => format!("sql/warehouses/{id}"),
2526 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
2527 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
2528 Panel::Secrets => return,
2530 };
2531 let url = format!("{}/{}", host.trim_end_matches('/'), path);
2532 #[cfg(target_os = "macos")]
2533 let opener = "open";
2534 #[cfg(not(target_os = "macos"))]
2535 let opener = "xdg-open";
2536 let _ = std::process::Command::new(opener).arg(url).spawn();
2537 }
2538
2539 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
2541 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
2542 for shape in self.shapes.iter().flatten() {
2543 if let Shape::List(items) = shape {
2544 for item in items {
2545 match item.status {
2546 Status::Running | Status::Success => ok += 1,
2547 Status::Pending => pending += 1,
2548 Status::Failed => failed += 1,
2549 Status::Stopped => idle += 1,
2550 Status::Unknown(_) => {}
2551 }
2552 }
2553 }
2554 }
2555 (ok, pending, failed, idle)
2556 }
2557
2558 pub fn last_refresh_age(&self) -> Duration {
2559 self.last_refresh.elapsed()
2560 }
2561
2562 pub fn spinner(&self) -> &'static str {
2563 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
2564 }
2565
2566 pub fn spinner_frame(&self) -> usize {
2567 self.spinner_frame
2568 }
2569
2570 pub fn busy(&self) -> bool {
2573 self.loading
2574 || self.detail_rx.is_some()
2575 || self.action_rx.is_some()
2576 || self.preview_rx.is_some()
2577 || self.cost_rx.is_some()
2578 || self.sql_rx.is_some()
2579 || self.run_rx.is_some()
2580 }
2581
2582 pub fn tick_spinner(&mut self) {
2583 self.spinner_frame = self.spinner_frame.wrapping_add(1);
2584 }
2585
2586 pub fn toggle_zoom(&mut self) {
2587 self.zoomed = !self.zoomed;
2588 }
2589
2590 pub fn focus_next(&mut self) {
2591 self.cycle_focus(1);
2592 }
2593
2594 pub fn focus_prev(&mut self) {
2595 self.cycle_focus(-1);
2596 }
2597
2598 fn cycle_focus(&mut self, delta: i32) {
2600 let visible = self.visible_panes();
2601 if visible.is_empty() {
2602 return;
2603 }
2604 let focus_idx = Panel::ALL
2605 .iter()
2606 .position(|p| p == &self.focus)
2607 .unwrap_or(0);
2608 let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
2609 let n = visible.len() as i32;
2610 let next = ((pos as i32 + delta) % n + n) % n;
2611 self.focus = Panel::ALL[visible[next as usize]];
2612 }
2613
2614 pub fn needs_refresh(&self) -> bool {
2615 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
2616 }
2617
2618 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
2619 if self.loading {
2620 return;
2621 }
2622 self.loading = true;
2623 self.error = None;
2624 self.last_refresh = Instant::now();
2625
2626 let (tx, rx) = mpsc::unbounded_channel();
2627 self.pending = Some(rx);
2628 self.in_flight = 8;
2629
2630 macro_rules! spawn_fetch {
2633 ($update:expr, $fetch:path) => {{
2634 let cli = Arc::clone(cli);
2635 let tx = tx.clone();
2636 tokio::spawn(async move {
2637 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
2638 let _ = tx.send($update(result));
2639 });
2640 }};
2641 }
2642
2643 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
2644 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
2645 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
2646 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
2647 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
2648 spawn_fetch!(
2649 |s: Result<Shape, String>| Update::Badge(s.ok()),
2650 fetchers::current_user::fetch
2651 );
2652 {
2653 let cli = Arc::clone(cli);
2654 let tx = tx.clone();
2655 let path = self.uc_path.clone();
2656 tokio::spawn(async move {
2657 let result = fetchers::catalog::fetch(&cli, &path)
2658 .await
2659 .map_err(|e| format!("{e:#}"));
2660 let _ = tx.send(Update::Panel(5, result));
2661 });
2662 }
2663 {
2664 let cli = Arc::clone(cli);
2665 let tx = tx.clone();
2666 let scope = self.secret_scope.clone();
2667 tokio::spawn(async move {
2668 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
2669 .await
2670 .map_err(|e| format!("{e:#}"));
2671 let _ = tx.send(Update::Panel(6, result));
2672 });
2673 }
2674 }
2675
2676 pub fn poll_refresh(&mut self) -> bool {
2678 let Some(rx) = &mut self.pending else {
2679 return false;
2680 };
2681 let mut changed = false;
2682 let mut updated_panes: Vec<usize> = Vec::new();
2683 loop {
2684 match rx.try_recv() {
2685 Ok(Update::Panel(i, result)) => {
2686 match result {
2687 Ok(mut shape) => {
2688 if i != 5 {
2692 if let Shape::List(items) = &mut shape {
2693 items.sort_by_key(|it| {
2694 (it.status.rank(), it.history.is_empty())
2695 });
2696 }
2697 }
2698 self.shapes[i] = Some(shape);
2699 self.updated_at[i] = Some(Instant::now());
2700 updated_panes.push(i);
2701 }
2702 Err(e) => {
2705 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
2706 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
2707 }
2708 }
2709 }
2710 self.in_flight -= 1;
2711 changed = true;
2712 }
2713 Ok(Update::Badge(badge)) => {
2714 if badge.is_some() {
2715 self.user_badge = badge;
2716 }
2717 self.in_flight -= 1;
2718 changed = true;
2719 }
2720 Err(mpsc::error::TryRecvError::Empty) => break,
2721 Err(mpsc::error::TryRecvError::Disconnected) => {
2722 self.in_flight = 0;
2723 break;
2724 }
2725 }
2726 }
2727 for i in updated_panes {
2728 self.alert_new_failures(i);
2729 }
2730 if self.in_flight == 0 {
2731 self.loading = false;
2732 self.pending = None;
2733 changed = true;
2734 }
2735 changed
2736 }
2737}