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