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