1use crate::cli::DatabricksCli;
2use crate::fetchers;
3use crate::shape::{DetailData, Shape, Status};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6use tokio::sync::{mpsc, oneshot};
7
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum ThemeMode {
10 Dark,
11 Light,
12 CatppuccinMocha,
13 CatppuccinLatte,
14 GruvboxDark,
15 Dracula,
16 Nord,
17 TokyoNight,
18}
19
20impl ThemeMode {
21 pub const ALL: &'static [ThemeMode] = &[
22 ThemeMode::Dark,
23 ThemeMode::Light,
24 ThemeMode::CatppuccinMocha,
25 ThemeMode::CatppuccinLatte,
26 ThemeMode::GruvboxDark,
27 ThemeMode::Dracula,
28 ThemeMode::Nord,
29 ThemeMode::TokyoNight,
30 ];
31
32 pub fn toggled(self) -> Self {
34 let idx = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
35 Self::ALL[(idx + 1) % Self::ALL.len()]
36 }
37
38 pub fn name(&self) -> &'static str {
39 match self {
40 ThemeMode::Dark => "Dark (terminal colors)",
41 ThemeMode::Light => "Light",
42 ThemeMode::CatppuccinMocha => "Catppuccin Mocha",
43 ThemeMode::CatppuccinLatte => "Catppuccin Latte",
44 ThemeMode::GruvboxDark => "Gruvbox Dark",
45 ThemeMode::Dracula => "Dracula",
46 ThemeMode::Nord => "Nord",
47 ThemeMode::TokyoNight => "Tokyo Night",
48 }
49 }
50
51 pub fn id(&self) -> &'static str {
53 match self {
54 ThemeMode::Dark => "dark",
55 ThemeMode::Light => "light",
56 ThemeMode::CatppuccinMocha => "catppuccin-mocha",
57 ThemeMode::CatppuccinLatte => "catppuccin-latte",
58 ThemeMode::GruvboxDark => "gruvbox",
59 ThemeMode::Dracula => "dracula",
60 ThemeMode::Nord => "nord",
61 ThemeMode::TokyoNight => "tokyo-night",
62 }
63 }
64
65 pub fn from_id(id: &str) -> Option<Self> {
66 Self::ALL.iter().copied().find(|t| t.id() == id)
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub enum Panel {
72 Clusters,
73 Jobs,
74 Pipelines,
75 Warehouses,
76 Dashboards,
77 Catalog,
78 Secrets,
79}
80
81impl Panel {
82 pub const ALL: &'static [Panel] = &[
83 Panel::Clusters,
84 Panel::Jobs,
85 Panel::Pipelines,
86 Panel::Warehouses,
87 Panel::Dashboards,
88 Panel::Catalog,
89 Panel::Secrets,
90 ];
91
92 pub fn title(&self) -> &'static str {
93 match self {
94 Panel::Clusters => "Compute",
95 Panel::Jobs => "Lakeflow Jobs",
96 Panel::Pipelines => "Lakeflow Pipelines",
97 Panel::Warehouses => "SQL Warehouses",
98 Panel::Dashboards => "AI/BI Dashboards",
99 Panel::Catalog => "Unity Catalog",
100 Panel::Secrets => "Secret Scopes",
101 }
102 }
103
104 pub fn icon(&self) -> &'static str {
105 match self {
106 Panel::Clusters => "⌬",
107 Panel::Jobs => "⟳",
108 Panel::Pipelines => "⋙",
109 Panel::Warehouses => "⌁",
110 Panel::Dashboards => "▦",
111 Panel::Catalog => "⧉",
112 Panel::Secrets => "◈",
113 }
114 }
115
116 pub fn id(&self) -> &'static str {
118 match self {
119 Panel::Clusters => "compute",
120 Panel::Jobs => "jobs",
121 Panel::Pipelines => "pipelines",
122 Panel::Warehouses => "warehouses",
123 Panel::Dashboards => "dashboards",
124 Panel::Catalog => "catalog",
125 Panel::Secrets => "secrets",
126 }
127 }
128
129 pub fn cli_group(&self) -> &'static str {
131 match self {
132 Panel::Clusters => "clusters",
133 Panel::Jobs => "jobs",
134 Panel::Pipelines => "pipelines",
135 Panel::Warehouses => "warehouses",
136 Panel::Dashboards => "lakeview",
137 Panel::Catalog => "tables",
138 Panel::Secrets => "secrets",
139 }
140 }
141}
142
143pub struct Detail {
144 pub panel: Panel,
145 pub name: String,
146 pub id: String,
147 pub kind: Option<String>,
149 pub section: &'static str,
151 pub data: Option<DetailData>,
153 pub show_raw: bool,
155 pub scroll: u16,
156}
157
158pub struct Preview {
160 pub name: String,
161 pub warehouse: String,
163 pub warehouse_id: String,
164 pub data: Option<Result<crate::shape::TableData, String>>,
166 pub scroll: usize,
168 pub col: usize,
170 pub filter: String,
173 pub filter_entry: bool,
174 pub record: bool,
176 pub rscroll: u16,
178}
179
180impl Preview {
181 pub fn visible_cols(&self) -> Vec<usize> {
183 let Some(Ok(t)) = &self.data else {
184 return Vec::new();
185 };
186 let q = self.filter.to_lowercase();
187 (0..t.headers.len())
188 .filter(|&i| q.is_empty() || t.headers[i].to_lowercase().contains(&q))
189 .collect()
190 }
191}
192
193enum PickTarget {
195 Preview(String),
196 Cost,
197 Lineage(String),
198 Sql(String),
199}
200
201pub struct SqlConsole {
203 pub input: String,
204 pub cursor: usize,
206 pub warehouse: String,
208 pub running: bool,
209 pub data: Option<Result<crate::shape::TableData, String>>,
210 pub last_sql: String,
212 pub scroll: usize,
213 pub col: usize,
215}
216
217pub struct SqlComplete {
220 pub items: Vec<String>,
222 pub index: usize,
224 pub seg_start: usize,
227 prefix: String,
229 context: String,
231 pub loading: bool,
233}
234
235const SQL_KEYWORDS: &[&str] = &[
237 "SELECT",
238 "FROM",
239 "WHERE",
240 "GROUP BY",
241 "ORDER BY",
242 "HAVING",
243 "LIMIT",
244 "JOIN",
245 "LEFT JOIN",
246 "INNER JOIN",
247 "FULL OUTER JOIN",
248 "CROSS JOIN",
249 "ON",
250 "AS",
251 "AND",
252 "OR",
253 "NOT",
254 "IN",
255 "IS",
256 "NULL",
257 "DISTINCT",
258 "COUNT",
259 "SUM",
260 "AVG",
261 "MIN",
262 "MAX",
263 "UNION",
264 "UNION ALL",
265 "INSERT INTO",
266 "VALUES",
267 "UPDATE",
268 "SET",
269 "DELETE",
270 "CREATE",
271 "DROP",
272 "ALTER",
273 "SHOW",
274 "DESCRIBE",
275 "EXPLAIN",
276 "WITH",
277 "CASE",
278 "WHEN",
279 "THEN",
280 "ELSE",
281 "END",
282 "BETWEEN",
283 "LIKE",
284 "CAST",
285 "OVER",
286 "PARTITION BY",
287];
288
289fn token_at_cursor(input: &str, cursor: usize) -> (usize, String, String) {
292 let chars: Vec<char> = input.chars().collect();
293 let cursor = cursor.min(chars.len());
294 let mut start = cursor;
295 while start > 0 && (chars[start - 1].is_alphanumeric() || matches!(chars[start - 1], '_' | '.'))
296 {
297 start -= 1;
298 }
299 let token: String = chars[start..cursor].iter().collect();
300 match token.rfind('.') {
301 Some(dot) => {
302 let context = token[..dot].to_string();
303 let prefix = token[dot + 1..].to_string();
304 (start + context.chars().count() + 1, context, prefix)
305 }
306 None => (start, String::new(), token),
307 }
308}
309
310fn from_table(input: &str) -> Option<String> {
313 let pos = input.to_lowercase().find("from ")?;
314 let rest = input.get(pos + 5..)?.trim_start();
316 let table: String = rest
317 .chars()
318 .take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '.'))
319 .collect();
320 (table.matches('.').count() == 2 && !table.ends_with('.')).then_some(table)
321}
322
323fn history_path() -> Option<std::path::PathBuf> {
325 let home = std::env::var_os("HOME")?;
326 Some(
327 std::path::PathBuf::from(home)
328 .join(".config")
329 .join("databricks-tui")
330 .join("history"),
331 )
332}
333
334fn load_history() -> Vec<String> {
335 history_path()
336 .and_then(|p| std::fs::read_to_string(p).ok())
337 .map(|s| {
338 s.lines()
339 .filter(|l| !l.trim().is_empty())
340 .map(str::to_string)
341 .collect()
342 })
343 .unwrap_or_default()
344}
345
346fn save_history(history: &[String]) {
347 let Some(path) = history_path() else {
348 return;
349 };
350 if let Some(dir) = path.parent() {
351 let _ = std::fs::create_dir_all(dir);
352 crate::config::restrict(dir, 0o700);
353 }
354 let tail: Vec<&str> = history
356 .iter()
357 .rev()
358 .take(200)
359 .rev()
360 .map(String::as_str)
361 .collect();
362 let _ = std::fs::write(&path, tail.join("\n") + "\n");
364 crate::config::restrict(&path, 0o600);
365}
366
367fn subsequence(haystack: &str, needle: &str) -> bool {
369 let mut chars = haystack.chars();
370 needle.chars().all(|n| chars.any(|h| h == n))
371}
372
373fn byte_at(input: &str, cursor: usize) -> usize {
375 input
376 .char_indices()
377 .nth(cursor)
378 .map(|(i, _)| i)
379 .unwrap_or(input.len())
380}
381
382pub struct WhPicker {
384 pub index: usize,
385 target: PickTarget,
386}
387
388pub struct CostView {
390 pub warehouse: String,
391 pub data: Option<Result<fetchers::cost::CostData, String>>,
392}
393
394pub struct RunView {
397 pub panel: Panel,
399 pub owner_name: String,
400 owner_id: String,
402 pub runs: Vec<(String, Status, String)>,
404 pub idx: usize,
406 pub data: Option<DetailData>,
407 pub show_raw: bool,
408 pub scroll: u16,
409 pub live: bool,
411 pub output: Option<String>,
413 pub show_output: bool,
414 pub show_timeline: bool,
417 pub show_dag: bool,
420 fetched_at: Instant,
421}
422
423type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
425
426enum RunUpdate {
427 Opened(Result<RunOpened, String>),
428 Detail(DetailData, bool),
429 Output(String, bool),
431}
432
433pub struct Problem {
435 pub panel: usize,
436 pub name: String,
437 pub status: Status,
438 pub note: String,
439}
440
441pub struct Problems {
443 pub items: Vec<Problem>,
444 pub index: usize,
445}
446
447pub struct Upcoming {
449 pub items: Vec<fetchers::upcoming::UpcomingJob>,
450 pub index: usize,
451 pub loading: bool,
452}
453
454pub struct Confirm {
456 pub message: String,
457 args: Vec<String>,
458}
459
460enum Update {
461 Panel(usize, Result<Shape, String>),
462 Badge(Option<Shape>),
463}
464
465const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
466
467pub struct App {
468 pub focus: Panel,
469 pub theme: ThemeMode,
470 pub zoomed: bool,
471 pub shapes: Vec<Option<Shape>>,
472 pub user_badge: Option<Shape>,
473 pub error: Option<String>,
474 pub refresh_interval: Duration,
475 last_refresh: Instant,
476 pub loading: bool,
477 pub detail: Option<Detail>,
478 pub confirm: Option<Confirm>,
479 pub flash: Option<(String, Instant)>,
480 pub selected: [usize; 7],
481 pub host: Option<String>,
482 pub profiles: Vec<String>,
484 pub profile: Option<String>,
485 pub picker: Option<usize>,
487 pub problems: Option<Problems>,
489 pub upcoming: Option<Upcoming>,
490 pub uc_path: Vec<String>,
492 uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
493 pub preview: Option<Preview>,
494 preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
495 pub wh_picker: Option<WhPicker>,
496 pub preview_warehouse: Option<(String, String)>,
498 pub cost: Option<CostView>,
499 #[allow(clippy::type_complexity)]
500 cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
501 workspace_id: Option<String>,
504 pub sql: Option<SqlConsole>,
505 sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
506 sql_history: Vec<String>,
508 hist_idx: Option<usize>,
510 hist_draft: String,
512 pub hist_search: Option<(String, usize)>,
514 pub run_view: Option<RunView>,
515 run_rx: Option<oneshot::Receiver<RunUpdate>>,
516 #[allow(clippy::type_complexity)]
517 upcoming_rx: Option<oneshot::Receiver<Result<Vec<fetchers::upcoming::UpcomingJob>, String>>>,
518 pending: Option<mpsc::UnboundedReceiver<Update>>,
519 detail_rx: Option<oneshot::Receiver<DetailData>>,
520 action_rx: Option<oneshot::Receiver<Result<String, String>>>,
521 host_rx: Option<oneshot::Receiver<Option<String>>>,
522 in_flight: usize,
523 spinner_frame: usize,
524 pub splash_until: Option<Instant>,
526 pub updated_at: [Option<Instant>; 7],
528 pub filters: [String; 7],
530 pub filter_entry: bool,
532 pub config: crate::config::Config,
534 failed_seen: [Option<std::collections::HashSet<String>>; 7],
537 pub jump: Option<Jump>,
539 pub pane_order: Vec<usize>,
541 pub hidden: [bool; 7],
543 pub pane_cfg: Option<usize>,
545 pub help: bool,
547 pub help_scroll: u16,
549 sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
551 pub sql_complete: Option<SqlComplete>,
553 uc_names: std::collections::HashMap<String, Vec<String>>,
556 #[allow(clippy::type_complexity)]
557 uc_names_rx: Option<oneshot::Receiver<(String, Result<Vec<String>, String>)>>,
558 pub secret_scope: Option<String>,
560 secrets_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
561 pub secret_form: Option<SecretForm>,
563}
564
565pub struct Jump {
567 pub query: String,
568 pub index: usize,
569}
570
571pub struct SecretForm {
573 pub scope: Option<String>,
575 pub key: String,
576 pub value: String,
577 pub stage: u8,
579}
580
581impl App {
582 pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
583 let mut app = Self {
584 focus: Panel::Clusters,
585 theme,
586 zoomed: false,
587 shapes: vec![None; 7],
588 user_badge: None,
589 error: None,
590 refresh_interval: Duration::from_secs(refresh_secs),
591 last_refresh: Instant::now()
592 .checked_sub(Duration::from_secs(refresh_secs + 1))
593 .unwrap_or(Instant::now()),
594 loading: false,
595 detail: None,
596 confirm: None,
597 flash: None,
598 selected: [0; 7],
599 host: None,
600 profiles: Vec::new(),
601 profile: None,
602 picker: None,
603 problems: None,
604 upcoming: None,
605 uc_path: Vec::new(),
606 uc_rx: None,
607 preview: None,
608 preview_rx: None,
609 wh_picker: None,
610 preview_warehouse: None,
611 cost: None,
612 cost_rx: None,
613 workspace_id: None,
614 sql: None,
615 sql_rx: None,
616 sql_history: load_history(),
617 hist_idx: None,
618 hist_draft: String::new(),
619 hist_search: None,
620 run_view: None,
621 run_rx: None,
622 upcoming_rx: None,
623 pending: None,
624 detail_rx: None,
625 action_rx: None,
626 host_rx: None,
627 in_flight: 0,
628 spinner_frame: 0,
629 splash_until: Some(Instant::now() + Duration::from_millis(1600)),
630 updated_at: [None; 7],
631 filters: Default::default(),
632 filter_entry: false,
633 config: crate::config::Config::load(),
634 failed_seen: Default::default(),
635 jump: None,
636 pane_order: (0..7).collect(),
637 hidden: [false; 7],
638 pane_cfg: None,
639 help: false,
640 help_scroll: 0,
641 sql_stmt: None,
642 sql_complete: None,
643 uc_names: Default::default(),
644 uc_names_rx: None,
645 secret_scope: None,
646 secrets_rx: None,
647 secret_form: None,
648 };
649 app.load_pane_prefs();
650 app
651 }
652
653 fn load_pane_prefs(&mut self) {
655 let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
656 let mut order: Vec<usize> = self
657 .config
658 .pane_order
659 .iter()
660 .filter_map(|id| idx_of(id))
661 .collect();
662 for i in 0..7 {
663 if !order.contains(&i) {
664 order.push(i);
665 }
666 }
667 self.pane_order = order;
668 for id in &self.config.hidden_panes {
669 if let Some(i) = idx_of(id) {
670 self.hidden[i] = true;
671 }
672 }
673 self.ensure_focus_visible();
674 }
675
676 fn persist_panes(&mut self) {
677 self.config.pane_order = self
678 .pane_order
679 .iter()
680 .map(|&i| Panel::ALL[i].id().to_string())
681 .collect();
682 self.config.hidden_panes = (0..7)
683 .filter(|&i| self.hidden[i])
684 .map(|i| Panel::ALL[i].id().to_string())
685 .collect();
686 self.config.save();
687 }
688
689 pub fn visible_panes(&self) -> Vec<usize> {
691 self.pane_order
692 .iter()
693 .copied()
694 .filter(|&i| !self.hidden[i])
695 .collect()
696 }
697
698 fn ensure_focus_visible(&mut self) {
700 let visible = self.visible_panes();
701 let focus_idx = Panel::ALL
702 .iter()
703 .position(|p| p == &self.focus)
704 .unwrap_or(0);
705 if !visible.contains(&focus_idx) {
706 if let Some(&first) = visible.first() {
707 self.focus = Panel::ALL[first];
708 }
709 }
710 }
711
712 fn reveal_pane(&mut self, idx: usize) {
714 if self.hidden[idx] {
715 self.hidden[idx] = false;
716 self.persist_panes();
717 }
718 }
719
720 pub fn open_pane_cfg(&mut self) {
721 self.pane_cfg = Some(0);
722 }
723
724 pub fn pane_cfg_next(&mut self) {
725 if let Some(i) = self.pane_cfg {
726 self.pane_cfg = Some((i + 1).min(6));
727 }
728 }
729
730 pub fn pane_cfg_prev(&mut self) {
731 if let Some(i) = self.pane_cfg {
732 self.pane_cfg = Some(i.saturating_sub(1));
733 }
734 }
735
736 pub fn pane_cfg_toggle(&mut self) {
739 let Some(pos) = self.pane_cfg else {
740 return;
741 };
742 let idx = self.pane_order[pos];
743 if !self.hidden[idx] && self.visible_panes().len() == 1 {
744 self.flash = Some((
745 "✗ at least one pane has to stay visible".to_string(),
746 Instant::now(),
747 ));
748 return;
749 }
750 self.hidden[idx] = !self.hidden[idx];
751 self.ensure_focus_visible();
752 self.persist_panes();
753 }
754
755 pub fn pane_cfg_move(&mut self, delta: i32) {
757 let Some(pos) = self.pane_cfg else {
758 return;
759 };
760 let new = if delta < 0 {
761 pos.saturating_sub(1)
762 } else {
763 (pos + 1).min(6)
764 };
765 if new != pos {
766 self.pane_order.swap(pos, new);
767 self.pane_cfg = Some(new);
768 self.persist_panes();
769 }
770 }
771
772 fn alert_new_failures(&mut self, idx: usize) {
775 if idx >= 5 {
777 return;
778 }
779 let Some(Shape::List(items)) = &self.shapes[idx] else {
780 return;
781 };
782 let failed: std::collections::HashSet<String> = items
783 .iter()
784 .filter(|it| {
785 matches!(it.status, Status::Failed)
786 || it
787 .history
788 .last()
789 .is_some_and(|s| matches!(s, Status::Failed))
790 })
791 .map(|it| it.name.clone())
792 .collect();
793 if let Some(prev) = &self.failed_seen[idx] {
794 let mut newly: Vec<&String> = failed.difference(prev).collect();
795 if !newly.is_empty() {
796 newly.sort();
797 let extra = if newly.len() > 1 {
798 format!(" (+{} more)", newly.len() - 1)
799 } else {
800 String::new()
801 };
802 self.flash = Some((
803 format!("✗ {}{extra} just failed — ! to inspect", newly[0]),
804 Instant::now(),
805 ));
806 print!("\x07");
808 let _ = std::io::Write::flush(&mut std::io::stdout());
809 }
810 }
811 self.failed_seen[idx] = Some(failed);
812 }
813
814 pub fn persist_theme(&mut self) {
816 self.config.theme = Some(self.theme.id().to_string());
817 self.config.save();
818 }
819
820 pub fn restore_warehouse_pref(&mut self) {
822 let profile = self.profile.as_deref().unwrap_or("DEFAULT");
823 self.preview_warehouse = self.config.warehouses.get(profile).cloned();
824 }
825
826 pub fn splash_active(&self) -> bool {
827 self.splash_until
828 .map(|t| Instant::now() < t)
829 .unwrap_or(false)
830 }
831
832 pub fn dismiss_splash(&mut self) {
833 self.splash_until = None;
834 }
835
836 pub fn any_fresh(&self) -> bool {
838 self.updated_at
839 .iter()
840 .flatten()
841 .any(|t| t.elapsed() < Duration::from_millis(1200))
842 }
843
844 pub fn open_picker(&mut self) {
845 if self.profiles.is_empty() {
846 return;
847 }
848 let current = self
849 .profile
850 .as_deref()
851 .and_then(|p| self.profiles.iter().position(|n| n == p))
852 .unwrap_or(0);
853 self.picker = Some(current);
854 }
855
856 pub fn picker_next(&mut self) {
857 if let Some(i) = self.picker {
858 self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
859 }
860 }
861
862 pub fn picker_prev(&mut self) {
863 if let Some(i) = self.picker {
864 self.picker = Some(i.saturating_sub(1));
865 }
866 }
867
868 pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
870 let idx = self.picker.take()?;
871 let name = self.profiles.get(idx)?.clone();
872 let profile_arg = if name == "DEFAULT" {
873 None
874 } else {
875 Some(name.clone())
876 };
877 self.profile = Some(name);
878
879 self.shapes = vec![None; 7];
881 self.user_badge = None;
882 self.host = None;
883 self.selected = [0; 7];
884 self.detail = None;
885 self.detail_rx = None;
886 self.confirm = None;
887 self.problems = None;
888 self.uc_path.clear();
889 self.uc_rx = None;
890 self.secret_scope = None;
891 self.secrets_rx = None;
892 self.secret_form = None;
893 self.preview = None;
894 self.preview_rx = None;
895 self.wh_picker = None;
896 self.preview_warehouse = None;
897 self.cost = None;
898 self.cost_rx = None;
899 self.workspace_id = None;
900 self.sql = None;
901 self.sql_rx = None;
902 self.run_view = None;
903 self.run_rx = None;
904 self.pending = None;
905 self.in_flight = 0;
906 self.loading = false;
907 self.zoomed = false;
908 self.filters = Default::default();
909 self.filter_entry = false;
910 self.failed_seen = Default::default();
911 self.jump = None;
912 self.sql_stmt = None;
913 self.sql_complete = None;
914 self.uc_names.clear();
915 self.uc_names_rx = None;
916 self.restore_warehouse_pref();
917
918 Some(Arc::new(DatabricksCli::new(profile_arg)))
919 }
920
921 pub fn open_jump(&mut self) {
922 self.jump = Some(Jump {
923 query: String::new(),
924 index: 0,
925 });
926 }
927
928 pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
932 let Some(jump) = &self.jump else {
933 return Vec::new();
934 };
935 let q = jump.query.to_lowercase();
936 let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
937 for (i, shape) in self.shapes.iter().enumerate() {
938 let Some(Shape::List(items)) = shape else {
939 continue;
940 };
941 for it in items {
942 let name = it.name.to_lowercase();
943 let rank = if q.is_empty() || name.contains(&q) {
944 0
945 } else if subsequence(&name, &q) {
946 1
947 } else {
948 continue;
949 };
950 scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
951 }
952 }
953 scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
954 scored
955 .into_iter()
956 .take(12)
957 .map(|(_, i, name, label)| (i, name, label))
958 .collect()
959 }
960
961 pub fn jump_push(&mut self, c: char) {
962 if let Some(j) = &mut self.jump {
963 j.query.push(c);
964 j.index = 0;
965 }
966 }
967
968 pub fn jump_pop(&mut self) {
969 if let Some(j) = &mut self.jump {
970 j.query.pop();
971 j.index = 0;
972 }
973 }
974
975 pub fn jump_next(&mut self) {
976 let len = self.jump_matches().len();
977 if let Some(j) = &mut self.jump {
978 j.index = (j.index + 1).min(len.saturating_sub(1));
979 }
980 }
981
982 pub fn jump_prev(&mut self) {
983 if let Some(j) = &mut self.jump {
984 j.index = j.index.saturating_sub(1);
985 }
986 }
987
988 pub fn jump_go(&mut self) {
990 let matches = self.jump_matches();
991 let Some(jump) = self.jump.take() else {
992 return;
993 };
994 let Some((panel_idx, name, _)) = matches.get(jump.index) else {
995 return;
996 };
997 self.reveal_pane(*panel_idx);
998 self.focus = Panel::ALL[*panel_idx];
999 self.filters[*panel_idx].clear();
1000 if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
1001 if let Some(pos) = items.iter().position(|i| &i.name == name) {
1002 self.selected[*panel_idx] = pos;
1003 }
1004 }
1005 }
1006
1007 pub fn open_problems(&mut self) {
1010 let mut items = Vec::new();
1011 for (i, shape) in self.shapes.iter().enumerate() {
1012 let Some(Shape::List(list)) = shape else {
1013 continue;
1014 };
1015 for it in list {
1016 let failed_now = matches!(it.status, Status::Failed);
1017 let failed_last = it
1018 .history
1019 .last()
1020 .is_some_and(|s| matches!(s, Status::Failed));
1021 if failed_now || failed_last {
1022 let note = if failed_now {
1023 it.detail.clone().unwrap_or_default()
1024 } else {
1025 "latest run failed".to_string()
1026 };
1027 items.push(Problem {
1028 panel: i,
1029 name: it.name.clone(),
1030 status: it.status.clone(),
1031 note,
1032 });
1033 }
1034 }
1035 }
1036 self.problems = Some(Problems { items, index: 0 });
1037 }
1038
1039 pub fn problems_next(&mut self) {
1040 if let Some(pr) = &mut self.problems {
1041 pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
1042 }
1043 }
1044
1045 pub fn problems_prev(&mut self) {
1046 if let Some(pr) = &mut self.problems {
1047 pr.index = pr.index.saturating_sub(1);
1048 }
1049 }
1050
1051 pub fn problems_jump(&mut self) {
1053 let Some(pr) = self.problems.take() else {
1054 return;
1055 };
1056 let Some(problem) = pr.items.get(pr.index) else {
1057 return;
1058 };
1059 self.reveal_pane(problem.panel);
1060 self.focus = Panel::ALL[problem.panel];
1061 self.filters[problem.panel].clear();
1063 if let Some(Shape::List(list)) = &self.shapes[problem.panel] {
1064 if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
1065 self.selected[problem.panel] = pos;
1066 }
1067 }
1068 }
1069
1070 pub fn open_upcoming(&mut self, cli: &Arc<DatabricksCli>) {
1073 self.upcoming = Some(Upcoming {
1074 items: Vec::new(),
1075 index: 0,
1076 loading: true,
1077 });
1078 let (tx, rx) = oneshot::channel();
1079 self.upcoming_rx = Some(rx);
1080 let cli = Arc::clone(cli);
1081 tokio::spawn(async move {
1082 let _ = tx.send(fetchers::upcoming::fetch(&cli).await);
1083 });
1084 }
1085
1086 pub fn close_upcoming(&mut self) {
1087 self.upcoming = None;
1088 self.upcoming_rx = None;
1089 }
1090
1091 pub fn poll_upcoming(&mut self) -> bool {
1092 let Some(rx) = &mut self.upcoming_rx else {
1093 return false;
1094 };
1095 match rx.try_recv() {
1096 Ok(result) => {
1097 self.upcoming_rx = None;
1098 match result {
1099 Ok(items) => {
1100 if let Some(u) = &mut self.upcoming {
1101 u.items = items;
1102 u.loading = false;
1103 }
1104 }
1105 Err(e) => {
1106 self.upcoming = None;
1107 let first = e.lines().next().unwrap_or("failed").to_string();
1108 self.flash = Some((format!("✗ upcoming: {first}"), Instant::now()));
1109 }
1110 }
1111 true
1112 }
1113 Err(oneshot::error::TryRecvError::Empty) => false,
1114 Err(oneshot::error::TryRecvError::Closed) => {
1115 self.upcoming_rx = None;
1116 true
1117 }
1118 }
1119 }
1120
1121 pub fn upcoming_next(&mut self) {
1122 if let Some(u) = &mut self.upcoming {
1123 u.index = (u.index + 1).min(u.items.len().saturating_sub(1));
1124 }
1125 }
1126
1127 pub fn upcoming_prev(&mut self) {
1128 if let Some(u) = &mut self.upcoming {
1129 u.index = u.index.saturating_sub(1);
1130 }
1131 }
1132
1133 pub fn upcoming_jump(&mut self) {
1135 let Some(u) = self.upcoming.take() else {
1136 return;
1137 };
1138 self.upcoming_rx = None;
1139 let Some(item) = u.items.get(u.index) else {
1140 return;
1141 };
1142 let Some(idx) = Panel::ALL.iter().position(|p| *p == Panel::Jobs) else {
1143 return;
1144 };
1145 self.reveal_pane(idx);
1146 self.focus = Panel::Jobs;
1147 self.filters[idx].clear();
1148 if let Some(Shape::List(list)) = &self.shapes[idx] {
1149 if let Some(pos) = list.iter().position(|i| i.name == item.name) {
1150 self.selected[idx] = pos;
1151 }
1152 }
1153 }
1154
1155 pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
1158 let (tx, rx) = oneshot::channel();
1159 self.host_rx = Some(rx);
1160 let cli = Arc::clone(cli);
1161 tokio::spawn(async move {
1162 let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
1163 json["details"]["host"]
1164 .as_str()
1165 .or_else(|| json["host"].as_str())
1166 .map(str::to_string)
1167 });
1168 let _ = tx.send(host);
1169 });
1170 }
1171
1172 pub fn poll_host(&mut self) {
1173 if let Some(rx) = &mut self.host_rx {
1174 match rx.try_recv() {
1175 Ok(host) => {
1176 self.host = host;
1177 self.host_rx = None;
1178 }
1179 Err(oneshot::error::TryRecvError::Empty) => {}
1180 Err(oneshot::error::TryRecvError::Closed) => {
1181 self.host_rx = None;
1182 }
1183 }
1184 }
1185 }
1186
1187 fn focus_index(&self) -> usize {
1188 Panel::ALL
1189 .iter()
1190 .position(|p| p == &self.focus)
1191 .unwrap_or(0)
1192 }
1193
1194 fn list_len(&self, idx: usize) -> usize {
1195 match &self.shapes[idx] {
1196 Some(Shape::List(items)) => items
1197 .iter()
1198 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1199 .count(),
1200 _ => 0,
1201 }
1202 }
1203
1204 pub fn selection(&self, idx: usize) -> usize {
1206 self.selected[idx].min(self.list_len(idx).saturating_sub(1))
1207 }
1208
1209 pub fn select_next(&mut self) {
1210 let idx = self.focus_index();
1211 let len = self.list_len(idx);
1212 if len > 0 {
1213 self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
1214 }
1215 }
1216
1217 pub fn select_prev(&mut self) {
1218 let idx = self.focus_index();
1219 self.selected[idx] = self.selection(idx).saturating_sub(1);
1220 }
1221
1222 fn selected_item(&self) -> Option<&crate::shape::ListItem> {
1225 let idx = self.focus_index();
1226 match &self.shapes[idx] {
1227 Some(Shape::List(items)) => items
1228 .iter()
1229 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1230 .nth(self.selection(idx)),
1231 _ => None,
1232 }
1233 }
1234
1235 pub fn filter_start(&mut self) {
1237 let idx = self.focus_index();
1238 self.filters[idx].clear();
1239 self.selected[idx] = 0;
1240 self.filter_entry = true;
1241 }
1242
1243 pub fn filter_push(&mut self, c: char) {
1244 let idx = self.focus_index();
1245 self.filters[idx].push(c);
1246 self.selected[idx] = 0;
1247 }
1248
1249 pub fn filter_pop(&mut self) {
1250 let idx = self.focus_index();
1251 self.filters[idx].pop();
1252 self.selected[idx] = 0;
1253 }
1254
1255 pub fn filter_accept(&mut self) {
1257 self.filter_entry = false;
1258 }
1259
1260 pub fn filter_clear(&mut self) {
1261 let idx = self.focus_index();
1262 self.filters[idx].clear();
1263 self.selected[idx] = 0;
1264 self.filter_entry = false;
1265 }
1266
1267 pub fn active_filter(&self) -> &str {
1269 &self.filters[self.focus_index()]
1270 }
1271
1272 pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
1273 let Some(item) = self.selected_item() else {
1274 return;
1275 };
1276 let Some(id) = item.id.clone() else {
1277 return;
1278 };
1279 let kind = match &item.status {
1280 Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1281 _ => None,
1282 };
1283 let section = match self.focus {
1284 Panel::Dashboards => "Contents",
1285 Panel::Catalog => "Columns",
1286 Panel::Warehouses => "Recent queries",
1287 _ => "Recent activity",
1288 };
1289 self.detail = Some(Detail {
1290 panel: self.focus,
1291 name: item.name.clone(),
1292 id: id.clone(),
1293 kind,
1294 section,
1295 data: None,
1296 show_raw: false,
1297 scroll: 0,
1298 });
1299
1300 let (tx, rx) = oneshot::channel();
1301 self.detail_rx = Some(rx);
1302 let cli = Arc::clone(cli);
1303 let kind = self.detail.as_ref().unwrap().kind.clone();
1304 if kind.as_deref() == Some("FILE") {
1306 if let Some(d) = &mut self.detail {
1307 d.section = "File head";
1308 }
1309 tokio::spawn(async move {
1310 let data = fetchers::catalog::file_peek(&cli, &id).await;
1311 let _ = tx.send(data);
1312 });
1313 return;
1314 }
1315 let group = match &kind {
1316 Some(k) if k == "VOLUME" => "volumes",
1317 _ => self.focus.cli_group(),
1318 };
1319 let warehouse = match &kind {
1322 Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1323 _ => None,
1324 };
1325 tokio::spawn(async move {
1326 let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1327 let _ = tx.send(data);
1328 });
1329 }
1330
1331 pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1334 if self.focus != Panel::Catalog {
1335 return false;
1336 }
1337 let Some(item) = self.selected_item() else {
1338 return self.uc_path.is_empty(); };
1340 if self.uc_path.len() >= 2 {
1343 let drillable =
1344 matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1345 if !drillable {
1346 return false;
1347 }
1348 }
1349 self.uc_path.push(item.name.clone());
1350 self.refresh_catalog(cli);
1351 true
1352 }
1353
1354 pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1356 if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1357 return false;
1358 }
1359 self.uc_path.pop();
1360 self.refresh_catalog(cli);
1361 true
1362 }
1363
1364 fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1365 self.shapes[5] = None;
1366 self.selected[5] = 0;
1367 self.filters[5].clear();
1369 let (tx, rx) = oneshot::channel();
1370 self.uc_rx = Some(rx);
1371 let cli = Arc::clone(cli);
1372 let path = self.uc_path.clone();
1373 tokio::spawn(async move {
1374 let result = fetchers::catalog::fetch(&cli, &path)
1375 .await
1376 .map_err(|e| format!("{e:#}"));
1377 let _ = tx.send(result);
1378 });
1379 }
1380
1381 pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1385 if self.focus != Panel::Secrets {
1386 return false;
1387 }
1388 if self.secret_scope.is_some() {
1390 return true;
1391 }
1392 let Some(item) = self.selected_item() else {
1393 return true; };
1395 self.secret_scope = Some(item.name.clone());
1396 self.refresh_secrets(cli);
1397 true
1398 }
1399
1400 pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1402 if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1403 return false;
1404 }
1405 self.secret_scope = None;
1406 self.refresh_secrets(cli);
1407 true
1408 }
1409
1410 fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1411 let idx = 6;
1412 self.shapes[idx] = None;
1413 self.selected[idx] = 0;
1414 self.filters[idx].clear();
1415 let (tx, rx) = oneshot::channel();
1416 self.secrets_rx = Some(rx);
1417 let cli = Arc::clone(cli);
1418 let scope = self.secret_scope.clone();
1419 tokio::spawn(async move {
1420 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1421 .await
1422 .map_err(|e| format!("{e:#}"));
1423 let _ = tx.send(result);
1424 });
1425 }
1426
1427 pub fn poll_secrets(&mut self) -> bool {
1428 let Some(rx) = &mut self.secrets_rx else {
1429 return false;
1430 };
1431 match rx.try_recv() {
1432 Ok(result) => {
1433 self.shapes[6] = Some(match result {
1434 Ok(shape) => shape,
1435 Err(e) => Shape::Text(format!("✗ {e}")),
1436 });
1437 self.updated_at[6] = Some(Instant::now());
1438 self.secrets_rx = None;
1439 true
1440 }
1441 Err(oneshot::error::TryRecvError::Empty) => false,
1442 Err(oneshot::error::TryRecvError::Closed) => {
1443 self.secrets_rx = None;
1444 true
1445 }
1446 }
1447 }
1448
1449 pub fn open_secret_form(&mut self) {
1452 if self.focus != Panel::Secrets {
1453 return;
1454 }
1455 self.secret_form = Some(SecretForm {
1456 scope: self.secret_scope.clone(),
1457 key: String::new(),
1458 value: String::new(),
1459 stage: 0,
1460 });
1461 }
1462
1463 pub fn secret_form_push(&mut self, c: char) {
1464 if let Some(form) = &mut self.secret_form {
1465 if form.stage == 0 {
1466 form.key.push(c);
1467 } else {
1468 form.value.push(c);
1469 }
1470 }
1471 }
1472
1473 pub fn secret_form_pop(&mut self) {
1474 if let Some(form) = &mut self.secret_form {
1475 if form.stage == 0 {
1476 form.key.pop();
1477 } else {
1478 form.value.pop();
1479 }
1480 }
1481 }
1482
1483 pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1485 let Some(form) = &mut self.secret_form else {
1486 return;
1487 };
1488 if form.key.trim().is_empty() {
1489 return;
1490 }
1491 match (form.scope.clone(), form.stage) {
1492 (None, _) => {
1494 let name = form.key.trim().to_string();
1495 self.secret_form = None;
1496 self.run_secret_action(
1497 cli,
1498 format!("Create scope “{name}”"),
1499 vec!["secrets".into(), "create-scope".into(), name],
1500 );
1501 }
1502 (Some(_), 0) => form.stage = 1,
1504 (Some(scope), _) => {
1505 let key = form.key.trim().to_string();
1506 let value = form.value.clone();
1507 self.secret_form = None;
1508 self.run_secret_action(
1509 cli,
1510 format!("Put secret “{key}” in “{scope}”"),
1511 vec![
1512 "secrets".into(),
1513 "put-secret".into(),
1514 scope,
1515 key,
1516 "--string-value".into(),
1517 value,
1518 ],
1519 );
1520 }
1521 }
1522 }
1523
1524 fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1527 self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1528 let (tx, rx) = oneshot::channel();
1529 self.action_rx = Some(rx);
1530 let cli = Arc::clone(cli);
1531 tokio::spawn(async move {
1532 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1533 let result = match cli.run_action(&arg_refs).await {
1534 Ok(()) => Ok(format!("✓ {label} — done")),
1535 Err(e) => Err(format!("✗ {e:#}")),
1536 };
1537 let _ = tx.send(result);
1538 });
1539 }
1540
1541 pub fn request_secret_delete(&mut self) {
1543 if self.focus != Panel::Secrets {
1544 return;
1545 }
1546 let Some(item) = self.selected_item() else {
1547 return;
1548 };
1549 let name = item.name.clone();
1550 let (message, args) = match &self.secret_scope {
1551 None => (
1552 format!("Delete scope “{name}” and all its secrets?"),
1553 vec!["secrets".to_string(), "delete-scope".to_string(), name],
1554 ),
1555 Some(scope) => (
1556 format!("Delete secret “{name}” from “{scope}”?"),
1557 vec![
1558 "secrets".to_string(),
1559 "delete-secret".to_string(),
1560 scope.clone(),
1561 name,
1562 ],
1563 ),
1564 };
1565 self.confirm = Some(Confirm { message, args });
1566 }
1567
1568 fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1570 let scope = match &self.secret_scope {
1571 Some(s) => Some(s.clone()),
1572 None => self.selected_item().map(|i| i.name.clone()),
1573 };
1574 let Some(scope) = scope else {
1575 return;
1576 };
1577 self.detail = Some(Detail {
1578 panel: Panel::Secrets,
1579 name: scope.clone(),
1580 id: scope.clone(),
1581 kind: None,
1582 section: "Access",
1583 data: None,
1584 show_raw: false,
1585 scroll: 0,
1586 });
1587 let (tx, rx) = oneshot::channel();
1588 self.detail_rx = Some(rx);
1589 let cli = Arc::clone(cli);
1590 tokio::spawn(async move {
1591 let acl_args = ["secrets", "list-acls", &scope];
1592 let data = match cli.run(&acl_args).await {
1593 Ok(json) => {
1594 let raw =
1595 serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1596 let acls = json
1598 .as_array()
1599 .cloned()
1600 .or_else(|| json["items"].as_array().cloned())
1601 .unwrap_or_default();
1602 let activity: Vec<(Status, String)> = acls
1603 .iter()
1604 .map(|a| {
1605 let principal = a["principal"].as_str().unwrap_or("?");
1606 let perm = a["permission"].as_str().unwrap_or("?");
1607 let status = if perm == "MANAGE" {
1608 Status::Success
1609 } else {
1610 Status::Unknown(String::new())
1611 };
1612 (status, format!("{principal} · {perm}"))
1613 })
1614 .collect();
1615 DetailData {
1616 summary: vec![("Scope".to_string(), scope.clone())],
1617 activity,
1618 raw,
1619 }
1620 }
1621 Err(e) => DetailData {
1622 summary: Vec::new(),
1623 activity: Vec::new(),
1624 raw: format!("{e:#}"),
1625 },
1626 };
1627 let _ = tx.send(data);
1628 });
1629 }
1630
1631 pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1634 let idx = match kind {
1635 "cluster" => 0,
1636 "job" => 1,
1637 "warehouse" => 3,
1638 _ => return None,
1639 };
1640 match &self.shapes[idx] {
1641 Some(Shape::List(items)) => items
1642 .iter()
1643 .find(|i| i.id.as_deref() == Some(id))
1644 .map(|i| i.name.clone()),
1645 _ => None,
1646 }
1647 }
1648
1649 pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1651 let Some(Shape::List(items)) = &self.shapes[3] else {
1652 return Vec::new();
1653 };
1654 items
1655 .iter()
1656 .filter_map(|i| {
1657 let id = i.id.clone()?;
1658 Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1659 })
1660 .collect()
1661 }
1662
1663 pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1667 if self.focus != Panel::Catalog {
1668 return;
1669 }
1670 let Some(item) = self.selected_item() else {
1671 return;
1672 };
1673 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1674 return;
1675 }
1676 let Some(full_name) = item.id.clone() else {
1677 return;
1678 };
1679 let warehouses = self.warehouses();
1680 if warehouses.is_empty() {
1681 self.flash = Some((
1682 "✗ no SQL warehouse available for previews".to_string(),
1683 Instant::now(),
1684 ));
1685 return;
1686 }
1687 if !force_pick {
1688 if let Some((id, name)) = self.preview_warehouse.clone() {
1689 self.start_preview_query(cli, full_name, id, name);
1690 return;
1691 }
1692 if let [(name, id, _)] = warehouses.as_slice() {
1693 self.preview_warehouse = Some((id.clone(), name.clone()));
1694 self.start_preview_query(cli, full_name, id.clone(), name.clone());
1695 return;
1696 }
1697 }
1698 let index = self
1700 .preview_warehouse
1701 .as_ref()
1702 .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1703 .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1704 .unwrap_or(0);
1705 self.wh_picker = Some(WhPicker {
1706 index,
1707 target: PickTarget::Preview(full_name),
1708 });
1709 }
1710
1711 pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1713 let warehouses = self.warehouses();
1714 if warehouses.is_empty() {
1715 self.flash = Some((
1716 "✗ no SQL warehouse available to query system tables".to_string(),
1717 Instant::now(),
1718 ));
1719 return;
1720 }
1721 if let Some((id, name)) = self.preview_warehouse.clone() {
1722 self.start_cost_query(cli, id, name);
1723 return;
1724 }
1725 if let [(name, id, _)] = warehouses.as_slice() {
1726 self.preview_warehouse = Some((id.clone(), name.clone()));
1727 self.start_cost_query(cli, id.clone(), name.clone());
1728 return;
1729 }
1730 let index = warehouses
1731 .iter()
1732 .position(|(_, _, running)| *running)
1733 .unwrap_or(0);
1734 self.wh_picker = Some(WhPicker {
1735 index,
1736 target: PickTarget::Cost,
1737 });
1738 }
1739
1740 fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1741 self.cost = Some(CostView {
1742 warehouse: name,
1743 data: None,
1744 });
1745 let (tx, rx) = oneshot::channel();
1746 self.cost_rx = Some(rx);
1747 let cli = Arc::clone(cli);
1748 let host = self.host.clone();
1749 let cached_ws = self.workspace_id.clone();
1750 tokio::spawn(async move {
1751 let ws = match (cached_ws, host) {
1753 (Some(w), _) => Some(w),
1754 (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1755 (None, None) => None,
1756 };
1757 let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1758 let _ = tx.send((result, ws));
1759 });
1760 }
1761
1762 pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1765 if self.focus != Panel::Catalog {
1766 return;
1767 }
1768 let Some(item) = self.selected_item() else {
1769 return;
1770 };
1771 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1772 return;
1773 }
1774 let Some(full_name) = item.id.clone() else {
1775 return;
1776 };
1777 let warehouses = self.warehouses();
1778 if warehouses.is_empty() {
1779 self.flash = Some((
1780 "✗ no SQL warehouse available to query lineage".to_string(),
1781 Instant::now(),
1782 ));
1783 return;
1784 }
1785 if let Some((id, _)) = self.preview_warehouse.clone() {
1786 self.start_lineage_query(cli, full_name, id);
1787 return;
1788 }
1789 if let [(name, id, _)] = warehouses.as_slice() {
1790 self.preview_warehouse = Some((id.clone(), name.clone()));
1791 let id = id.clone();
1792 self.start_lineage_query(cli, full_name, id);
1793 return;
1794 }
1795 let index = warehouses
1796 .iter()
1797 .position(|(_, _, running)| *running)
1798 .unwrap_or(0);
1799 self.wh_picker = Some(WhPicker {
1800 index,
1801 target: PickTarget::Lineage(full_name),
1802 });
1803 }
1804
1805 fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1806 self.detail = Some(Detail {
1807 panel: Panel::Catalog,
1808 name: full_name.clone(),
1809 id: full_name.clone(),
1810 kind: None,
1811 section: "Lineage",
1812 data: None,
1813 show_raw: false,
1814 scroll: 0,
1815 });
1816 let (tx, rx) = oneshot::channel();
1817 self.detail_rx = Some(rx);
1818 let cli = Arc::clone(cli);
1819 tokio::spawn(async move {
1820 let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1821 let _ = tx.send(data);
1822 });
1823 }
1824
1825 pub fn close_cost(&mut self) {
1826 self.cost = None;
1827 self.cost_rx = None;
1828 }
1829
1830 fn selected_table_fqn(&self) -> Option<String> {
1832 if self.focus != Panel::Catalog {
1833 return None;
1834 }
1835 let item = self.selected_item()?;
1836 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1837 return None;
1838 }
1839 item.id.clone()
1840 }
1841
1842 pub fn open_sql(&mut self) {
1845 if self.sql.is_none() {
1846 let input = self
1847 .selected_table_fqn()
1848 .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1849 .unwrap_or_default();
1850 self.sql = Some(SqlConsole {
1851 cursor: input.chars().count(),
1852 input,
1853 warehouse: String::new(),
1854 running: false,
1855 data: None,
1856 last_sql: String::new(),
1857 scroll: 0,
1858 col: 0,
1859 });
1860 }
1861 }
1862
1863 pub fn close_sql(&mut self) {
1864 self.sql = None;
1865 self.sql_rx = None;
1866 self.hist_idx = None;
1867 self.hist_draft.clear();
1868 self.hist_search = None;
1869 self.sql_complete = None;
1870 }
1871
1872 pub fn sql_tab(&mut self, cli: &Arc<DatabricksCli>) {
1876 match &self.sql_complete {
1877 Some(c) if c.loading => return,
1878 Some(_) => {
1879 self.sql_complete_next(1);
1880 return;
1881 }
1882 None => {}
1883 }
1884 let Some(console) = &self.sql else {
1885 return;
1886 };
1887 let (seg_start, context, prefix) = token_at_cursor(&console.input, console.cursor);
1888 let missing = if context.is_empty() {
1891 from_table(&console.input)
1892 .filter(|fqn| !self.uc_names.contains_key(fqn))
1893 .or_else(|| (!self.uc_names.contains_key("")).then(String::new))
1894 } else {
1895 (!self.uc_names.contains_key(&context)).then(|| context.clone())
1896 };
1897 let loading = missing.is_some();
1898 self.sql_complete = Some(SqlComplete {
1899 items: Vec::new(),
1900 index: 0,
1901 seg_start,
1902 prefix,
1903 context,
1904 loading,
1905 });
1906 match missing {
1907 Some(path) => self.fetch_uc_names(cli, path),
1908 None => self.sql_complete_fill(),
1909 }
1910 }
1911
1912 fn sql_complete_fill(&mut self) {
1915 let Some(console) = &self.sql else {
1916 self.sql_complete = None;
1917 return;
1918 };
1919 let input = console.input.clone();
1920 let Some(comp) = &self.sql_complete else {
1921 return;
1922 };
1923 let q = comp.prefix.to_lowercase();
1924 let mut items: Vec<String> = Vec::new();
1925 if comp.context.is_empty() {
1926 if let Some(cols) = from_table(&input).and_then(|fqn| self.uc_names.get(&fqn)) {
1927 items.extend(
1928 cols.iter()
1929 .filter(|n| n.to_lowercase().starts_with(&q))
1930 .cloned(),
1931 );
1932 }
1933 if let Some(cats) = self.uc_names.get("") {
1934 items.extend(
1935 cats.iter()
1936 .filter(|n| n.to_lowercase().starts_with(&q))
1937 .cloned(),
1938 );
1939 }
1940 if !q.is_empty() {
1941 items.extend(
1942 SQL_KEYWORDS
1943 .iter()
1944 .filter(|k| k.to_lowercase().starts_with(&q))
1945 .map(|k| k.to_string()),
1946 );
1947 }
1948 } else if let Some(names) = self.uc_names.get(&comp.context) {
1949 items.extend(
1950 names
1951 .iter()
1952 .filter(|n| n.to_lowercase().starts_with(&q))
1953 .cloned(),
1954 );
1955 }
1956 items.dedup();
1957 if items.is_empty() {
1958 self.sql_complete = None;
1959 self.flash = Some(("no completions".to_string(), Instant::now()));
1960 return;
1961 }
1962 let single = items.len() == 1;
1963 if let Some(comp) = &mut self.sql_complete {
1964 comp.items = items;
1965 comp.index = 0;
1966 }
1967 self.sql_complete_apply();
1968 if single {
1969 self.sql_complete = None;
1970 }
1971 }
1972
1973 fn sql_complete_apply(&mut self) {
1975 let Some(comp) = &self.sql_complete else {
1976 return;
1977 };
1978 let candidate = comp.items[comp.index].clone();
1979 let plain = candidate
1980 .chars()
1981 .all(|c| c.is_alphanumeric() || matches!(c, '_' | ' '));
1982 let text = if plain {
1983 candidate
1984 } else {
1985 format!("`{candidate}`")
1986 };
1987 self.sql_replace_segment(comp.seg_start, &text);
1988 }
1989
1990 fn sql_replace_segment(&mut self, seg_start: usize, text: &str) {
1992 if let Some(console) = &mut self.sql {
1993 let from = byte_at(&console.input, seg_start);
1994 let to = byte_at(&console.input, console.cursor);
1995 console.input.replace_range(from..to, text);
1996 console.cursor = seg_start + text.chars().count();
1997 }
1998 }
1999
2000 pub fn sql_complete_next(&mut self, delta: i32) {
2002 let Some(comp) = &mut self.sql_complete else {
2003 return;
2004 };
2005 if comp.items.is_empty() {
2006 return;
2007 }
2008 let n = comp.items.len() as i32;
2009 comp.index = (comp.index as i32 + delta).rem_euclid(n) as usize;
2010 self.sql_complete_apply();
2011 }
2012
2013 pub fn sql_complete_cancel(&mut self) {
2015 if let Some(comp) = &self.sql_complete {
2016 let (seg_start, prefix) = (comp.seg_start, comp.prefix.clone());
2017 self.sql_replace_segment(seg_start, &prefix);
2018 }
2019 self.sql_complete = None;
2020 }
2021
2022 pub fn sql_complete_accept(&mut self) {
2024 self.sql_complete = None;
2025 }
2026
2027 fn fetch_uc_names(&mut self, cli: &Arc<DatabricksCli>, path: String) {
2028 let (tx, rx) = oneshot::channel();
2029 self.uc_names_rx = Some(rx);
2030 let cli = Arc::clone(cli);
2031 tokio::spawn(async move {
2032 let names = fetchers::catalog::names(&cli, &path)
2033 .await
2034 .map_err(|e| format!("{e:#}"));
2035 let _ = tx.send((path, names));
2036 });
2037 }
2038
2039 pub fn poll_uc_names(&mut self) -> bool {
2041 let Some(rx) = &mut self.uc_names_rx else {
2042 return false;
2043 };
2044 match rx.try_recv() {
2045 Ok((path, result)) => {
2046 self.uc_names_rx = None;
2047 match result {
2048 Ok(names) => {
2049 self.uc_names.insert(path, names);
2050 if self.sql_complete.as_ref().is_some_and(|c| c.loading) {
2051 if let Some(c) = &mut self.sql_complete {
2052 c.loading = false;
2053 }
2054 self.sql_complete_fill();
2055 }
2056 }
2057 Err(e) => {
2058 self.sql_complete = None;
2059 let first = e.lines().next().unwrap_or("fetch failed").to_string();
2060 self.flash = Some((format!("✗ completions: {first}"), Instant::now()));
2061 }
2062 }
2063 true
2064 }
2065 Err(oneshot::error::TryRecvError::Empty) => false,
2066 Err(oneshot::error::TryRecvError::Closed) => {
2067 self.uc_names_rx = None;
2068 self.sql_complete = None;
2069 true
2070 }
2071 }
2072 }
2073
2074 pub fn sql_input(&self) -> Option<String> {
2076 self.sql.as_ref().map(|c| c.input.clone())
2077 }
2078
2079 pub fn sql_set_input(&mut self, s: &str) {
2081 if let Some(console) = &mut self.sql {
2082 console.input = s.to_string();
2083 console.cursor = console.input.chars().count();
2084 }
2085 }
2086
2087 pub fn hist_search_current(&self) -> Option<&String> {
2089 let (query, nth) = self.hist_search.as_ref()?;
2090 self.sql_history
2091 .iter()
2092 .rev()
2093 .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
2094 .nth(*nth)
2095 }
2096
2097 pub fn hist_search_start(&mut self) {
2098 if self.sql.is_some() {
2099 self.hist_search = Some((String::new(), 0));
2100 }
2101 }
2102
2103 pub fn hist_search_push(&mut self, c: char) {
2104 if let Some((query, nth)) = &mut self.hist_search {
2105 query.push(c);
2106 *nth = 0;
2107 }
2108 }
2109
2110 pub fn hist_search_pop(&mut self) {
2111 if let Some((query, nth)) = &mut self.hist_search {
2112 query.pop();
2113 *nth = 0;
2114 }
2115 }
2116
2117 pub fn hist_search_older(&mut self) {
2119 let Some((query, nth)) = &self.hist_search else {
2120 return;
2121 };
2122 let q = query.to_lowercase();
2123 let matches = self
2124 .sql_history
2125 .iter()
2126 .filter(|h| h.to_lowercase().contains(&q))
2127 .count();
2128 if nth + 1 < matches {
2129 if let Some((_, n)) = &mut self.hist_search {
2130 *n += 1;
2131 }
2132 }
2133 }
2134
2135 pub fn hist_search_accept(&mut self) {
2136 if let Some(stmt) = self.hist_search_current().cloned() {
2137 self.sql_set_input(&stmt);
2138 }
2139 self.hist_search = None;
2140 }
2141
2142 pub fn hist_search_cancel(&mut self) {
2143 self.hist_search = None;
2144 }
2145
2146 pub fn sql_push(&mut self, c: char) {
2147 if let Some(console) = &mut self.sql {
2148 let at = byte_at(&console.input, console.cursor);
2149 console.input.insert(at, c);
2150 console.cursor += 1;
2151 }
2152 }
2153
2154 pub fn sql_pop(&mut self) {
2156 if let Some(console) = &mut self.sql {
2157 if console.cursor > 0 {
2158 let at = byte_at(&console.input, console.cursor - 1);
2159 console.input.remove(at);
2160 console.cursor -= 1;
2161 }
2162 }
2163 }
2164
2165 pub fn sql_delete(&mut self) {
2167 if let Some(console) = &mut self.sql {
2168 if console.cursor < console.input.chars().count() {
2169 let at = byte_at(&console.input, console.cursor);
2170 console.input.remove(at);
2171 }
2172 }
2173 }
2174
2175 pub fn sql_left(&mut self) {
2176 if let Some(console) = &mut self.sql {
2177 console.cursor = console.cursor.saturating_sub(1);
2178 }
2179 }
2180
2181 pub fn sql_right(&mut self) {
2182 if let Some(console) = &mut self.sql {
2183 console.cursor = (console.cursor + 1).min(console.input.chars().count());
2184 }
2185 }
2186
2187 pub fn sql_hist_prev(&mut self) {
2189 let Some(console) = &mut self.sql else {
2190 return;
2191 };
2192 if self.sql_history.is_empty() {
2193 return;
2194 }
2195 let idx = match self.hist_idx {
2196 None => {
2197 self.hist_draft = console.input.clone();
2198 self.sql_history.len() - 1
2199 }
2200 Some(i) => i.saturating_sub(1),
2201 };
2202 self.hist_idx = Some(idx);
2203 console.input = self.sql_history[idx].clone();
2204 console.cursor = console.input.chars().count();
2205 }
2206
2207 pub fn sql_hist_next(&mut self) {
2209 let Some(console) = &mut self.sql else {
2210 return;
2211 };
2212 let Some(idx) = self.hist_idx else {
2213 return;
2214 };
2215 if idx + 1 < self.sql_history.len() {
2216 self.hist_idx = Some(idx + 1);
2217 console.input = self.sql_history[idx + 1].clone();
2218 } else {
2219 self.hist_idx = None;
2220 console.input = self.hist_draft.clone();
2221 }
2222 console.cursor = console.input.chars().count();
2223 }
2224
2225 pub fn sql_home(&mut self) {
2226 if let Some(console) = &mut self.sql {
2227 console.cursor = 0;
2228 }
2229 }
2230
2231 pub fn sql_end(&mut self) {
2232 if let Some(console) = &mut self.sql {
2233 console.cursor = console.input.chars().count();
2234 }
2235 }
2236
2237 pub fn sql_scroll(&mut self, delta: i32) {
2238 if let Some(console) = &mut self.sql {
2239 let max = match &console.data {
2240 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2241 _ => 0,
2242 };
2243 console.scroll = if delta < 0 {
2244 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
2245 } else {
2246 (console.scroll + delta as usize).min(max)
2247 };
2248 }
2249 }
2250
2251 pub fn sql_cols(&mut self, delta: i32) {
2253 if let Some(console) = &mut self.sql {
2254 let n = match &console.data {
2255 Some(Ok(t)) => t.headers.len(),
2256 _ => 0,
2257 };
2258 console.col = if delta < 0 {
2259 console.col.saturating_sub(1)
2260 } else {
2261 (console.col + 1).min(n.saturating_sub(1))
2262 };
2263 }
2264 }
2265
2266 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
2268 let Some(console) = &self.sql else {
2269 return;
2270 };
2271 if console.running {
2272 return;
2273 }
2274 let query = console.input.trim().to_string();
2275 if query.is_empty() {
2276 return;
2277 }
2278 if self.sql_history.last() != Some(&query) {
2281 self.sql_history.push(query.clone());
2282 save_history(&self.sql_history);
2283 }
2284 self.hist_idx = None;
2285 self.hist_draft.clear();
2286 let warehouses = self.warehouses();
2287 if warehouses.is_empty() {
2288 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
2289 return;
2290 }
2291 if let Some((id, name)) = self.preview_warehouse.clone() {
2292 self.start_sql_query(cli, query, id, name);
2293 return;
2294 }
2295 if let [(name, id, _)] = warehouses.as_slice() {
2296 self.preview_warehouse = Some((id.clone(), name.clone()));
2297 self.start_sql_query(cli, query, id.clone(), name.clone());
2298 return;
2299 }
2300 let index = warehouses
2301 .iter()
2302 .position(|(_, _, running)| *running)
2303 .unwrap_or(0);
2304 self.wh_picker = Some(WhPicker {
2305 index,
2306 target: PickTarget::Sql(query),
2307 });
2308 }
2309
2310 fn start_sql_query(
2311 &mut self,
2312 cli: &Arc<DatabricksCli>,
2313 query: String,
2314 id: String,
2315 name: String,
2316 ) {
2317 if let Some(console) = &mut self.sql {
2318 console.running = true;
2319 console.warehouse = name;
2320 console.scroll = 0;
2321 console.last_sql = query.clone();
2322 }
2323 let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
2325 self.sql_stmt = Some(std::sync::Arc::clone(&handle));
2326 let (tx, rx) = oneshot::channel();
2327 self.sql_rx = Some(rx);
2328 let cli = Arc::clone(cli);
2329 tokio::spawn(async move {
2330 let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
2331 let _ = tx.send(result);
2332 });
2333 }
2334
2335 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
2338 let stamp = std::time::SystemTime::now()
2339 .duration_since(std::time::UNIX_EPOCH)
2340 .map(|d| d.as_secs())
2341 .unwrap_or(0);
2342 let slug: String = label
2343 .chars()
2344 .map(|c| if c.is_alphanumeric() { c } else { '-' })
2345 .collect::<String>()
2346 .trim_matches('-')
2347 .chars()
2348 .take(40)
2349 .collect();
2350 let name = format!("databricks-{slug}-{stamp}.csv");
2351 let msg = match std::fs::write(&name, data.to_csv()) {
2352 Ok(()) => {
2353 let cwd = std::env::current_dir()
2354 .map(|d| d.display().to_string())
2355 .unwrap_or_default();
2356 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
2357 }
2358 Err(e) => format!("✗ export failed: {e}"),
2359 };
2360 self.flash = Some((msg, Instant::now()));
2361 }
2362
2363 pub fn sql_export(&mut self) {
2365 if let Some(SqlConsole {
2366 data: Some(Ok(data)),
2367 last_sql,
2368 ..
2369 }) = &self.sql
2370 {
2371 let (label, data) = (last_sql.clone(), data.clone());
2372 self.export_csv(&label, &data);
2373 }
2374 }
2375
2376 pub fn preview_h(&mut self, delta: i32) {
2379 let Some(pv) = &mut self.preview else {
2380 return;
2381 };
2382 if pv.record {
2383 let max = match &pv.data {
2384 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2385 _ => 0,
2386 };
2387 pv.scroll = if delta < 0 {
2388 pv.scroll.saturating_sub(1)
2389 } else {
2390 (pv.scroll + 1).min(max)
2391 };
2392 pv.rscroll = 0;
2393 } else {
2394 let cols = pv.visible_cols().len();
2395 pv.col = if delta < 0 {
2396 pv.col.saturating_sub(1)
2397 } else {
2398 (pv.col + 1).min(cols.saturating_sub(1))
2399 };
2400 }
2401 }
2402
2403 pub fn preview_toggle_record(&mut self) {
2405 if let Some(pv) = &mut self.preview {
2406 if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
2407 pv.record = !pv.record;
2408 pv.rscroll = 0;
2409 }
2410 }
2411 }
2412
2413 pub fn preview_filter_start(&mut self) {
2414 if let Some(pv) = &mut self.preview {
2415 pv.filter.clear();
2416 pv.filter_entry = true;
2417 pv.col = 0;
2418 pv.rscroll = 0;
2419 }
2420 }
2421
2422 pub fn preview_filter_push(&mut self, c: char) {
2423 if let Some(pv) = &mut self.preview {
2424 pv.filter.push(c);
2425 pv.col = 0;
2426 pv.rscroll = 0;
2427 }
2428 }
2429
2430 pub fn preview_filter_pop(&mut self) {
2431 if let Some(pv) = &mut self.preview {
2432 pv.filter.pop();
2433 pv.col = 0;
2434 }
2435 }
2436
2437 pub fn preview_filter_accept(&mut self) {
2438 if let Some(pv) = &mut self.preview {
2439 pv.filter_entry = false;
2440 }
2441 }
2442
2443 pub fn preview_filter_clear(&mut self) {
2444 if let Some(pv) = &mut self.preview {
2445 pv.filter.clear();
2446 pv.filter_entry = false;
2447 pv.col = 0;
2448 pv.rscroll = 0;
2449 }
2450 }
2451
2452 pub fn preview_export(&mut self) {
2454 if let Some(Preview {
2455 data: Some(Ok(data)),
2456 name,
2457 ..
2458 }) = &self.preview
2459 {
2460 let (label, data) = (name.clone(), data.clone());
2461 self.export_csv(&label, &data);
2462 }
2463 }
2464
2465 pub fn poll_sql(&mut self) -> bool {
2466 let Some(rx) = &mut self.sql_rx else {
2467 return false;
2468 };
2469 match rx.try_recv() {
2470 Ok(result) => {
2471 if let Err(e) = &result {
2474 if e != "statement canceled" {
2475 self.preview_warehouse = None;
2476 }
2477 }
2478 if let Some(console) = &mut self.sql {
2479 console.running = false;
2480 console.data = Some(result);
2481 console.col = 0;
2482 }
2483 self.sql_rx = None;
2484 self.sql_stmt = None;
2485 true
2486 }
2487 Err(oneshot::error::TryRecvError::Empty) => false,
2488 Err(oneshot::error::TryRecvError::Closed) => {
2489 if let Some(console) = &mut self.sql {
2490 console.running = false;
2491 }
2492 self.sql_rx = None;
2493 true
2494 }
2495 }
2496 }
2497
2498 pub fn poll_cost(&mut self) -> bool {
2499 let Some(rx) = &mut self.cost_rx else {
2500 return false;
2501 };
2502 match rx.try_recv() {
2503 Ok((result, ws)) => {
2504 if result.is_err() {
2505 self.preview_warehouse = None;
2506 }
2507 if ws.is_some() {
2508 self.workspace_id = ws;
2509 }
2510 if let Some(cv) = &mut self.cost {
2511 cv.data = Some(result);
2512 }
2513 self.cost_rx = None;
2514 true
2515 }
2516 Err(oneshot::error::TryRecvError::Empty) => false,
2517 Err(oneshot::error::TryRecvError::Closed) => {
2518 self.cost_rx = None;
2519 true
2520 }
2521 }
2522 }
2523
2524 pub fn wh_picker_next(&mut self) {
2525 let len = self.warehouses().len();
2526 if let Some(p) = &mut self.wh_picker {
2527 p.index = (p.index + 1).min(len.saturating_sub(1));
2528 }
2529 }
2530
2531 pub fn wh_picker_prev(&mut self) {
2532 if let Some(p) = &mut self.wh_picker {
2533 p.index = p.index.saturating_sub(1);
2534 }
2535 }
2536
2537 pub fn wh_picker_cancel(&mut self) {
2538 self.wh_picker = None;
2539 }
2540
2541 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
2543 let Some(picker) = self.wh_picker.take() else {
2544 return;
2545 };
2546 let warehouses = self.warehouses();
2547 let Some((name, id, _)) = warehouses.get(picker.index) else {
2548 return;
2549 };
2550 self.preview_warehouse = Some((id.clone(), name.clone()));
2551 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
2553 self.config
2554 .warehouses
2555 .insert(profile, (id.clone(), name.clone()));
2556 self.config.save();
2557 match picker.target {
2558 PickTarget::Preview(table) => {
2559 self.start_preview_query(cli, table, id.clone(), name.clone())
2560 }
2561 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
2562 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
2563 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
2564 }
2565 }
2566
2567 fn start_preview_query(
2568 &mut self,
2569 cli: &Arc<DatabricksCli>,
2570 full_name: String,
2571 warehouse_id: String,
2572 warehouse_name: String,
2573 ) {
2574 self.preview = Some(Preview {
2575 name: full_name.clone(),
2576 warehouse: warehouse_name,
2577 warehouse_id: warehouse_id.clone(),
2578 data: None,
2579 scroll: 0,
2580 col: 0,
2581 filter: String::new(),
2582 filter_entry: false,
2583 record: false,
2584 rscroll: 0,
2585 });
2586 let (tx, rx) = oneshot::channel();
2587 self.preview_rx = Some(rx);
2588 let cli = Arc::clone(cli);
2589 tokio::spawn(async move {
2590 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
2591 let _ = tx.send(result);
2592 });
2593 }
2594
2595 pub fn close_preview(&mut self) {
2596 self.preview = None;
2597 self.preview_rx = None;
2598 }
2599
2600 pub fn poll_preview(&mut self) -> bool {
2601 let Some(rx) = &mut self.preview_rx else {
2602 return false;
2603 };
2604 match rx.try_recv() {
2605 Ok(result) => {
2606 if result.is_err() {
2608 self.preview_warehouse = None;
2609 }
2610 if let Some(pv) = &mut self.preview {
2611 pv.data = Some(result);
2612 }
2613 self.preview_rx = None;
2614 true
2615 }
2616 Err(oneshot::error::TryRecvError::Empty) => false,
2617 Err(oneshot::error::TryRecvError::Closed) => {
2618 self.preview_rx = None;
2619 true
2620 }
2621 }
2622 }
2623
2624 pub fn preview_scroll(&mut self, delta: i32) {
2625 if let Some(pv) = &mut self.preview {
2626 if pv.record {
2628 let max = pv.visible_cols().len().saturating_sub(1) as u16;
2629 pv.rscroll = if delta < 0 {
2630 pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
2631 } else {
2632 pv.rscroll.saturating_add(delta as u16).min(max)
2633 };
2634 return;
2635 }
2636 let max = match &pv.data {
2637 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2638 _ => 0,
2639 };
2640 pv.scroll = if delta < 0 {
2641 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
2642 } else {
2643 (pv.scroll + delta as usize).min(max)
2644 };
2645 }
2646 }
2647
2648 pub fn poll_uc(&mut self) -> bool {
2649 let Some(rx) = &mut self.uc_rx else {
2650 return false;
2651 };
2652 match rx.try_recv() {
2653 Ok(result) => {
2654 self.shapes[5] = Some(match result {
2655 Ok(shape) => shape,
2656 Err(e) => Shape::Text(format!("✗ {e}")),
2657 });
2658 self.updated_at[5] = Some(Instant::now());
2659 self.uc_rx = None;
2660 true
2661 }
2662 Err(oneshot::error::TryRecvError::Empty) => false,
2663 Err(oneshot::error::TryRecvError::Closed) => {
2664 self.uc_rx = None;
2665 true
2666 }
2667 }
2668 }
2669
2670 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
2673 if self.focus == Panel::Secrets {
2674 return self.open_secret_acls(cli);
2675 }
2676 let Some(item) = self.selected_item() else {
2677 return;
2678 };
2679 let Some(id) = item.id.clone() else {
2680 return;
2681 };
2682 let (uc, object_type): (bool, &'static str) = match self.focus {
2683 Panel::Catalog => match &item.status {
2684 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
2685 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
2686 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
2687 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
2688 _ => return,
2689 },
2690 Panel::Clusters => (false, "clusters"),
2691 Panel::Jobs => (false, "jobs"),
2692 Panel::Pipelines => (false, "pipelines"),
2693 Panel::Warehouses => (false, "warehouses"),
2694 Panel::Dashboards => (false, "dashboards"),
2695 Panel::Secrets => return,
2697 };
2698 self.detail = Some(Detail {
2699 panel: self.focus,
2700 name: item.name.clone(),
2701 id: id.clone(),
2702 kind: None,
2703 section: "Access",
2704 data: None,
2705 show_raw: false,
2706 scroll: 0,
2707 });
2708 let (tx, rx) = oneshot::channel();
2709 self.detail_rx = Some(rx);
2710 let cli = Arc::clone(cli);
2711 tokio::spawn(async move {
2712 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
2713 let _ = tx.send(data);
2714 });
2715 }
2716
2717 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
2720 let Some(d) = &self.detail else {
2721 return;
2722 };
2723 let panel = d.panel;
2724 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
2725 return;
2726 }
2727 let owner_id = d.id.clone();
2728 self.run_view = Some(RunView {
2729 panel,
2730 owner_name: d.name.clone(),
2731 owner_id: owner_id.clone(),
2732 runs: Vec::new(),
2733 idx: 0,
2734 data: None,
2735 show_raw: false,
2736 scroll: 0,
2737 live: false,
2738 output: None,
2739 show_output: false,
2740 show_timeline: false,
2741 show_dag: false,
2742 fetched_at: Instant::now(),
2743 });
2744 let (tx, rx) = oneshot::channel();
2745 self.run_rx = Some(rx);
2746 let cli = Arc::clone(cli);
2747 tokio::spawn(async move {
2748 let result = async {
2749 let runs = if panel == Panel::Jobs {
2750 fetchers::runs::list(&cli, &owner_id).await?
2751 } else {
2752 fetchers::updates::list(&cli, &owner_id).await?
2753 };
2754 let Some((run_id, _, _)) = runs.first().cloned() else {
2755 return Err("no runs recorded yet".to_string());
2756 };
2757 let (data, live) = if panel == Panel::Jobs {
2758 fetchers::runs::fetch(&cli, &run_id).await
2759 } else {
2760 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2761 };
2762 Ok((runs, data, live))
2763 }
2764 .await;
2765 let _ = tx.send(RunUpdate::Opened(result));
2766 });
2767 }
2768
2769 pub fn close_run(&mut self) {
2770 self.run_view = None;
2771 self.run_rx = None;
2772 }
2773
2774 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
2776 if self.run_rx.is_some() {
2777 return;
2778 }
2779 let Some(rv) = &mut self.run_view else {
2780 return;
2781 };
2782 if rv.runs.is_empty() {
2783 return;
2784 }
2785 let new = if delta < 0 {
2786 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
2787 } else {
2788 (rv.idx + delta as usize).min(rv.runs.len() - 1)
2789 };
2790 if new == rv.idx {
2791 return;
2792 }
2793 rv.idx = new;
2794 rv.data = None;
2795 rv.scroll = 0;
2796 rv.show_raw = false;
2797 rv.output = None;
2798 rv.show_output = false;
2799 let run_id = rv.runs[new].0.clone();
2800 self.start_run_fetch(cli, run_id);
2801 }
2802
2803 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
2804 let Some(rv) = &self.run_view else {
2805 return;
2806 };
2807 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
2808 let (tx, rx) = oneshot::channel();
2809 self.run_rx = Some(rx);
2810 let cli = Arc::clone(cli);
2811 tokio::spawn(async move {
2812 let (data, live) = if panel == Panel::Jobs {
2813 fetchers::runs::fetch(&cli, &run_id).await
2814 } else {
2815 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2816 };
2817 let _ = tx.send(RunUpdate::Detail(data, live));
2818 });
2819 }
2820
2821 pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
2824 let Some(rv) = &mut self.run_view else {
2825 return;
2826 };
2827 if rv.panel != Panel::Jobs {
2828 self.flash = Some((
2829 "✗ output view is for job runs — pipeline events are already inline".to_string(),
2830 Instant::now(),
2831 ));
2832 return;
2833 }
2834 if rv.show_output {
2835 rv.show_output = false;
2836 rv.scroll = 0;
2837 return;
2838 }
2839 if rv.output.is_none() && self.run_rx.is_some() {
2840 self.flash = Some((
2841 "⏳ run still loading — try again in a moment".to_string(),
2842 Instant::now(),
2843 ));
2844 return;
2845 }
2846 rv.show_output = true;
2847 rv.scroll = 0;
2848 if rv.output.is_some() {
2849 return;
2850 }
2851 self.start_output_fetch(cli);
2852 }
2853
2854 fn start_output_fetch(&mut self, cli: &Arc<DatabricksCli>) {
2855 let Some(rv) = &self.run_view else {
2856 return;
2857 };
2858 let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
2859 return;
2860 };
2861 let (tx, rx) = oneshot::channel();
2862 self.run_rx = Some(rx);
2863 let cli = Arc::clone(cli);
2864 tokio::spawn(async move {
2865 let (text, live) = fetchers::runs::full_output(&cli, &run_id).await;
2866 let _ = tx.send(RunUpdate::Output(text, live));
2867 });
2868 }
2869
2870 pub fn request_run_repair(&mut self) {
2872 let Some(rv) = &self.run_view else {
2873 return;
2874 };
2875 if rv.panel != Panel::Jobs {
2876 self.flash = Some((
2877 "✗ repair applies to job runs only".to_string(),
2878 Instant::now(),
2879 ));
2880 return;
2881 }
2882 if rv.live {
2883 self.flash = Some((
2884 "✗ run is still executing — cancel it first (s)".to_string(),
2885 Instant::now(),
2886 ));
2887 return;
2888 }
2889 let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
2890 return;
2891 };
2892 if matches!(status, Status::Success) {
2893 self.flash = Some((
2894 "✗ run succeeded — nothing to repair".to_string(),
2895 Instant::now(),
2896 ));
2897 return;
2898 }
2899 self.confirm = Some(Confirm {
2900 message: format!(
2901 "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
2902 rv.owner_name
2903 ),
2904 args: vec![
2905 "jobs".to_string(),
2906 "repair-run".to_string(),
2907 run_id.clone(),
2908 "--rerun-all-failed-tasks".to_string(),
2909 ],
2910 });
2911 }
2912
2913 pub fn run_toggle_timeline(&mut self) {
2915 let Some(rv) = &mut self.run_view else {
2916 return;
2917 };
2918 if rv.panel != Panel::Jobs {
2919 self.flash = Some((
2920 "✗ timeline is for job runs — pipeline events are already inline".to_string(),
2921 Instant::now(),
2922 ));
2923 return;
2924 }
2925 rv.show_timeline = !rv.show_timeline;
2926 rv.show_dag = false;
2927 rv.scroll = 0;
2928 }
2929
2930 pub fn run_toggle_dag(&mut self) {
2932 let Some(rv) = &mut self.run_view else {
2933 return;
2934 };
2935 if rv.panel != Panel::Jobs {
2936 self.flash = Some((
2937 "✗ the task DAG is for job runs — pipelines have no task graph here".to_string(),
2938 Instant::now(),
2939 ));
2940 return;
2941 }
2942 rv.show_dag = !rv.show_dag;
2943 rv.show_timeline = false;
2944 rv.scroll = 0;
2945 }
2946
2947 pub fn run_toggle_raw(&mut self) {
2948 if let Some(rv) = &mut self.run_view {
2949 rv.show_raw = !rv.show_raw;
2950 rv.scroll = 0;
2951 }
2952 }
2953
2954 pub fn run_scroll(&mut self, delta: i32) {
2955 if let Some(rv) = &mut self.run_view {
2956 rv.scroll = if delta < 0 {
2957 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
2958 } else {
2959 rv.scroll.saturating_add(delta as u16)
2960 };
2961 }
2962 }
2963
2964 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2967 if let Some(rx) = &mut self.run_rx {
2968 match rx.try_recv() {
2969 Ok(update) => {
2970 self.run_rx = None;
2971 if let Some(rv) = &mut self.run_view {
2972 match update {
2973 RunUpdate::Opened(Ok((runs, data, live))) => {
2974 rv.runs = runs;
2975 rv.idx = 0;
2976 rv.data = Some(data);
2977 rv.live = live;
2978 }
2979 RunUpdate::Opened(Err(e)) => {
2980 rv.data = Some(DetailData {
2981 summary: Vec::new(),
2982 activity: Vec::new(),
2983 raw: format!("✗ {e}"),
2984 });
2985 rv.live = false;
2986 }
2987 RunUpdate::Detail(data, live) => {
2988 rv.data = Some(data);
2989 rv.live = live;
2990 }
2991 RunUpdate::Output(text, live) => {
2992 rv.output = Some(text);
2993 rv.live = live;
2994 }
2995 }
2996 rv.fetched_at = Instant::now();
2997 }
2998 true
2999 }
3000 Err(oneshot::error::TryRecvError::Empty) => false,
3001 Err(oneshot::error::TryRecvError::Closed) => {
3002 self.run_rx = None;
3003 true
3004 }
3005 }
3006 } else if let Some(rv) = &self.run_view {
3007 if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
3008 if rv.show_output {
3009 if rv.output.is_some() {
3012 self.start_output_fetch(cli);
3013 }
3014 } else if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
3015 self.start_run_fetch(cli, run_id);
3016 }
3017 }
3018 false
3019 } else {
3020 false
3021 }
3022 }
3023
3024 pub fn close_detail(&mut self) {
3025 self.detail = None;
3026 self.detail_rx = None;
3027 }
3028
3029 pub fn toggle_raw(&mut self) {
3030 if let Some(d) = &mut self.detail {
3031 d.show_raw = !d.show_raw;
3032 d.scroll = 0;
3033 }
3034 }
3035
3036 pub fn poll_detail(&mut self) -> bool {
3038 let Some(rx) = &mut self.detail_rx else {
3039 return false;
3040 };
3041 match rx.try_recv() {
3042 Ok(data) => {
3043 if let Some(d) = &mut self.detail {
3044 d.data = Some(data);
3045 }
3046 self.detail_rx = None;
3047 true
3048 }
3049 Err(oneshot::error::TryRecvError::Empty) => false,
3050 Err(oneshot::error::TryRecvError::Closed) => {
3051 self.detail_rx = None;
3052 true
3053 }
3054 }
3055 }
3056
3057 pub fn detail_scroll(&mut self, delta: i32) {
3058 if let Some(d) = &mut self.detail {
3059 let max = match &d.data {
3060 Some(data) if d.show_raw => data.raw.lines().count(),
3061 Some(data) => data.summary.len() + data.activity.len() + 3,
3062 None => 0,
3063 } as u16;
3064 d.scroll = if delta < 0 {
3065 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
3066 } else {
3067 (d.scroll + delta as u16).min(max.saturating_sub(1))
3068 };
3069 }
3070 }
3071
3072 pub fn request_action(&mut self) {
3075 if matches!(
3077 self.focus,
3078 Panel::Dashboards | Panel::Catalog | Panel::Secrets
3079 ) {
3080 return;
3081 }
3082 let Some(item) = self.selected_item() else {
3083 return;
3084 };
3085 let Some(id) = item.id.clone() else {
3086 return;
3087 };
3088 let name = item.name.clone();
3089 let active = matches!(
3090 item.status,
3091 Status::Running | Status::Pending | Status::Success
3092 );
3093 let group = self.focus.cli_group();
3094 let (verb, action): (&str, &str) = match self.focus {
3095 Panel::Jobs => ("Run", "run-now"),
3096 Panel::Clusters if active => ("Stop", "delete"),
3097 Panel::Pipelines if active => ("Stop", "stop"),
3098 Panel::Pipelines => ("Start update for", "start-update"),
3099 _ if active => ("Stop", "stop"),
3100 _ => ("Start", "start"),
3101 };
3102 self.confirm = Some(Confirm {
3103 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
3104 args: vec![group.to_string(), action.to_string(), id],
3105 });
3106 }
3107
3108 pub fn request_run_cancel(&mut self) {
3110 let Some(rv) = &self.run_view else {
3111 return;
3112 };
3113 if !rv.live {
3114 self.flash = Some((
3115 "✗ nothing to cancel — this run already finished".to_string(),
3116 Instant::now(),
3117 ));
3118 return;
3119 }
3120 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3121 return;
3122 };
3123 let (message, args) = if rv.panel == Panel::Jobs {
3124 (
3125 format!("Cancel run {run_id} of “{}”?", rv.owner_name),
3126 vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
3127 )
3128 } else {
3129 (
3130 format!("Stop “{}” (cancels the active update)?", rv.owner_name),
3131 vec![
3132 "pipelines".to_string(),
3133 "stop".to_string(),
3134 rv.owner_id.clone(),
3135 ],
3136 )
3137 };
3138 self.confirm = Some(Confirm { message, args });
3139 }
3140
3141 pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
3144 let id = self
3145 .sql_stmt
3146 .as_ref()
3147 .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
3148 let Some(id) = id else {
3149 self.flash = Some((
3150 "✗ statement not submitted yet — try again in a moment".to_string(),
3151 Instant::now(),
3152 ));
3153 return;
3154 };
3155 let cli = Arc::clone(cli);
3156 tokio::spawn(async move {
3157 let path = format!("/api/2.0/sql/statements/{id}/cancel");
3158 let _ = cli.run_action(&["api", "post", &path]).await;
3159 });
3160 self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
3161 }
3162
3163 pub fn cancel_confirm(&mut self) {
3164 self.confirm = None;
3165 }
3166
3167 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
3168 let Some(c) = self.confirm.take() else {
3169 return;
3170 };
3171 let base = c.message.trim_end_matches('?').to_string();
3172 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3173
3174 let (tx, rx) = oneshot::channel();
3175 self.action_rx = Some(rx);
3176 let cli = Arc::clone(cli);
3177 tokio::spawn(async move {
3178 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
3179 let result = match cli.run_action(&args).await {
3180 Ok(()) => Ok(format!("✓ {base} — done")),
3181 Err(e) => Err(format!("✗ {e:#}")),
3182 };
3183 let _ = tx.send(result);
3184 });
3185 }
3186
3187 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3189 let Some(rx) = &mut self.action_rx else {
3190 return false;
3191 };
3192 match rx.try_recv() {
3193 Ok(result) => {
3194 let ok = result.is_ok();
3195 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
3196 self.action_rx = None;
3197 if ok {
3198 self.start_refresh(cli);
3199 if self.run_rx.is_none() {
3202 let current = self
3203 .run_view
3204 .as_ref()
3205 .and_then(|rv| rv.runs.get(rv.idx).cloned());
3206 if let Some((run_id, _, _)) = current {
3207 self.start_run_fetch(cli, run_id);
3208 }
3209 }
3210 }
3211 true
3212 }
3213 Err(oneshot::error::TryRecvError::Empty) => false,
3214 Err(oneshot::error::TryRecvError::Closed) => {
3215 self.action_rx = None;
3216 true
3217 }
3218 }
3219 }
3220
3221 pub fn expire_flash(&mut self) -> bool {
3223 if let Some((_, since)) = &self.flash {
3224 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
3225 self.flash = None;
3226 return true;
3227 }
3228 }
3229 false
3230 }
3231
3232 pub fn open_in_browser(&self) {
3234 let Some(host) = &self.host else {
3235 return;
3236 };
3237 let (panel, id) = match &self.detail {
3238 Some(d) => (d.panel, Some(d.id.clone())),
3239 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
3240 };
3241 let Some(id) = id else {
3242 return;
3243 };
3244 let path = match panel {
3245 Panel::Clusters => format!("compute/clusters/{id}"),
3246 Panel::Jobs => format!("jobs/{id}"),
3247 Panel::Pipelines => format!("pipelines/{id}"),
3248 Panel::Warehouses => format!("sql/warehouses/{id}"),
3249 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
3250 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
3251 Panel::Secrets => return,
3253 };
3254 let url = format!("{}/{}", host.trim_end_matches('/'), path);
3255 #[cfg(target_os = "macos")]
3256 let opener = "open";
3257 #[cfg(not(target_os = "macos"))]
3258 let opener = "xdg-open";
3259 let _ = std::process::Command::new(opener).arg(url).spawn();
3260 }
3261
3262 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
3264 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
3265 for shape in self.shapes.iter().flatten() {
3266 if let Shape::List(items) = shape {
3267 for item in items {
3268 match item.status {
3269 Status::Running | Status::Success => ok += 1,
3270 Status::Pending => pending += 1,
3271 Status::Failed => failed += 1,
3272 Status::Stopped => idle += 1,
3273 Status::Unknown(_) => {}
3274 }
3275 }
3276 }
3277 }
3278 (ok, pending, failed, idle)
3279 }
3280
3281 pub fn last_refresh_age(&self) -> Duration {
3282 self.last_refresh.elapsed()
3283 }
3284
3285 pub fn spinner(&self) -> &'static str {
3286 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
3287 }
3288
3289 pub fn spinner_frame(&self) -> usize {
3290 self.spinner_frame
3291 }
3292
3293 pub fn busy(&self) -> bool {
3296 self.loading
3297 || self.detail_rx.is_some()
3298 || self.action_rx.is_some()
3299 || self.preview_rx.is_some()
3300 || self.cost_rx.is_some()
3301 || self.sql_rx.is_some()
3302 || self.run_rx.is_some()
3303 }
3304
3305 pub fn tick_spinner(&mut self) {
3306 self.spinner_frame = self.spinner_frame.wrapping_add(1);
3307 }
3308
3309 pub fn toggle_zoom(&mut self) {
3310 self.zoomed = !self.zoomed;
3311 }
3312
3313 pub fn focus_next(&mut self) {
3314 self.cycle_focus(1);
3315 }
3316
3317 pub fn focus_prev(&mut self) {
3318 self.cycle_focus(-1);
3319 }
3320
3321 fn cycle_focus(&mut self, delta: i32) {
3323 let visible = self.visible_panes();
3324 if visible.is_empty() {
3325 return;
3326 }
3327 let focus_idx = Panel::ALL
3328 .iter()
3329 .position(|p| p == &self.focus)
3330 .unwrap_or(0);
3331 let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
3332 let n = visible.len() as i32;
3333 let next = ((pos as i32 + delta) % n + n) % n;
3334 self.focus = Panel::ALL[visible[next as usize]];
3335 }
3336
3337 pub fn needs_refresh(&self) -> bool {
3338 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
3339 }
3340
3341 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
3342 if self.loading {
3343 return;
3344 }
3345 self.loading = true;
3346 self.error = None;
3347 self.last_refresh = Instant::now();
3348
3349 let (tx, rx) = mpsc::unbounded_channel();
3350 self.pending = Some(rx);
3351 self.in_flight = 8;
3352
3353 macro_rules! spawn_fetch {
3356 ($update:expr, $fetch:path) => {{
3357 let cli = Arc::clone(cli);
3358 let tx = tx.clone();
3359 tokio::spawn(async move {
3360 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
3361 let _ = tx.send($update(result));
3362 });
3363 }};
3364 }
3365
3366 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
3367 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
3368 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
3369 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
3370 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
3371 spawn_fetch!(
3372 |s: Result<Shape, String>| Update::Badge(s.ok()),
3373 fetchers::current_user::fetch
3374 );
3375 {
3376 let cli = Arc::clone(cli);
3377 let tx = tx.clone();
3378 let path = self.uc_path.clone();
3379 tokio::spawn(async move {
3380 let result = fetchers::catalog::fetch(&cli, &path)
3381 .await
3382 .map_err(|e| format!("{e:#}"));
3383 let _ = tx.send(Update::Panel(5, result));
3384 });
3385 }
3386 {
3387 let cli = Arc::clone(cli);
3388 let tx = tx.clone();
3389 let scope = self.secret_scope.clone();
3390 tokio::spawn(async move {
3391 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
3392 .await
3393 .map_err(|e| format!("{e:#}"));
3394 let _ = tx.send(Update::Panel(6, result));
3395 });
3396 }
3397 }
3398
3399 pub fn poll_refresh(&mut self) -> bool {
3401 let Some(rx) = &mut self.pending else {
3402 return false;
3403 };
3404 let mut changed = false;
3405 let mut updated_panes: Vec<usize> = Vec::new();
3406 loop {
3407 match rx.try_recv() {
3408 Ok(Update::Panel(i, result)) => {
3409 match result {
3410 Ok(mut shape) => {
3411 if i != 5 {
3415 if let Shape::List(items) = &mut shape {
3416 items.sort_by_key(|it| {
3417 (it.status.rank(), it.history.is_empty())
3418 });
3419 }
3420 }
3421 self.shapes[i] = Some(shape);
3422 self.updated_at[i] = Some(Instant::now());
3423 updated_panes.push(i);
3424 }
3425 Err(e) => {
3428 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
3429 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
3430 }
3431 }
3432 }
3433 self.in_flight -= 1;
3434 changed = true;
3435 }
3436 Ok(Update::Badge(badge)) => {
3437 if badge.is_some() {
3438 self.user_badge = badge;
3439 }
3440 self.in_flight -= 1;
3441 changed = true;
3442 }
3443 Err(mpsc::error::TryRecvError::Empty) => break,
3444 Err(mpsc::error::TryRecvError::Disconnected) => {
3445 self.in_flight = 0;
3446 break;
3447 }
3448 }
3449 }
3450 for i in updated_panes {
3451 self.alert_new_failures(i);
3452 }
3453 if self.in_flight == 0 {
3454 self.loading = false;
3455 self.pending = None;
3456 changed = true;
3457 }
3458 changed
3459 }
3460}
3461
3462#[cfg(test)]
3463mod tests {
3464 use super::{from_table, token_at_cursor};
3465
3466 #[test]
3467 fn token_bare_word() {
3468 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM ma", 16);
3469 assert_eq!((start, ctx.as_str(), prefix.as_str()), (14, "", "ma"));
3470 }
3471
3472 #[test]
3473 fn token_dotted_path() {
3474 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM main.sales.or", 27);
3475 assert_eq!(
3476 (start, ctx.as_str(), prefix.as_str()),
3477 (25, "main.sales", "or")
3478 );
3479 }
3480
3481 #[test]
3482 fn token_trailing_dot() {
3483 let (start, ctx, prefix) = token_at_cursor("main.", 5);
3484 assert_eq!((start, ctx.as_str(), prefix.as_str()), (5, "main", ""));
3485 }
3486
3487 #[test]
3488 fn token_mid_input() {
3489 let (start, ctx, prefix) = token_at_cursor("SELECT co FROM t", 9);
3491 assert_eq!((start, ctx.as_str(), prefix.as_str()), (7, "", "co"));
3492 }
3493
3494 #[test]
3495 fn from_table_fully_qualified() {
3496 assert_eq!(
3497 from_table("SELECT x FROM main.sales.orders WHERE x > 1").as_deref(),
3498 Some("main.sales.orders")
3499 );
3500 }
3501
3502 #[test]
3503 fn from_table_rejects_partial_names() {
3504 assert_eq!(from_table("SELECT x FROM orders"), None);
3505 assert_eq!(from_table("SELECT x FROM main.sales."), None);
3506 assert_eq!(from_table("SELECT 1"), None);
3507 }
3508}