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 CatppuccinMacchiato,
14 CatppuccinFrappe,
15 CatppuccinLatte,
16 GruvboxDark,
17 GruvboxLight,
18 Dracula,
19 Nord,
20 TokyoNight,
21 RosePine,
22 Everforest,
23 Kanagawa,
24 SolarizedDark,
25 OneDark,
26}
27
28impl ThemeMode {
29 pub const ALL: &'static [ThemeMode] = &[
30 ThemeMode::Dark,
31 ThemeMode::Light,
32 ThemeMode::CatppuccinMocha,
33 ThemeMode::CatppuccinMacchiato,
34 ThemeMode::CatppuccinFrappe,
35 ThemeMode::CatppuccinLatte,
36 ThemeMode::GruvboxDark,
37 ThemeMode::GruvboxLight,
38 ThemeMode::Dracula,
39 ThemeMode::Nord,
40 ThemeMode::TokyoNight,
41 ThemeMode::RosePine,
42 ThemeMode::Everforest,
43 ThemeMode::Kanagawa,
44 ThemeMode::SolarizedDark,
45 ThemeMode::OneDark,
46 ];
47
48 pub fn toggled(self) -> Self {
50 let idx = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
51 Self::ALL[(idx + 1) % Self::ALL.len()]
52 }
53
54 pub fn name(&self) -> &'static str {
55 match self {
56 ThemeMode::Dark => "Dark (terminal colors)",
57 ThemeMode::Light => "Light",
58 ThemeMode::CatppuccinMocha => "Catppuccin Mocha",
59 ThemeMode::CatppuccinMacchiato => "Catppuccin Macchiato",
60 ThemeMode::CatppuccinFrappe => "Catppuccin Frappé",
61 ThemeMode::CatppuccinLatte => "Catppuccin Latte",
62 ThemeMode::GruvboxDark => "Gruvbox Dark",
63 ThemeMode::GruvboxLight => "Gruvbox Light",
64 ThemeMode::Dracula => "Dracula",
65 ThemeMode::Nord => "Nord",
66 ThemeMode::TokyoNight => "Tokyo Night",
67 ThemeMode::RosePine => "Rosé Pine",
68 ThemeMode::Everforest => "Everforest Dark",
69 ThemeMode::Kanagawa => "Kanagawa",
70 ThemeMode::SolarizedDark => "Solarized Dark",
71 ThemeMode::OneDark => "One Dark",
72 }
73 }
74
75 pub fn id(&self) -> &'static str {
77 match self {
78 ThemeMode::Dark => "dark",
79 ThemeMode::Light => "light",
80 ThemeMode::CatppuccinMocha => "catppuccin-mocha",
81 ThemeMode::CatppuccinMacchiato => "catppuccin-macchiato",
82 ThemeMode::CatppuccinFrappe => "catppuccin-frappe",
83 ThemeMode::CatppuccinLatte => "catppuccin-latte",
84 ThemeMode::GruvboxDark => "gruvbox",
85 ThemeMode::GruvboxLight => "gruvbox-light",
86 ThemeMode::Dracula => "dracula",
87 ThemeMode::Nord => "nord",
88 ThemeMode::TokyoNight => "tokyo-night",
89 ThemeMode::RosePine => "rose-pine",
90 ThemeMode::Everforest => "everforest",
91 ThemeMode::Kanagawa => "kanagawa",
92 ThemeMode::SolarizedDark => "solarized-dark",
93 ThemeMode::OneDark => "one-dark",
94 }
95 }
96
97 pub fn from_id(id: &str) -> Option<Self> {
98 Self::ALL.iter().copied().find(|t| t.id() == id)
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum Panel {
104 Clusters,
105 Jobs,
106 Pipelines,
107 Warehouses,
108 Dashboards,
109 Catalog,
110 Secrets,
111}
112
113impl Panel {
114 pub const ALL: &'static [Panel] = &[
115 Panel::Clusters,
116 Panel::Jobs,
117 Panel::Pipelines,
118 Panel::Warehouses,
119 Panel::Dashboards,
120 Panel::Catalog,
121 Panel::Secrets,
122 ];
123
124 pub fn title(&self) -> &'static str {
125 match self {
126 Panel::Clusters => "Compute",
127 Panel::Jobs => "Lakeflow Jobs",
128 Panel::Pipelines => "Lakeflow Pipelines",
129 Panel::Warehouses => "SQL Warehouses",
130 Panel::Dashboards => "AI/BI Dashboards",
131 Panel::Catalog => "Unity Catalog",
132 Panel::Secrets => "Secret Scopes",
133 }
134 }
135
136 pub fn icon(&self) -> &'static str {
137 match self {
138 Panel::Clusters => "⌬",
139 Panel::Jobs => "⟳",
140 Panel::Pipelines => "⋙",
141 Panel::Warehouses => "⌁",
142 Panel::Dashboards => "▦",
143 Panel::Catalog => "⧉",
144 Panel::Secrets => "◈",
145 }
146 }
147
148 pub fn id(&self) -> &'static str {
150 match self {
151 Panel::Clusters => "compute",
152 Panel::Jobs => "jobs",
153 Panel::Pipelines => "pipelines",
154 Panel::Warehouses => "warehouses",
155 Panel::Dashboards => "dashboards",
156 Panel::Catalog => "catalog",
157 Panel::Secrets => "secrets",
158 }
159 }
160
161 pub fn cli_group(&self) -> &'static str {
163 match self {
164 Panel::Clusters => "clusters",
165 Panel::Jobs => "jobs",
166 Panel::Pipelines => "pipelines",
167 Panel::Warehouses => "warehouses",
168 Panel::Dashboards => "lakeview",
169 Panel::Catalog => "tables",
170 Panel::Secrets => "secrets",
171 }
172 }
173}
174
175pub struct Detail {
176 pub panel: Panel,
177 pub name: String,
178 pub id: String,
179 pub kind: Option<String>,
181 pub section: &'static str,
183 pub data: Option<DetailData>,
185 pub show_raw: bool,
187 pub scroll: u16,
188}
189
190pub struct Preview {
192 pub name: String,
193 pub warehouse: String,
195 pub warehouse_id: String,
196 pub data: Option<Result<crate::shape::TableData, String>>,
198 pub scroll: usize,
200 pub col: usize,
202 pub filter: String,
205 pub filter_entry: bool,
206 pub record: bool,
208 pub rscroll: u16,
210}
211
212impl Preview {
213 pub fn visible_cols(&self) -> Vec<usize> {
215 let Some(Ok(t)) = &self.data else {
216 return Vec::new();
217 };
218 let q = self.filter.to_lowercase();
219 (0..t.headers.len())
220 .filter(|&i| q.is_empty() || t.headers[i].to_lowercase().contains(&q))
221 .collect()
222 }
223}
224
225enum PickTarget {
227 Preview(String),
228 Cost,
229 ItemCost(fetchers::cost::ResourceKind, String, String),
231 Lineage(String),
232 Sql(String),
233}
234
235pub struct SqlConsole {
237 pub input: String,
238 pub cursor: usize,
240 pub warehouse: String,
242 pub running: bool,
243 pub data: Option<Result<crate::shape::TableData, String>>,
244 pub last_sql: String,
246 pub scroll: usize,
247 pub col: usize,
249}
250
251pub struct SqlComplete {
254 pub items: Vec<String>,
256 pub index: usize,
258 pub seg_start: usize,
261 prefix: String,
263 context: String,
265 pub loading: bool,
267}
268
269pub(crate) const SQL_KEYWORDS: &[&str] = &[
272 "SELECT",
273 "FROM",
274 "WHERE",
275 "GROUP BY",
276 "ORDER BY",
277 "HAVING",
278 "LIMIT",
279 "JOIN",
280 "LEFT JOIN",
281 "INNER JOIN",
282 "FULL OUTER JOIN",
283 "CROSS JOIN",
284 "ON",
285 "AS",
286 "AND",
287 "OR",
288 "NOT",
289 "IN",
290 "IS",
291 "NULL",
292 "DISTINCT",
293 "COUNT",
294 "SUM",
295 "AVG",
296 "MIN",
297 "MAX",
298 "UNION",
299 "UNION ALL",
300 "INSERT INTO",
301 "VALUES",
302 "UPDATE",
303 "SET",
304 "DELETE",
305 "CREATE",
306 "DROP",
307 "ALTER",
308 "SHOW",
309 "DESCRIBE",
310 "EXPLAIN",
311 "WITH",
312 "CASE",
313 "WHEN",
314 "THEN",
315 "ELSE",
316 "END",
317 "BETWEEN",
318 "LIKE",
319 "CAST",
320 "OVER",
321 "PARTITION BY",
322];
323
324fn token_at_cursor(input: &str, cursor: usize) -> (usize, String, String) {
327 let chars: Vec<char> = input.chars().collect();
328 let cursor = cursor.min(chars.len());
329 let mut start = cursor;
330 while start > 0 && (chars[start - 1].is_alphanumeric() || matches!(chars[start - 1], '_' | '.'))
331 {
332 start -= 1;
333 }
334 let token: String = chars[start..cursor].iter().collect();
335 match token.rfind('.') {
336 Some(dot) => {
337 let context = token[..dot].to_string();
338 let prefix = token[dot + 1..].to_string();
339 (start + context.chars().count() + 1, context, prefix)
340 }
341 None => (start, String::new(), token),
342 }
343}
344
345fn from_table(input: &str) -> Option<String> {
348 let pos = input.to_lowercase().find("from ")?;
349 let rest = input.get(pos + 5..)?.trim_start();
351 let table: String = rest
352 .chars()
353 .take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '.'))
354 .collect();
355 (table.matches('.').count() == 2 && !table.ends_with('.')).then_some(table)
356}
357
358fn history_path() -> Option<std::path::PathBuf> {
360 let home = std::env::var_os("HOME")?;
361 Some(
362 std::path::PathBuf::from(home)
363 .join(".config")
364 .join("databricks-tui")
365 .join("history"),
366 )
367}
368
369fn load_history() -> Vec<String> {
370 history_path()
371 .and_then(|p| std::fs::read_to_string(p).ok())
372 .map(|s| {
373 s.lines()
374 .filter(|l| !l.trim().is_empty())
375 .map(str::to_string)
376 .collect()
377 })
378 .unwrap_or_default()
379}
380
381fn save_history(history: &[String]) {
382 let Some(path) = history_path() else {
383 return;
384 };
385 if let Some(dir) = path.parent() {
386 let _ = std::fs::create_dir_all(dir);
387 crate::config::restrict(dir, 0o700);
388 }
389 let tail: Vec<&str> = history
391 .iter()
392 .rev()
393 .take(200)
394 .rev()
395 .map(String::as_str)
396 .collect();
397 let _ = std::fs::write(&path, tail.join("\n") + "\n");
399 crate::config::restrict(&path, 0o600);
400}
401
402fn subsequence(haystack: &str, needle: &str) -> bool {
404 let mut chars = haystack.chars();
405 needle.chars().all(|n| chars.any(|h| h == n))
406}
407
408fn byte_at(input: &str, cursor: usize) -> usize {
410 input
411 .char_indices()
412 .nth(cursor)
413 .map(|(i, _)| i)
414 .unwrap_or(input.len())
415}
416
417fn parse_params(input: &str) -> Result<Vec<(String, String)>, String> {
421 let mut pairs = Vec::new();
422 for seg in input.split(',') {
423 let seg = seg.trim();
424 if seg.is_empty() {
425 continue;
426 }
427 let Some((k, v)) = seg.split_once('=') else {
428 return Err(format!(
429 "✗ “{seg}” isn't key=value — separate parameters with commas"
430 ));
431 };
432 let k = k.trim();
433 if k.is_empty() {
434 return Err(format!("✗ “{seg}” is missing the parameter name"));
435 }
436 pairs.push((k.to_string(), v.trim().to_string()));
437 }
438 Ok(pairs)
439}
440
441pub struct WhPicker {
443 pub index: usize,
444 target: PickTarget,
445}
446
447pub struct CostView {
449 pub warehouse: String,
450 pub data: Option<Result<fetchers::cost::CostData, String>>,
451}
452
453pub struct ItemCostView {
455 pub warehouse: String,
456 pub kind: fetchers::cost::ResourceKind,
457 pub name: String,
459 pub id: String,
460 pub data: Option<Result<fetchers::cost::ResourceCost, String>>,
461}
462
463pub struct RunView {
466 pub panel: Panel,
468 pub owner_name: String,
469 owner_id: String,
471 pub runs: Vec<(String, Status, String)>,
473 pub idx: usize,
475 pub data: Option<DetailData>,
476 pub show_raw: bool,
477 pub scroll: u16,
478 pub live: bool,
480 pub output: Option<String>,
482 pub show_output: bool,
483 pub show_timeline: bool,
486 pub show_dag: bool,
489 pub show_grid: bool,
492 pub grid: Option<Result<fetchers::runs::GridData, String>>,
493 fetched_at: Instant,
494}
495
496type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
498
499enum RunUpdate {
500 Opened(Result<RunOpened, String>),
501 Detail(DetailData, bool),
502 Output(String, bool),
504}
505
506pub struct Problem {
508 pub panel: Option<usize>,
511 pub name: String,
512 pub status: Status,
513 pub note: String,
514 pub profile: Option<String>,
516}
517
518pub struct Problems {
522 pub items: Vec<Problem>,
523 pub index: usize,
524 pub scanning: bool,
526}
527
528pub struct Upcoming {
530 pub items: Vec<fetchers::upcoming::UpcomingJob>,
531 pub index: usize,
532 pub loading: bool,
533}
534
535pub struct Confirm {
537 pub message: String,
538 args: Vec<String>,
539 pub params: Option<(String, String)>,
542}
543
544pub struct ParamForm {
547 pub job_id: String,
548 pub job: String,
549 pub input: String,
550 pub cursor: usize,
552 pub kind: fetchers::jobs::ParamKind,
553 pub loading: bool,
555}
556
557pub struct Watched {
559 pub run_id: String,
560 pub job: String,
561}
562
563const WATCH_INTERVAL: Duration = Duration::from_secs(10);
565
566enum Update {
567 Panel(usize, Result<Shape, String>),
568 Badge(Option<Shape>),
569}
570
571const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
572
573pub struct App {
574 pub focus: Panel,
575 pub theme: ThemeMode,
576 pub zoomed: bool,
577 pub shapes: Vec<Option<Shape>>,
578 pub user_badge: Option<Shape>,
579 pub error: Option<String>,
580 pub refresh_interval: Duration,
581 last_refresh: Instant,
582 pub loading: bool,
583 pub detail: Option<Detail>,
584 pub confirm: Option<Confirm>,
585 pub flash: Option<(String, Instant)>,
586 pub selected: [usize; 7],
587 pub host: Option<String>,
588 pub profiles: Vec<String>,
590 pub profile: Option<String>,
591 pub picker: Option<usize>,
593 pub problems: Option<Problems>,
595 pub upcoming: Option<Upcoming>,
596 pub uc_path: Vec<String>,
598 uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
599 pub preview: Option<Preview>,
600 preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
601 pub wh_picker: Option<WhPicker>,
602 pub preview_warehouse: Option<(String, String)>,
604 pub cost: Option<CostView>,
605 #[allow(clippy::type_complexity)]
606 cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
607 pub item_cost: Option<ItemCostView>,
608 #[allow(clippy::type_complexity)]
609 item_cost_rx:
610 Option<oneshot::Receiver<(Result<fetchers::cost::ResourceCost, String>, Option<String>)>>,
611 workspace_id: Option<String>,
614 pub sql: Option<SqlConsole>,
615 sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
616 sql_history: Vec<String>,
618 hist_idx: Option<usize>,
620 hist_draft: String,
622 pub hist_search: Option<(String, usize)>,
624 pub run_view: Option<RunView>,
625 run_rx: Option<oneshot::Receiver<RunUpdate>>,
626 grid_rx: Option<oneshot::Receiver<Result<fetchers::runs::GridData, String>>>,
627 #[allow(clippy::type_complexity)]
628 upcoming_rx: Option<oneshot::Receiver<Result<Vec<fetchers::upcoming::UpcomingJob>, String>>>,
629 problems_rx: Option<oneshot::Receiver<Vec<fetchers::problems::RemoteProblem>>>,
630 pending: Option<mpsc::UnboundedReceiver<Update>>,
631 detail_rx: Option<oneshot::Receiver<DetailData>>,
632 action_rx: Option<oneshot::Receiver<Result<String, String>>>,
633 host_rx: Option<oneshot::Receiver<Option<String>>>,
634 in_flight: usize,
635 spinner_frame: usize,
636 pub splash_until: Option<Instant>,
638 pub updated_at: [Option<Instant>; 7],
640 pub filters: [String; 7],
642 pub filter_entry: bool,
644 pub fav_only: [bool; 7],
646 pub config: crate::config::Config,
648 failed_seen: [Option<std::collections::HashSet<(String, bool)>>; 7],
652 pub jump: Option<Jump>,
654 pub pane_order: Vec<usize>,
656 pub hidden: [bool; 7],
658 pub pane_cfg: Option<usize>,
660 pub help: bool,
662 pub help_scroll: u16,
664 sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
666 pub sql_complete: Option<SqlComplete>,
668 uc_names: std::collections::HashMap<String, Vec<String>>,
671 #[allow(clippy::type_complexity)]
672 uc_names_rx: Option<oneshot::Receiver<(String, Result<Vec<String>, String>)>>,
673 pub secret_scope: Option<String>,
675 secrets_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
676 pub secret_form: Option<SecretForm>,
678 pub param_form: Option<ParamForm>,
680 #[allow(clippy::type_complexity)]
681 param_rx: Option<
682 oneshot::Receiver<Result<(Vec<(String, String)>, fetchers::jobs::ParamKind), String>>,
683 >,
684 pub watched: Vec<Watched>,
686 #[allow(clippy::type_complexity)]
687 watch_rx: Option<oneshot::Receiver<Vec<(String, Result<(Status, bool), String>)>>>,
688 watch_at: Instant,
690}
691
692pub struct Jump {
694 pub query: String,
695 pub index: usize,
696}
697
698pub struct SecretForm {
700 pub scope: Option<String>,
702 pub key: String,
703 pub value: String,
704 pub stage: u8,
706}
707
708impl App {
709 pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
710 let mut app = Self {
711 focus: Panel::Clusters,
712 theme,
713 zoomed: false,
714 shapes: vec![None; 7],
715 user_badge: None,
716 error: None,
717 refresh_interval: Duration::from_secs(refresh_secs),
718 last_refresh: Instant::now()
719 .checked_sub(Duration::from_secs(refresh_secs + 1))
720 .unwrap_or(Instant::now()),
721 loading: false,
722 detail: None,
723 confirm: None,
724 flash: None,
725 selected: [0; 7],
726 host: None,
727 profiles: Vec::new(),
728 profile: None,
729 picker: None,
730 problems: None,
731 upcoming: None,
732 uc_path: Vec::new(),
733 uc_rx: None,
734 preview: None,
735 preview_rx: None,
736 wh_picker: None,
737 preview_warehouse: None,
738 cost: None,
739 cost_rx: None,
740 item_cost: None,
741 item_cost_rx: None,
742 workspace_id: None,
743 sql: None,
744 sql_rx: None,
745 sql_history: load_history(),
746 hist_idx: None,
747 hist_draft: String::new(),
748 hist_search: None,
749 run_view: None,
750 run_rx: None,
751 grid_rx: None,
752 upcoming_rx: None,
753 problems_rx: None,
754 pending: None,
755 detail_rx: None,
756 action_rx: None,
757 host_rx: None,
758 in_flight: 0,
759 spinner_frame: 0,
760 splash_until: Some(Instant::now() + Duration::from_millis(1600)),
761 updated_at: [None; 7],
762 filters: Default::default(),
763 filter_entry: false,
764 fav_only: [false; 7],
765 config: crate::config::Config::load(),
766 failed_seen: Default::default(),
767 jump: None,
768 pane_order: (0..7).collect(),
769 hidden: [false; 7],
770 pane_cfg: None,
771 help: false,
772 help_scroll: 0,
773 sql_stmt: None,
774 sql_complete: None,
775 uc_names: Default::default(),
776 uc_names_rx: None,
777 secret_scope: None,
778 secrets_rx: None,
779 secret_form: None,
780 param_form: None,
781 param_rx: None,
782 watched: Vec::new(),
783 watch_rx: None,
784 watch_at: Instant::now(),
785 };
786 app.load_pane_prefs();
787 app
788 }
789
790 fn load_pane_prefs(&mut self) {
792 let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
793 let mut order: Vec<usize> = self
794 .config
795 .pane_order
796 .iter()
797 .filter_map(|id| idx_of(id))
798 .collect();
799 for i in 0..7 {
800 if !order.contains(&i) {
801 order.push(i);
802 }
803 }
804 self.pane_order = order;
805 for id in &self.config.hidden_panes {
806 if let Some(i) = idx_of(id) {
807 self.hidden[i] = true;
808 }
809 }
810 self.ensure_focus_visible();
811 }
812
813 fn persist_panes(&mut self) {
814 self.config.pane_order = self
815 .pane_order
816 .iter()
817 .map(|&i| Panel::ALL[i].id().to_string())
818 .collect();
819 self.config.hidden_panes = (0..7)
820 .filter(|&i| self.hidden[i])
821 .map(|i| Panel::ALL[i].id().to_string())
822 .collect();
823 self.config.save();
824 }
825
826 pub fn visible_panes(&self) -> Vec<usize> {
828 self.pane_order
829 .iter()
830 .copied()
831 .filter(|&i| !self.hidden[i])
832 .collect()
833 }
834
835 fn ensure_focus_visible(&mut self) {
837 let visible = self.visible_panes();
838 let focus_idx = Panel::ALL
839 .iter()
840 .position(|p| p == &self.focus)
841 .unwrap_or(0);
842 if !visible.contains(&focus_idx) {
843 if let Some(&first) = visible.first() {
844 self.focus = Panel::ALL[first];
845 }
846 }
847 }
848
849 fn reveal_pane(&mut self, idx: usize) {
851 if self.hidden[idx] {
852 self.hidden[idx] = false;
853 self.persist_panes();
854 }
855 }
856
857 pub fn open_pane_cfg(&mut self) {
858 self.pane_cfg = Some(0);
859 }
860
861 pub fn pane_cfg_next(&mut self) {
862 if let Some(i) = self.pane_cfg {
863 self.pane_cfg = Some((i + 1).min(6));
864 }
865 }
866
867 pub fn pane_cfg_prev(&mut self) {
868 if let Some(i) = self.pane_cfg {
869 self.pane_cfg = Some(i.saturating_sub(1));
870 }
871 }
872
873 pub fn pane_cfg_toggle(&mut self) {
876 let Some(pos) = self.pane_cfg else {
877 return;
878 };
879 let idx = self.pane_order[pos];
880 if !self.hidden[idx] && self.visible_panes().len() == 1 {
881 self.flash = Some((
882 "✗ at least one pane has to stay visible".to_string(),
883 Instant::now(),
884 ));
885 return;
886 }
887 self.hidden[idx] = !self.hidden[idx];
888 self.ensure_focus_visible();
889 self.persist_panes();
890 }
891
892 pub fn pane_cfg_move(&mut self, delta: i32) {
894 let Some(pos) = self.pane_cfg else {
895 return;
896 };
897 let new = if delta < 0 {
898 pos.saturating_sub(1)
899 } else {
900 (pos + 1).min(6)
901 };
902 if new != pos {
903 self.pane_order.swap(pos, new);
904 self.pane_cfg = Some(new);
905 self.persist_panes();
906 }
907 }
908
909 fn alert_new_failures(&mut self, idx: usize) {
912 if idx >= 5 {
914 return;
915 }
916 let Some(Shape::List(items)) = &self.shapes[idx] else {
917 return;
918 };
919 let mut attention: std::collections::HashSet<(String, bool)> =
922 std::collections::HashSet::new();
923 for it in items {
924 let failed = matches!(it.status, Status::Failed)
925 || it
926 .history
927 .last()
928 .is_some_and(|s| matches!(s, Status::Failed));
929 if failed {
930 attention.insert((it.name.clone(), false));
931 }
932 if it.alert.is_some() {
933 attention.insert((it.name.clone(), true));
934 }
935 }
936 if let Some(prev) = &self.failed_seen[idx] {
937 let mut newly: Vec<&(String, bool)> = attention.difference(prev).collect();
938 if !newly.is_empty() {
939 newly.sort_by_key(|(name, slow)| (*slow, name.clone()));
941 let extra = if newly.len() > 1 {
942 format!(" (+{} more)", newly.len() - 1)
943 } else {
944 String::new()
945 };
946 let (name, slow) = newly[0];
947 self.flash = Some((
948 if *slow {
949 format!("⚠ {name}{extra} running much longer than usual — ! to inspect")
950 } else {
951 format!("✗ {name}{extra} just failed — ! to inspect")
952 },
953 Instant::now(),
954 ));
955 print!("\x07");
957 let _ = std::io::Write::flush(&mut std::io::stdout());
958 }
959 }
960 self.failed_seen[idx] = Some(attention);
961 }
962
963 pub fn persist_theme(&mut self) {
965 self.config.theme = Some(self.theme.id().to_string());
966 self.config.save();
967 }
968
969 pub fn restore_warehouse_pref(&mut self) {
971 let profile = self.profile.as_deref().unwrap_or("DEFAULT");
972 self.preview_warehouse = self.config.warehouses.get(profile).cloned();
973 }
974
975 pub fn splash_active(&self) -> bool {
976 self.splash_until
977 .map(|t| Instant::now() < t)
978 .unwrap_or(false)
979 }
980
981 pub fn dismiss_splash(&mut self) {
982 self.splash_until = None;
983 }
984
985 pub fn any_fresh(&self) -> bool {
987 self.updated_at
988 .iter()
989 .flatten()
990 .any(|t| t.elapsed() < Duration::from_millis(1200))
991 }
992
993 pub fn open_picker(&mut self) {
994 if self.profiles.is_empty() {
995 return;
996 }
997 let current = self
998 .profile
999 .as_deref()
1000 .and_then(|p| self.profiles.iter().position(|n| n == p))
1001 .unwrap_or(0);
1002 self.picker = Some(current);
1003 }
1004
1005 pub fn picker_next(&mut self) {
1006 if let Some(i) = self.picker {
1007 self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
1008 }
1009 }
1010
1011 pub fn picker_prev(&mut self) {
1012 if let Some(i) = self.picker {
1013 self.picker = Some(i.saturating_sub(1));
1014 }
1015 }
1016
1017 pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
1019 let idx = self.picker.take()?;
1020 let name = self.profiles.get(idx)?.clone();
1021 Some(self.switch_profile(name))
1022 }
1023
1024 fn switch_profile(&mut self, name: String) -> Arc<DatabricksCli> {
1027 let profile_arg = if name == "DEFAULT" {
1028 None
1029 } else {
1030 Some(name.clone())
1031 };
1032 self.profile = Some(name);
1033
1034 self.shapes = vec![None; 7];
1036 self.user_badge = None;
1037 self.host = None;
1038 self.selected = [0; 7];
1039 self.detail = None;
1040 self.detail_rx = None;
1041 self.confirm = None;
1042 self.problems = None;
1043 self.problems_rx = None;
1044 self.uc_path.clear();
1045 self.uc_rx = None;
1046 self.secret_scope = None;
1047 self.secrets_rx = None;
1048 self.secret_form = None;
1049 self.param_form = None;
1050 self.param_rx = None;
1051 self.watched.clear();
1052 self.watch_rx = None;
1053 self.preview = None;
1054 self.preview_rx = None;
1055 self.wh_picker = None;
1056 self.preview_warehouse = None;
1057 self.cost = None;
1058 self.cost_rx = None;
1059 self.item_cost = None;
1060 self.item_cost_rx = None;
1061 self.workspace_id = None;
1062 self.sql = None;
1063 self.sql_rx = None;
1064 self.run_view = None;
1065 self.run_rx = None;
1066 self.grid_rx = None;
1067 self.pending = None;
1068 self.in_flight = 0;
1069 self.loading = false;
1070 self.zoomed = false;
1071 self.filters = Default::default();
1072 self.filter_entry = false;
1073 self.fav_only = [false; 7];
1074 self.failed_seen = Default::default();
1075 self.jump = None;
1076 self.sql_stmt = None;
1077 self.sql_complete = None;
1078 self.uc_names.clear();
1079 self.uc_names_rx = None;
1080 self.restore_warehouse_pref();
1081
1082 Arc::new(DatabricksCli::new(profile_arg))
1083 }
1084
1085 pub fn open_jump(&mut self) {
1086 self.jump = Some(Jump {
1087 query: String::new(),
1088 index: 0,
1089 });
1090 }
1091
1092 pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
1096 let Some(jump) = &self.jump else {
1097 return Vec::new();
1098 };
1099 let q = jump.query.to_lowercase();
1100 let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
1101 for (i, shape) in self.shapes.iter().enumerate() {
1102 let Some(Shape::List(items)) = shape else {
1103 continue;
1104 };
1105 for it in items {
1106 let name = it.name.to_lowercase();
1107 let rank = if q.is_empty() || name.contains(&q) {
1108 0
1109 } else if subsequence(&name, &q) {
1110 1
1111 } else {
1112 continue;
1113 };
1114 scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
1115 }
1116 }
1117 scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
1118 scored
1119 .into_iter()
1120 .take(12)
1121 .map(|(_, i, name, label)| (i, name, label))
1122 .collect()
1123 }
1124
1125 pub fn jump_push(&mut self, c: char) {
1126 if let Some(j) = &mut self.jump {
1127 j.query.push(c);
1128 j.index = 0;
1129 }
1130 }
1131
1132 pub fn jump_pop(&mut self) {
1133 if let Some(j) = &mut self.jump {
1134 j.query.pop();
1135 j.index = 0;
1136 }
1137 }
1138
1139 pub fn jump_next(&mut self) {
1140 let len = self.jump_matches().len();
1141 if let Some(j) = &mut self.jump {
1142 j.index = (j.index + 1).min(len.saturating_sub(1));
1143 }
1144 }
1145
1146 pub fn jump_prev(&mut self) {
1147 if let Some(j) = &mut self.jump {
1148 j.index = j.index.saturating_sub(1);
1149 }
1150 }
1151
1152 pub fn jump_go(&mut self) {
1154 let matches = self.jump_matches();
1155 let Some(jump) = self.jump.take() else {
1156 return;
1157 };
1158 let Some((panel_idx, name, _)) = matches.get(jump.index) else {
1159 return;
1160 };
1161 self.reveal_pane(*panel_idx);
1162 self.focus = Panel::ALL[*panel_idx];
1163 self.filters[*panel_idx].clear();
1164 self.fav_only[*panel_idx] = false;
1165 if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
1166 if let Some(pos) = items.iter().position(|i| &i.name == name) {
1167 self.selected[*panel_idx] = pos;
1168 }
1169 }
1170 }
1171
1172 pub fn open_problems(&mut self) {
1176 let mut items = Vec::new();
1177 for (i, shape) in self.shapes.iter().enumerate() {
1178 let Some(Shape::List(list)) = shape else {
1179 continue;
1180 };
1181 for it in list {
1182 let failed_now = matches!(it.status, Status::Failed);
1183 let failed_last = it
1184 .history
1185 .last()
1186 .is_some_and(|s| matches!(s, Status::Failed));
1187 if failed_now || failed_last || it.alert.is_some() {
1188 let note = if failed_now {
1189 it.detail.clone().unwrap_or_default()
1190 } else if let Some(alert) = &it.alert {
1191 alert.clone()
1192 } else {
1193 "latest run failed".to_string()
1194 };
1195 items.push(Problem {
1196 panel: Some(i),
1197 name: it.name.clone(),
1198 status: it.status.clone(),
1199 note,
1200 profile: None,
1201 });
1202 }
1203 }
1204 }
1205 let current = self
1206 .profile
1207 .clone()
1208 .unwrap_or_else(|| "DEFAULT".to_string());
1209 let others: Vec<String> = self
1210 .profiles
1211 .iter()
1212 .filter(|n| **n != current)
1213 .cloned()
1214 .collect();
1215 let scanning = !others.is_empty();
1216 if scanning {
1217 let (tx, rx) = oneshot::channel();
1218 self.problems_rx = Some(rx);
1219 tokio::spawn(async move {
1220 let _ = tx.send(fetchers::problems::fetch(others, current).await);
1221 });
1222 }
1223 self.problems = Some(Problems {
1224 items,
1225 index: 0,
1226 scanning,
1227 });
1228 }
1229
1230 pub fn close_problems(&mut self) {
1231 self.problems = None;
1232 self.problems_rx = None;
1233 }
1234
1235 pub fn poll_problems(&mut self) -> bool {
1236 let Some(rx) = &mut self.problems_rx else {
1237 return false;
1238 };
1239 match rx.try_recv() {
1240 Ok(remote) => {
1241 self.problems_rx = None;
1242 let Some(pr) = &mut self.problems else {
1243 return false;
1244 };
1245 pr.scanning = false;
1246 pr.items.extend(remote.into_iter().map(|r| Problem {
1247 panel: r.panel,
1248 name: r.name,
1249 status: r.status,
1250 note: r.note,
1251 profile: Some(r.profile),
1252 }));
1253 true
1254 }
1255 Err(oneshot::error::TryRecvError::Empty) => false,
1256 Err(oneshot::error::TryRecvError::Closed) => {
1257 self.problems_rx = None;
1258 if let Some(pr) = &mut self.problems {
1259 pr.scanning = false;
1260 }
1261 true
1262 }
1263 }
1264 }
1265
1266 pub fn problems_next(&mut self) {
1267 if let Some(pr) = &mut self.problems {
1268 pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
1269 }
1270 }
1271
1272 pub fn problems_prev(&mut self) {
1273 if let Some(pr) = &mut self.problems {
1274 pr.index = pr.index.saturating_sub(1);
1275 }
1276 }
1277
1278 pub fn problems_jump(&mut self) -> Option<Arc<DatabricksCli>> {
1282 let Some(pr) = self.problems.take() else {
1283 self.problems_rx = None;
1284 return None;
1285 };
1286 self.problems_rx = None;
1287 let problem = pr.items.get(pr.index)?;
1288 if let Some(profile) = &problem.profile {
1289 let target = match problem.panel {
1290 Some(i) => format!("{} is in {}", problem.name, Panel::ALL[i].title()),
1291 None => "check its auth".to_string(),
1292 };
1293 self.flash = Some((
1294 format!("⌂ switched to {profile} — {target}"),
1295 Instant::now(),
1296 ));
1297 let profile = profile.clone();
1298 return Some(self.switch_profile(profile));
1299 }
1300 let panel = problem.panel?;
1301 self.reveal_pane(panel);
1302 self.focus = Panel::ALL[panel];
1303 self.filters[panel].clear();
1306 self.fav_only[panel] = false;
1307 if let Some(Shape::List(list)) = &self.shapes[panel] {
1308 if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
1309 self.selected[panel] = pos;
1310 }
1311 }
1312 None
1313 }
1314
1315 pub fn open_upcoming(&mut self, cli: &Arc<DatabricksCli>) {
1318 self.upcoming = Some(Upcoming {
1319 items: Vec::new(),
1320 index: 0,
1321 loading: true,
1322 });
1323 let (tx, rx) = oneshot::channel();
1324 self.upcoming_rx = Some(rx);
1325 let cli = Arc::clone(cli);
1326 tokio::spawn(async move {
1327 let _ = tx.send(fetchers::upcoming::fetch(&cli).await);
1328 });
1329 }
1330
1331 pub fn close_upcoming(&mut self) {
1332 self.upcoming = None;
1333 self.upcoming_rx = None;
1334 }
1335
1336 pub fn poll_upcoming(&mut self) -> bool {
1337 let Some(rx) = &mut self.upcoming_rx else {
1338 return false;
1339 };
1340 match rx.try_recv() {
1341 Ok(result) => {
1342 self.upcoming_rx = None;
1343 match result {
1344 Ok(items) => {
1345 if let Some(u) = &mut self.upcoming {
1346 u.items = items;
1347 u.loading = false;
1348 }
1349 }
1350 Err(e) => {
1351 self.upcoming = None;
1352 let first = e.lines().next().unwrap_or("failed").to_string();
1353 self.flash = Some((format!("✗ upcoming: {first}"), Instant::now()));
1354 }
1355 }
1356 true
1357 }
1358 Err(oneshot::error::TryRecvError::Empty) => false,
1359 Err(oneshot::error::TryRecvError::Closed) => {
1360 self.upcoming_rx = None;
1361 true
1362 }
1363 }
1364 }
1365
1366 pub fn upcoming_next(&mut self) {
1367 if let Some(u) = &mut self.upcoming {
1368 u.index = (u.index + 1).min(u.items.len().saturating_sub(1));
1369 }
1370 }
1371
1372 pub fn upcoming_prev(&mut self) {
1373 if let Some(u) = &mut self.upcoming {
1374 u.index = u.index.saturating_sub(1);
1375 }
1376 }
1377
1378 pub fn upcoming_jump(&mut self) {
1380 let Some(u) = self.upcoming.take() else {
1381 return;
1382 };
1383 self.upcoming_rx = None;
1384 let Some(item) = u.items.get(u.index) else {
1385 return;
1386 };
1387 let Some(idx) = Panel::ALL.iter().position(|p| *p == Panel::Jobs) else {
1388 return;
1389 };
1390 self.reveal_pane(idx);
1391 self.focus = Panel::Jobs;
1392 self.filters[idx].clear();
1393 if let Some(Shape::List(list)) = &self.shapes[idx] {
1394 if let Some(pos) = list.iter().position(|i| i.name == item.name) {
1395 self.selected[idx] = pos;
1396 }
1397 }
1398 }
1399
1400 pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
1403 let (tx, rx) = oneshot::channel();
1404 self.host_rx = Some(rx);
1405 let cli = Arc::clone(cli);
1406 tokio::spawn(async move {
1407 let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
1408 json["details"]["host"]
1409 .as_str()
1410 .or_else(|| json["host"].as_str())
1411 .map(str::to_string)
1412 });
1413 let _ = tx.send(host);
1414 });
1415 }
1416
1417 pub fn poll_host(&mut self) {
1418 if let Some(rx) = &mut self.host_rx {
1419 match rx.try_recv() {
1420 Ok(host) => {
1421 self.host = host;
1422 self.host_rx = None;
1423 }
1424 Err(oneshot::error::TryRecvError::Empty) => {}
1425 Err(oneshot::error::TryRecvError::Closed) => {
1426 self.host_rx = None;
1427 }
1428 }
1429 }
1430 }
1431
1432 fn focus_index(&self) -> usize {
1433 Panel::ALL
1434 .iter()
1435 .position(|p| p == &self.focus)
1436 .unwrap_or(0)
1437 }
1438
1439 fn favorite_keys(&self, idx: usize) -> Option<&Vec<String>> {
1441 let profile = self.profile.as_deref().unwrap_or("DEFAULT");
1442 self.config
1443 .favorites
1444 .get(profile)
1445 .and_then(|panes| panes.get(Panel::ALL[idx].id()))
1446 }
1447
1448 pub fn is_favorite(&self, idx: usize, item: &crate::shape::ListItem) -> bool {
1450 let key = crate::shape::fav_key(item);
1451 self.favorite_keys(idx)
1452 .is_some_and(|keys| keys.iter().any(|k| k == &key))
1453 }
1454
1455 pub fn pane_fav_set(&self, idx: usize) -> std::collections::HashSet<String> {
1457 self.favorite_keys(idx)
1458 .map(|keys| keys.iter().cloned().collect())
1459 .unwrap_or_default()
1460 }
1461
1462 pub fn fav_filter_active(&self, idx: usize) -> bool {
1465 self.fav_only[idx]
1466 && matches!(&self.shapes[idx], Some(Shape::List(items))
1467 if items.iter().any(|it| self.is_favorite(idx, it)))
1468 }
1469
1470 fn passes(&self, idx: usize, item: &crate::shape::ListItem) -> bool {
1473 crate::shape::item_matches(item, &self.filters[idx])
1474 && (!self.fav_filter_active(idx) || self.is_favorite(idx, item))
1475 }
1476
1477 fn list_len(&self, idx: usize) -> usize {
1478 match &self.shapes[idx] {
1479 Some(Shape::List(items)) => items.iter().filter(|it| self.passes(idx, it)).count(),
1480 _ => 0,
1481 }
1482 }
1483
1484 pub fn selection(&self, idx: usize) -> usize {
1486 self.selected[idx].min(self.list_len(idx).saturating_sub(1))
1487 }
1488
1489 pub fn select_next(&mut self) {
1490 let idx = self.focus_index();
1491 let len = self.list_len(idx);
1492 if len > 0 {
1493 self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
1494 }
1495 }
1496
1497 pub fn select_prev(&mut self) {
1498 let idx = self.focus_index();
1499 self.selected[idx] = self.selection(idx).saturating_sub(1);
1500 }
1501
1502 fn selected_item(&self) -> Option<&crate::shape::ListItem> {
1505 let idx = self.focus_index();
1506 match &self.shapes[idx] {
1507 Some(Shape::List(items)) => items
1508 .iter()
1509 .filter(|it| self.passes(idx, it))
1510 .nth(self.selection(idx)),
1511 _ => None,
1512 }
1513 }
1514
1515 pub fn toggle_favorite(&mut self) {
1517 let idx = self.focus_index();
1518 let Some((key, name)) = self
1519 .selected_item()
1520 .map(|it| (crate::shape::fav_key(it), it.name.clone()))
1521 else {
1522 return;
1523 };
1524 let pane_id = Panel::ALL[idx].id().to_string();
1525 let profile = self.profile.as_deref().unwrap_or("DEFAULT").to_string();
1526 let list = self
1527 .config
1528 .favorites
1529 .entry(profile)
1530 .or_default()
1531 .entry(pane_id)
1532 .or_default();
1533 let pinned = match list.iter().position(|k| k == &key) {
1534 Some(pos) => {
1535 list.remove(pos);
1536 false
1537 }
1538 None => {
1539 list.push(key);
1540 true
1541 }
1542 };
1543 self.config.save();
1544 let msg = if pinned {
1545 format!("★ pinned {name}")
1546 } else {
1547 format!("☆ unpinned {name}")
1548 };
1549 self.flash = Some((msg, Instant::now()));
1550 }
1551
1552 pub fn toggle_fav_only(&mut self) {
1554 let idx = self.focus_index();
1555 self.fav_only[idx] = !self.fav_only[idx];
1556 self.selected[idx] = 0;
1557 let has_fav = self.favorite_keys(idx).is_some_and(|k| !k.is_empty());
1558 let msg = if !self.fav_only[idx] {
1559 "showing all".to_string()
1560 } else if has_fav {
1561 "★ favorites only".to_string()
1562 } else {
1563 "★ favorites only — none pinned yet (press f)".to_string()
1564 };
1565 self.flash = Some((msg, Instant::now()));
1566 }
1567
1568 pub fn filter_start(&mut self) {
1570 let idx = self.focus_index();
1571 self.filters[idx].clear();
1572 self.selected[idx] = 0;
1573 self.filter_entry = true;
1574 }
1575
1576 pub fn filter_push(&mut self, c: char) {
1577 let idx = self.focus_index();
1578 self.filters[idx].push(c);
1579 self.selected[idx] = 0;
1580 }
1581
1582 pub fn filter_pop(&mut self) {
1583 let idx = self.focus_index();
1584 self.filters[idx].pop();
1585 self.selected[idx] = 0;
1586 }
1587
1588 pub fn filter_accept(&mut self) {
1590 self.filter_entry = false;
1591 }
1592
1593 pub fn filter_clear(&mut self) {
1594 let idx = self.focus_index();
1595 self.filters[idx].clear();
1596 self.selected[idx] = 0;
1597 self.filter_entry = false;
1598 }
1599
1600 pub fn active_filter(&self) -> &str {
1602 &self.filters[self.focus_index()]
1603 }
1604
1605 pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
1606 let Some(item) = self.selected_item() else {
1607 return;
1608 };
1609 let Some(id) = item.id.clone() else {
1610 return;
1611 };
1612 let kind = match &item.status {
1613 Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1614 _ => None,
1615 };
1616 let section = match self.focus {
1617 Panel::Dashboards => "Contents",
1618 Panel::Catalog => "Columns",
1619 Panel::Warehouses => "Recent queries",
1620 _ => "Recent activity",
1621 };
1622 self.detail = Some(Detail {
1623 panel: self.focus,
1624 name: item.name.clone(),
1625 id: id.clone(),
1626 kind,
1627 section,
1628 data: None,
1629 show_raw: false,
1630 scroll: 0,
1631 });
1632
1633 let (tx, rx) = oneshot::channel();
1634 self.detail_rx = Some(rx);
1635 let cli = Arc::clone(cli);
1636 let kind = self.detail.as_ref().unwrap().kind.clone();
1637 if kind.as_deref() == Some("FILE") {
1639 if let Some(d) = &mut self.detail {
1640 d.section = "File head";
1641 }
1642 tokio::spawn(async move {
1643 let data = fetchers::catalog::file_peek(&cli, &id).await;
1644 let _ = tx.send(data);
1645 });
1646 return;
1647 }
1648 let group = match &kind {
1649 Some(k) if k == "VOLUME" => "volumes",
1650 _ => self.focus.cli_group(),
1651 };
1652 let warehouse = match &kind {
1655 Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1656 _ => None,
1657 };
1658 tokio::spawn(async move {
1659 let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1660 let _ = tx.send(data);
1661 });
1662 }
1663
1664 pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1667 if self.focus != Panel::Catalog {
1668 return false;
1669 }
1670 let Some(item) = self.selected_item() else {
1671 return self.uc_path.is_empty(); };
1673 if self.uc_path.len() >= 2 {
1676 let drillable =
1677 matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1678 if !drillable {
1679 return false;
1680 }
1681 }
1682 self.uc_path.push(item.name.clone());
1683 self.refresh_catalog(cli);
1684 true
1685 }
1686
1687 pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1689 if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1690 return false;
1691 }
1692 self.uc_path.pop();
1693 self.refresh_catalog(cli);
1694 true
1695 }
1696
1697 fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1698 self.shapes[5] = None;
1699 self.selected[5] = 0;
1700 self.filters[5].clear();
1702 let (tx, rx) = oneshot::channel();
1703 self.uc_rx = Some(rx);
1704 let cli = Arc::clone(cli);
1705 let path = self.uc_path.clone();
1706 tokio::spawn(async move {
1707 let result = fetchers::catalog::fetch(&cli, &path)
1708 .await
1709 .map_err(|e| format!("{e:#}"));
1710 let _ = tx.send(result);
1711 });
1712 }
1713
1714 pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1718 if self.focus != Panel::Secrets {
1719 return false;
1720 }
1721 if self.secret_scope.is_some() {
1723 return true;
1724 }
1725 let Some(item) = self.selected_item() else {
1726 return true; };
1728 self.secret_scope = Some(item.name.clone());
1729 self.refresh_secrets(cli);
1730 true
1731 }
1732
1733 pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1735 if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1736 return false;
1737 }
1738 self.secret_scope = None;
1739 self.refresh_secrets(cli);
1740 true
1741 }
1742
1743 fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1744 let idx = 6;
1745 self.shapes[idx] = None;
1746 self.selected[idx] = 0;
1747 self.filters[idx].clear();
1748 let (tx, rx) = oneshot::channel();
1749 self.secrets_rx = Some(rx);
1750 let cli = Arc::clone(cli);
1751 let scope = self.secret_scope.clone();
1752 tokio::spawn(async move {
1753 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1754 .await
1755 .map_err(|e| format!("{e:#}"));
1756 let _ = tx.send(result);
1757 });
1758 }
1759
1760 pub fn poll_secrets(&mut self) -> bool {
1761 let Some(rx) = &mut self.secrets_rx else {
1762 return false;
1763 };
1764 match rx.try_recv() {
1765 Ok(result) => {
1766 self.shapes[6] = Some(match result {
1767 Ok(shape) => shape,
1768 Err(e) => Shape::Text(format!("✗ {e}")),
1769 });
1770 self.updated_at[6] = Some(Instant::now());
1771 self.secrets_rx = None;
1772 true
1773 }
1774 Err(oneshot::error::TryRecvError::Empty) => false,
1775 Err(oneshot::error::TryRecvError::Closed) => {
1776 self.secrets_rx = None;
1777 true
1778 }
1779 }
1780 }
1781
1782 pub fn open_secret_form(&mut self) {
1785 if self.focus != Panel::Secrets {
1786 return;
1787 }
1788 self.secret_form = Some(SecretForm {
1789 scope: self.secret_scope.clone(),
1790 key: String::new(),
1791 value: String::new(),
1792 stage: 0,
1793 });
1794 }
1795
1796 pub fn secret_form_push(&mut self, c: char) {
1797 if let Some(form) = &mut self.secret_form {
1798 if form.stage == 0 {
1799 form.key.push(c);
1800 } else {
1801 form.value.push(c);
1802 }
1803 }
1804 }
1805
1806 pub fn secret_form_pop(&mut self) {
1807 if let Some(form) = &mut self.secret_form {
1808 if form.stage == 0 {
1809 form.key.pop();
1810 } else {
1811 form.value.pop();
1812 }
1813 }
1814 }
1815
1816 pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1818 let Some(form) = &mut self.secret_form else {
1819 return;
1820 };
1821 if form.key.trim().is_empty() {
1822 return;
1823 }
1824 match (form.scope.clone(), form.stage) {
1825 (None, _) => {
1827 let name = form.key.trim().to_string();
1828 self.secret_form = None;
1829 self.run_secret_action(
1830 cli,
1831 format!("Create scope “{name}”"),
1832 vec!["secrets".into(), "create-scope".into(), name],
1833 );
1834 }
1835 (Some(_), 0) => form.stage = 1,
1837 (Some(scope), _) => {
1838 let key = form.key.trim().to_string();
1839 let value = form.value.clone();
1840 self.secret_form = None;
1841 self.run_secret_action(
1842 cli,
1843 format!("Put secret “{key}” in “{scope}”"),
1844 vec![
1845 "secrets".into(),
1846 "put-secret".into(),
1847 scope,
1848 key,
1849 "--string-value".into(),
1850 value,
1851 ],
1852 );
1853 }
1854 }
1855 }
1856
1857 fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1860 self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1861 let (tx, rx) = oneshot::channel();
1862 self.action_rx = Some(rx);
1863 let cli = Arc::clone(cli);
1864 tokio::spawn(async move {
1865 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1866 let result = match cli.run_action(&arg_refs).await {
1867 Ok(()) => Ok(format!("✓ {label} — done")),
1868 Err(e) => Err(format!("✗ {e:#}")),
1869 };
1870 let _ = tx.send(result);
1871 });
1872 }
1873
1874 pub fn request_secret_delete(&mut self) {
1876 if self.focus != Panel::Secrets {
1877 return;
1878 }
1879 let Some(item) = self.selected_item() else {
1880 return;
1881 };
1882 let name = item.name.clone();
1883 let (message, args) = match &self.secret_scope {
1884 None => (
1885 format!("Delete scope “{name}” and all its secrets?"),
1886 vec!["secrets".to_string(), "delete-scope".to_string(), name],
1887 ),
1888 Some(scope) => (
1889 format!("Delete secret “{name}” from “{scope}”?"),
1890 vec![
1891 "secrets".to_string(),
1892 "delete-secret".to_string(),
1893 scope.clone(),
1894 name,
1895 ],
1896 ),
1897 };
1898 self.confirm = Some(Confirm {
1899 message,
1900 args,
1901 params: None,
1902 });
1903 }
1904
1905 fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1907 let scope = match &self.secret_scope {
1908 Some(s) => Some(s.clone()),
1909 None => self.selected_item().map(|i| i.name.clone()),
1910 };
1911 let Some(scope) = scope else {
1912 return;
1913 };
1914 self.detail = Some(Detail {
1915 panel: Panel::Secrets,
1916 name: scope.clone(),
1917 id: scope.clone(),
1918 kind: None,
1919 section: "Access",
1920 data: None,
1921 show_raw: false,
1922 scroll: 0,
1923 });
1924 let (tx, rx) = oneshot::channel();
1925 self.detail_rx = Some(rx);
1926 let cli = Arc::clone(cli);
1927 tokio::spawn(async move {
1928 let acl_args = ["secrets", "list-acls", &scope];
1929 let data = match cli.run(&acl_args).await {
1930 Ok(json) => {
1931 let raw =
1932 serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1933 let acls = json
1935 .as_array()
1936 .cloned()
1937 .or_else(|| json["items"].as_array().cloned())
1938 .unwrap_or_default();
1939 let activity: Vec<(Status, String)> = acls
1940 .iter()
1941 .map(|a| {
1942 let principal = a["principal"].as_str().unwrap_or("?");
1943 let perm = a["permission"].as_str().unwrap_or("?");
1944 let status = if perm == "MANAGE" {
1945 Status::Success
1946 } else {
1947 Status::Unknown(String::new())
1948 };
1949 (status, format!("{principal} · {perm}"))
1950 })
1951 .collect();
1952 DetailData {
1953 summary: vec![("Scope".to_string(), scope.clone())],
1954 activity,
1955 raw,
1956 }
1957 }
1958 Err(e) => DetailData {
1959 summary: Vec::new(),
1960 activity: Vec::new(),
1961 raw: format!("{e:#}"),
1962 },
1963 };
1964 let _ = tx.send(data);
1965 });
1966 }
1967
1968 pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1971 let idx = match kind {
1972 "cluster" => 0,
1973 "job" => 1,
1974 "warehouse" => 3,
1975 _ => return None,
1976 };
1977 match &self.shapes[idx] {
1978 Some(Shape::List(items)) => items
1979 .iter()
1980 .find(|i| i.id.as_deref() == Some(id))
1981 .map(|i| i.name.clone()),
1982 _ => None,
1983 }
1984 }
1985
1986 pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1988 let Some(Shape::List(items)) = &self.shapes[3] else {
1989 return Vec::new();
1990 };
1991 items
1992 .iter()
1993 .filter_map(|i| {
1994 let id = i.id.clone()?;
1995 Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1996 })
1997 .collect()
1998 }
1999
2000 pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
2004 if self.focus != Panel::Catalog {
2005 return;
2006 }
2007 let Some(item) = self.selected_item() else {
2008 return;
2009 };
2010 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
2011 return;
2012 }
2013 let Some(full_name) = item.id.clone() else {
2014 return;
2015 };
2016 let warehouses = self.warehouses();
2017 if warehouses.is_empty() {
2018 self.flash = Some((
2019 "✗ no SQL warehouse available for previews".to_string(),
2020 Instant::now(),
2021 ));
2022 return;
2023 }
2024 if !force_pick {
2025 if let Some((id, name)) = self.preview_warehouse.clone() {
2026 self.start_preview_query(cli, full_name, id, name);
2027 return;
2028 }
2029 if let [(name, id, _)] = warehouses.as_slice() {
2030 self.preview_warehouse = Some((id.clone(), name.clone()));
2031 self.start_preview_query(cli, full_name, id.clone(), name.clone());
2032 return;
2033 }
2034 }
2035 let index = self
2037 .preview_warehouse
2038 .as_ref()
2039 .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
2040 .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
2041 .unwrap_or(0);
2042 self.wh_picker = Some(WhPicker {
2043 index,
2044 target: PickTarget::Preview(full_name),
2045 });
2046 }
2047
2048 pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
2050 let warehouses = self.warehouses();
2051 if warehouses.is_empty() {
2052 self.flash = Some((
2053 "✗ no SQL warehouse available to query system tables".to_string(),
2054 Instant::now(),
2055 ));
2056 return;
2057 }
2058 if let Some((id, name)) = self.preview_warehouse.clone() {
2059 self.start_cost_query(cli, id, name);
2060 return;
2061 }
2062 if let [(name, id, _)] = warehouses.as_slice() {
2063 self.preview_warehouse = Some((id.clone(), name.clone()));
2064 self.start_cost_query(cli, id.clone(), name.clone());
2065 return;
2066 }
2067 let index = warehouses
2068 .iter()
2069 .position(|(_, _, running)| *running)
2070 .unwrap_or(0);
2071 self.wh_picker = Some(WhPicker {
2072 index,
2073 target: PickTarget::Cost,
2074 });
2075 }
2076
2077 fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
2078 self.cost = Some(CostView {
2079 warehouse: name,
2080 data: None,
2081 });
2082 let (tx, rx) = oneshot::channel();
2083 self.cost_rx = Some(rx);
2084 let cli = Arc::clone(cli);
2085 let host = self.host.clone();
2086 let cached_ws = self.workspace_id.clone();
2087 tokio::spawn(async move {
2088 let ws = match (cached_ws, host) {
2090 (Some(w), _) => Some(w),
2091 (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
2092 (None, None) => None,
2093 };
2094 let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
2095 let _ = tx.send((result, ws));
2096 });
2097 }
2098
2099 pub fn open_item_cost(&mut self, cli: &Arc<DatabricksCli>) {
2104 let kind = match self.focus {
2105 Panel::Jobs => fetchers::cost::ResourceKind::Job,
2106 Panel::Pipelines => fetchers::cost::ResourceKind::Pipeline,
2107 _ => return,
2108 };
2109 let Some(item) = self.selected_item() else {
2110 return;
2111 };
2112 let (Some(id), name) = (item.id.clone(), item.name.clone()) else {
2113 return;
2114 };
2115 let warehouses = self.warehouses();
2116 if warehouses.is_empty() {
2117 self.flash = Some((
2118 "✗ no SQL warehouse available to query system tables".to_string(),
2119 Instant::now(),
2120 ));
2121 return;
2122 }
2123 if let Some((wh_id, wh_name)) = self.preview_warehouse.clone() {
2124 self.start_item_cost_query(cli, wh_id, wh_name, kind, id, name);
2125 return;
2126 }
2127 if let [(wh_name, wh_id, _)] = warehouses.as_slice() {
2128 self.preview_warehouse = Some((wh_id.clone(), wh_name.clone()));
2129 let (wh_id, wh_name) = (wh_id.clone(), wh_name.clone());
2130 self.start_item_cost_query(cli, wh_id, wh_name, kind, id, name);
2131 return;
2132 }
2133 let index = warehouses
2134 .iter()
2135 .position(|(_, _, running)| *running)
2136 .unwrap_or(0);
2137 self.wh_picker = Some(WhPicker {
2138 index,
2139 target: PickTarget::ItemCost(kind, id, name),
2140 });
2141 }
2142
2143 fn start_item_cost_query(
2144 &mut self,
2145 cli: &Arc<DatabricksCli>,
2146 warehouse_id: String,
2147 warehouse_name: String,
2148 kind: fetchers::cost::ResourceKind,
2149 id: String,
2150 name: String,
2151 ) {
2152 self.item_cost = Some(ItemCostView {
2153 warehouse: warehouse_name,
2154 kind,
2155 name,
2156 id: id.clone(),
2157 data: None,
2158 });
2159 let (tx, rx) = oneshot::channel();
2160 self.item_cost_rx = Some(rx);
2161 let cli = Arc::clone(cli);
2162 let host = self.host.clone();
2163 let cached_ws = self.workspace_id.clone();
2164 tokio::spawn(async move {
2165 let ws = match (cached_ws, host) {
2167 (Some(w), _) => Some(w),
2168 (None, Some(h)) => {
2169 fetchers::cost::resolve_workspace_id(&cli, &warehouse_id, &h).await
2170 }
2171 (None, None) => None,
2172 };
2173 let result =
2174 fetchers::cost::fetch_resource(&cli, &warehouse_id, kind, &id, ws.as_deref()).await;
2175 let _ = tx.send((result, ws));
2176 });
2177 }
2178
2179 pub fn close_item_cost(&mut self) {
2180 self.item_cost = None;
2181 self.item_cost_rx = None;
2182 }
2183
2184 pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
2187 if self.focus != Panel::Catalog {
2188 return;
2189 }
2190 let Some(item) = self.selected_item() else {
2191 return;
2192 };
2193 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
2194 return;
2195 }
2196 let Some(full_name) = item.id.clone() else {
2197 return;
2198 };
2199 let warehouses = self.warehouses();
2200 if warehouses.is_empty() {
2201 self.flash = Some((
2202 "✗ no SQL warehouse available to query lineage".to_string(),
2203 Instant::now(),
2204 ));
2205 return;
2206 }
2207 if let Some((id, _)) = self.preview_warehouse.clone() {
2208 self.start_lineage_query(cli, full_name, id);
2209 return;
2210 }
2211 if let [(name, id, _)] = warehouses.as_slice() {
2212 self.preview_warehouse = Some((id.clone(), name.clone()));
2213 let id = id.clone();
2214 self.start_lineage_query(cli, full_name, id);
2215 return;
2216 }
2217 let index = warehouses
2218 .iter()
2219 .position(|(_, _, running)| *running)
2220 .unwrap_or(0);
2221 self.wh_picker = Some(WhPicker {
2222 index,
2223 target: PickTarget::Lineage(full_name),
2224 });
2225 }
2226
2227 fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
2228 self.detail = Some(Detail {
2229 panel: Panel::Catalog,
2230 name: full_name.clone(),
2231 id: full_name.clone(),
2232 kind: None,
2233 section: "Lineage",
2234 data: None,
2235 show_raw: false,
2236 scroll: 0,
2237 });
2238 let (tx, rx) = oneshot::channel();
2239 self.detail_rx = Some(rx);
2240 let cli = Arc::clone(cli);
2241 tokio::spawn(async move {
2242 let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
2243 let _ = tx.send(data);
2244 });
2245 }
2246
2247 pub fn close_cost(&mut self) {
2248 self.cost = None;
2249 self.cost_rx = None;
2250 }
2251
2252 fn selected_table_fqn(&self) -> Option<String> {
2254 if self.focus != Panel::Catalog {
2255 return None;
2256 }
2257 let item = self.selected_item()?;
2258 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
2259 return None;
2260 }
2261 item.id.clone()
2262 }
2263
2264 pub fn open_sql(&mut self) {
2267 if self.sql.is_none() {
2268 let input = self
2269 .selected_table_fqn()
2270 .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
2271 .unwrap_or_default();
2272 self.sql = Some(SqlConsole {
2273 cursor: input.chars().count(),
2274 input,
2275 warehouse: String::new(),
2276 running: false,
2277 data: None,
2278 last_sql: String::new(),
2279 scroll: 0,
2280 col: 0,
2281 });
2282 }
2283 }
2284
2285 pub fn close_sql(&mut self) {
2286 self.sql = None;
2287 self.sql_rx = None;
2288 self.hist_idx = None;
2289 self.hist_draft.clear();
2290 self.hist_search = None;
2291 self.sql_complete = None;
2292 }
2293
2294 pub fn sql_tab(&mut self, cli: &Arc<DatabricksCli>) {
2298 match &self.sql_complete {
2299 Some(c) if c.loading => return,
2300 Some(_) => {
2301 self.sql_complete_next(1);
2302 return;
2303 }
2304 None => {}
2305 }
2306 let Some(console) = &self.sql else {
2307 return;
2308 };
2309 let (seg_start, context, prefix) = token_at_cursor(&console.input, console.cursor);
2310 let missing = if context.is_empty() {
2313 from_table(&console.input)
2314 .filter(|fqn| !self.uc_names.contains_key(fqn))
2315 .or_else(|| (!self.uc_names.contains_key("")).then(String::new))
2316 } else {
2317 (!self.uc_names.contains_key(&context)).then(|| context.clone())
2318 };
2319 let loading = missing.is_some();
2320 self.sql_complete = Some(SqlComplete {
2321 items: Vec::new(),
2322 index: 0,
2323 seg_start,
2324 prefix,
2325 context,
2326 loading,
2327 });
2328 match missing {
2329 Some(path) => self.fetch_uc_names(cli, path),
2330 None => self.sql_complete_fill(),
2331 }
2332 }
2333
2334 fn sql_complete_fill(&mut self) {
2337 let Some(console) = &self.sql else {
2338 self.sql_complete = None;
2339 return;
2340 };
2341 let input = console.input.clone();
2342 let Some(comp) = &self.sql_complete else {
2343 return;
2344 };
2345 let q = comp.prefix.to_lowercase();
2346 let mut items: Vec<String> = Vec::new();
2347 if comp.context.is_empty() {
2348 if let Some(cols) = from_table(&input).and_then(|fqn| self.uc_names.get(&fqn)) {
2349 items.extend(
2350 cols.iter()
2351 .filter(|n| n.to_lowercase().starts_with(&q))
2352 .cloned(),
2353 );
2354 }
2355 if let Some(cats) = self.uc_names.get("") {
2356 items.extend(
2357 cats.iter()
2358 .filter(|n| n.to_lowercase().starts_with(&q))
2359 .cloned(),
2360 );
2361 }
2362 if !q.is_empty() {
2363 items.extend(
2364 SQL_KEYWORDS
2365 .iter()
2366 .filter(|k| k.to_lowercase().starts_with(&q))
2367 .map(|k| k.to_string()),
2368 );
2369 }
2370 } else if let Some(names) = self.uc_names.get(&comp.context) {
2371 items.extend(
2372 names
2373 .iter()
2374 .filter(|n| n.to_lowercase().starts_with(&q))
2375 .cloned(),
2376 );
2377 }
2378 items.dedup();
2379 if items.is_empty() {
2380 self.sql_complete = None;
2381 self.flash = Some(("no completions".to_string(), Instant::now()));
2382 return;
2383 }
2384 let single = items.len() == 1;
2385 if let Some(comp) = &mut self.sql_complete {
2386 comp.items = items;
2387 comp.index = 0;
2388 }
2389 self.sql_complete_apply();
2390 if single {
2391 self.sql_complete = None;
2392 }
2393 }
2394
2395 fn sql_complete_apply(&mut self) {
2397 let Some(comp) = &self.sql_complete else {
2398 return;
2399 };
2400 let candidate = comp.items[comp.index].clone();
2401 let plain = candidate
2402 .chars()
2403 .all(|c| c.is_alphanumeric() || matches!(c, '_' | ' '));
2404 let text = if plain {
2405 candidate
2406 } else {
2407 format!("`{candidate}`")
2408 };
2409 self.sql_replace_segment(comp.seg_start, &text);
2410 }
2411
2412 fn sql_replace_segment(&mut self, seg_start: usize, text: &str) {
2414 if let Some(console) = &mut self.sql {
2415 let from = byte_at(&console.input, seg_start);
2416 let to = byte_at(&console.input, console.cursor);
2417 console.input.replace_range(from..to, text);
2418 console.cursor = seg_start + text.chars().count();
2419 }
2420 }
2421
2422 pub fn sql_complete_next(&mut self, delta: i32) {
2424 let Some(comp) = &mut self.sql_complete else {
2425 return;
2426 };
2427 if comp.items.is_empty() {
2428 return;
2429 }
2430 let n = comp.items.len() as i32;
2431 comp.index = (comp.index as i32 + delta).rem_euclid(n) as usize;
2432 self.sql_complete_apply();
2433 }
2434
2435 pub fn sql_complete_cancel(&mut self) {
2437 if let Some(comp) = &self.sql_complete {
2438 let (seg_start, prefix) = (comp.seg_start, comp.prefix.clone());
2439 self.sql_replace_segment(seg_start, &prefix);
2440 }
2441 self.sql_complete = None;
2442 }
2443
2444 pub fn sql_complete_accept(&mut self) {
2446 self.sql_complete = None;
2447 }
2448
2449 fn fetch_uc_names(&mut self, cli: &Arc<DatabricksCli>, path: String) {
2450 let (tx, rx) = oneshot::channel();
2451 self.uc_names_rx = Some(rx);
2452 let cli = Arc::clone(cli);
2453 tokio::spawn(async move {
2454 let names = fetchers::catalog::names(&cli, &path)
2455 .await
2456 .map_err(|e| format!("{e:#}"));
2457 let _ = tx.send((path, names));
2458 });
2459 }
2460
2461 pub fn poll_uc_names(&mut self) -> bool {
2463 let Some(rx) = &mut self.uc_names_rx else {
2464 return false;
2465 };
2466 match rx.try_recv() {
2467 Ok((path, result)) => {
2468 self.uc_names_rx = None;
2469 match result {
2470 Ok(names) => {
2471 self.uc_names.insert(path, names);
2472 if self.sql_complete.as_ref().is_some_and(|c| c.loading) {
2473 if let Some(c) = &mut self.sql_complete {
2474 c.loading = false;
2475 }
2476 self.sql_complete_fill();
2477 }
2478 }
2479 Err(e) => {
2480 self.sql_complete = None;
2481 let first = e.lines().next().unwrap_or("fetch failed").to_string();
2482 self.flash = Some((format!("✗ completions: {first}"), Instant::now()));
2483 }
2484 }
2485 true
2486 }
2487 Err(oneshot::error::TryRecvError::Empty) => false,
2488 Err(oneshot::error::TryRecvError::Closed) => {
2489 self.uc_names_rx = None;
2490 self.sql_complete = None;
2491 true
2492 }
2493 }
2494 }
2495
2496 pub fn sql_input(&self) -> Option<String> {
2498 self.sql.as_ref().map(|c| c.input.clone())
2499 }
2500
2501 pub fn sql_set_input(&mut self, s: &str) {
2503 if let Some(console) = &mut self.sql {
2504 console.input = s.to_string();
2505 console.cursor = console.input.chars().count();
2506 }
2507 }
2508
2509 pub fn hist_search_current(&self) -> Option<&String> {
2511 let (query, nth) = self.hist_search.as_ref()?;
2512 self.sql_history
2513 .iter()
2514 .rev()
2515 .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
2516 .nth(*nth)
2517 }
2518
2519 pub fn hist_search_start(&mut self) {
2520 if self.sql.is_some() {
2521 self.hist_search = Some((String::new(), 0));
2522 }
2523 }
2524
2525 pub fn hist_search_push(&mut self, c: char) {
2526 if let Some((query, nth)) = &mut self.hist_search {
2527 query.push(c);
2528 *nth = 0;
2529 }
2530 }
2531
2532 pub fn hist_search_pop(&mut self) {
2533 if let Some((query, nth)) = &mut self.hist_search {
2534 query.pop();
2535 *nth = 0;
2536 }
2537 }
2538
2539 pub fn hist_search_older(&mut self) {
2541 let Some((query, nth)) = &self.hist_search else {
2542 return;
2543 };
2544 let q = query.to_lowercase();
2545 let matches = self
2546 .sql_history
2547 .iter()
2548 .filter(|h| h.to_lowercase().contains(&q))
2549 .count();
2550 if nth + 1 < matches {
2551 if let Some((_, n)) = &mut self.hist_search {
2552 *n += 1;
2553 }
2554 }
2555 }
2556
2557 pub fn hist_search_accept(&mut self) {
2558 if let Some(stmt) = self.hist_search_current().cloned() {
2559 self.sql_set_input(&stmt);
2560 }
2561 self.hist_search = None;
2562 }
2563
2564 pub fn hist_search_cancel(&mut self) {
2565 self.hist_search = None;
2566 }
2567
2568 pub fn sql_push(&mut self, c: char) {
2569 if let Some(console) = &mut self.sql {
2570 let at = byte_at(&console.input, console.cursor);
2571 console.input.insert(at, c);
2572 console.cursor += 1;
2573 }
2574 }
2575
2576 pub fn sql_pop(&mut self) {
2578 if let Some(console) = &mut self.sql {
2579 if console.cursor > 0 {
2580 let at = byte_at(&console.input, console.cursor - 1);
2581 console.input.remove(at);
2582 console.cursor -= 1;
2583 }
2584 }
2585 }
2586
2587 pub fn sql_delete(&mut self) {
2589 if let Some(console) = &mut self.sql {
2590 if console.cursor < console.input.chars().count() {
2591 let at = byte_at(&console.input, console.cursor);
2592 console.input.remove(at);
2593 }
2594 }
2595 }
2596
2597 pub fn sql_left(&mut self) {
2598 if let Some(console) = &mut self.sql {
2599 console.cursor = console.cursor.saturating_sub(1);
2600 }
2601 }
2602
2603 pub fn sql_right(&mut self) {
2604 if let Some(console) = &mut self.sql {
2605 console.cursor = (console.cursor + 1).min(console.input.chars().count());
2606 }
2607 }
2608
2609 pub fn sql_hist_prev(&mut self) {
2611 let Some(console) = &mut self.sql else {
2612 return;
2613 };
2614 if self.sql_history.is_empty() {
2615 return;
2616 }
2617 let idx = match self.hist_idx {
2618 None => {
2619 self.hist_draft = console.input.clone();
2620 self.sql_history.len() - 1
2621 }
2622 Some(i) => i.saturating_sub(1),
2623 };
2624 self.hist_idx = Some(idx);
2625 console.input = self.sql_history[idx].clone();
2626 console.cursor = console.input.chars().count();
2627 }
2628
2629 pub fn sql_hist_next(&mut self) {
2631 let Some(console) = &mut self.sql else {
2632 return;
2633 };
2634 let Some(idx) = self.hist_idx else {
2635 return;
2636 };
2637 if idx + 1 < self.sql_history.len() {
2638 self.hist_idx = Some(idx + 1);
2639 console.input = self.sql_history[idx + 1].clone();
2640 } else {
2641 self.hist_idx = None;
2642 console.input = self.hist_draft.clone();
2643 }
2644 console.cursor = console.input.chars().count();
2645 }
2646
2647 pub fn sql_home(&mut self) {
2648 if let Some(console) = &mut self.sql {
2649 console.cursor = 0;
2650 }
2651 }
2652
2653 pub fn sql_end(&mut self) {
2654 if let Some(console) = &mut self.sql {
2655 console.cursor = console.input.chars().count();
2656 }
2657 }
2658
2659 pub fn sql_scroll(&mut self, delta: i32) {
2660 if let Some(console) = &mut self.sql {
2661 let max = match &console.data {
2662 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2663 _ => 0,
2664 };
2665 console.scroll = if delta < 0 {
2666 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
2667 } else {
2668 (console.scroll + delta as usize).min(max)
2669 };
2670 }
2671 }
2672
2673 pub fn sql_cols(&mut self, delta: i32) {
2675 if let Some(console) = &mut self.sql {
2676 let n = match &console.data {
2677 Some(Ok(t)) => t.headers.len(),
2678 _ => 0,
2679 };
2680 console.col = if delta < 0 {
2681 console.col.saturating_sub(1)
2682 } else {
2683 (console.col + 1).min(n.saturating_sub(1))
2684 };
2685 }
2686 }
2687
2688 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
2690 let Some(console) = &self.sql else {
2691 return;
2692 };
2693 if console.running {
2694 return;
2695 }
2696 let query = console.input.trim().to_string();
2697 if query.is_empty() {
2698 return;
2699 }
2700 if self.sql_history.last() != Some(&query) {
2703 self.sql_history.push(query.clone());
2704 save_history(&self.sql_history);
2705 }
2706 self.hist_idx = None;
2707 self.hist_draft.clear();
2708 let warehouses = self.warehouses();
2709 if warehouses.is_empty() {
2710 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
2711 return;
2712 }
2713 if let Some((id, name)) = self.preview_warehouse.clone() {
2714 self.start_sql_query(cli, query, id, name);
2715 return;
2716 }
2717 if let [(name, id, _)] = warehouses.as_slice() {
2718 self.preview_warehouse = Some((id.clone(), name.clone()));
2719 self.start_sql_query(cli, query, id.clone(), name.clone());
2720 return;
2721 }
2722 let index = warehouses
2723 .iter()
2724 .position(|(_, _, running)| *running)
2725 .unwrap_or(0);
2726 self.wh_picker = Some(WhPicker {
2727 index,
2728 target: PickTarget::Sql(query),
2729 });
2730 }
2731
2732 fn start_sql_query(
2733 &mut self,
2734 cli: &Arc<DatabricksCli>,
2735 query: String,
2736 id: String,
2737 name: String,
2738 ) {
2739 if let Some(console) = &mut self.sql {
2740 console.running = true;
2741 console.warehouse = name;
2742 console.scroll = 0;
2743 console.last_sql = query.clone();
2744 }
2745 let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
2747 self.sql_stmt = Some(std::sync::Arc::clone(&handle));
2748 let (tx, rx) = oneshot::channel();
2749 self.sql_rx = Some(rx);
2750 let cli = Arc::clone(cli);
2751 tokio::spawn(async move {
2752 let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
2753 let _ = tx.send(result);
2754 });
2755 }
2756
2757 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
2760 let stamp = std::time::SystemTime::now()
2761 .duration_since(std::time::UNIX_EPOCH)
2762 .map(|d| d.as_secs())
2763 .unwrap_or(0);
2764 let slug: String = label
2765 .chars()
2766 .map(|c| if c.is_alphanumeric() { c } else { '-' })
2767 .collect::<String>()
2768 .trim_matches('-')
2769 .chars()
2770 .take(40)
2771 .collect();
2772 let name = format!("databricks-{slug}-{stamp}.csv");
2773 let msg = match std::fs::write(&name, data.to_csv()) {
2774 Ok(()) => {
2775 let cwd = std::env::current_dir()
2776 .map(|d| d.display().to_string())
2777 .unwrap_or_default();
2778 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
2779 }
2780 Err(e) => format!("✗ export failed: {e}"),
2781 };
2782 self.flash = Some((msg, Instant::now()));
2783 }
2784
2785 pub fn sql_export(&mut self) {
2787 if let Some(SqlConsole {
2788 data: Some(Ok(data)),
2789 last_sql,
2790 ..
2791 }) = &self.sql
2792 {
2793 let (label, data) = (last_sql.clone(), data.clone());
2794 self.export_csv(&label, &data);
2795 }
2796 }
2797
2798 pub fn preview_h(&mut self, delta: i32) {
2801 let Some(pv) = &mut self.preview else {
2802 return;
2803 };
2804 if pv.record {
2805 let max = match &pv.data {
2806 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2807 _ => 0,
2808 };
2809 pv.scroll = if delta < 0 {
2810 pv.scroll.saturating_sub(1)
2811 } else {
2812 (pv.scroll + 1).min(max)
2813 };
2814 pv.rscroll = 0;
2815 } else {
2816 let cols = pv.visible_cols().len();
2817 pv.col = if delta < 0 {
2818 pv.col.saturating_sub(1)
2819 } else {
2820 (pv.col + 1).min(cols.saturating_sub(1))
2821 };
2822 }
2823 }
2824
2825 pub fn preview_toggle_record(&mut self) {
2827 if let Some(pv) = &mut self.preview {
2828 if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
2829 pv.record = !pv.record;
2830 pv.rscroll = 0;
2831 }
2832 }
2833 }
2834
2835 pub fn preview_filter_start(&mut self) {
2836 if let Some(pv) = &mut self.preview {
2837 pv.filter.clear();
2838 pv.filter_entry = true;
2839 pv.col = 0;
2840 pv.rscroll = 0;
2841 }
2842 }
2843
2844 pub fn preview_filter_push(&mut self, c: char) {
2845 if let Some(pv) = &mut self.preview {
2846 pv.filter.push(c);
2847 pv.col = 0;
2848 pv.rscroll = 0;
2849 }
2850 }
2851
2852 pub fn preview_filter_pop(&mut self) {
2853 if let Some(pv) = &mut self.preview {
2854 pv.filter.pop();
2855 pv.col = 0;
2856 }
2857 }
2858
2859 pub fn preview_filter_accept(&mut self) {
2860 if let Some(pv) = &mut self.preview {
2861 pv.filter_entry = false;
2862 }
2863 }
2864
2865 pub fn preview_filter_clear(&mut self) {
2866 if let Some(pv) = &mut self.preview {
2867 pv.filter.clear();
2868 pv.filter_entry = false;
2869 pv.col = 0;
2870 pv.rscroll = 0;
2871 }
2872 }
2873
2874 pub fn preview_export(&mut self) {
2876 if let Some(Preview {
2877 data: Some(Ok(data)),
2878 name,
2879 ..
2880 }) = &self.preview
2881 {
2882 let (label, data) = (name.clone(), data.clone());
2883 self.export_csv(&label, &data);
2884 }
2885 }
2886
2887 pub fn poll_sql(&mut self) -> bool {
2888 let Some(rx) = &mut self.sql_rx else {
2889 return false;
2890 };
2891 match rx.try_recv() {
2892 Ok(result) => {
2893 if let Err(e) = &result {
2896 if e != "statement canceled" {
2897 self.preview_warehouse = None;
2898 }
2899 }
2900 if let Some(console) = &mut self.sql {
2901 console.running = false;
2902 console.data = Some(result);
2903 console.col = 0;
2904 }
2905 self.sql_rx = None;
2906 self.sql_stmt = None;
2907 true
2908 }
2909 Err(oneshot::error::TryRecvError::Empty) => false,
2910 Err(oneshot::error::TryRecvError::Closed) => {
2911 if let Some(console) = &mut self.sql {
2912 console.running = false;
2913 }
2914 self.sql_rx = None;
2915 true
2916 }
2917 }
2918 }
2919
2920 pub fn poll_cost(&mut self) -> bool {
2921 let Some(rx) = &mut self.cost_rx else {
2922 return false;
2923 };
2924 match rx.try_recv() {
2925 Ok((result, ws)) => {
2926 if result.is_err() {
2927 self.preview_warehouse = None;
2928 }
2929 if ws.is_some() {
2930 self.workspace_id = ws;
2931 }
2932 if let Some(cv) = &mut self.cost {
2933 cv.data = Some(result);
2934 }
2935 self.cost_rx = None;
2936 true
2937 }
2938 Err(oneshot::error::TryRecvError::Empty) => false,
2939 Err(oneshot::error::TryRecvError::Closed) => {
2940 self.cost_rx = None;
2941 true
2942 }
2943 }
2944 }
2945
2946 pub fn poll_item_cost(&mut self) -> bool {
2947 let Some(rx) = &mut self.item_cost_rx else {
2948 return false;
2949 };
2950 match rx.try_recv() {
2951 Ok((result, ws)) => {
2952 if result.is_err() {
2953 self.preview_warehouse = None;
2954 }
2955 if ws.is_some() {
2956 self.workspace_id = ws;
2957 }
2958 if let Some(cv) = &mut self.item_cost {
2959 cv.data = Some(result);
2960 }
2961 self.item_cost_rx = None;
2962 true
2963 }
2964 Err(oneshot::error::TryRecvError::Empty) => false,
2965 Err(oneshot::error::TryRecvError::Closed) => {
2966 self.item_cost_rx = None;
2967 true
2968 }
2969 }
2970 }
2971
2972 pub fn wh_picker_next(&mut self) {
2973 let len = self.warehouses().len();
2974 if let Some(p) = &mut self.wh_picker {
2975 p.index = (p.index + 1).min(len.saturating_sub(1));
2976 }
2977 }
2978
2979 pub fn wh_picker_prev(&mut self) {
2980 if let Some(p) = &mut self.wh_picker {
2981 p.index = p.index.saturating_sub(1);
2982 }
2983 }
2984
2985 pub fn wh_picker_cancel(&mut self) {
2986 self.wh_picker = None;
2987 }
2988
2989 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
2991 let Some(picker) = self.wh_picker.take() else {
2992 return;
2993 };
2994 let warehouses = self.warehouses();
2995 let Some((name, id, _)) = warehouses.get(picker.index) else {
2996 return;
2997 };
2998 self.preview_warehouse = Some((id.clone(), name.clone()));
2999 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
3001 self.config
3002 .warehouses
3003 .insert(profile, (id.clone(), name.clone()));
3004 self.config.save();
3005 match picker.target {
3006 PickTarget::Preview(table) => {
3007 self.start_preview_query(cli, table, id.clone(), name.clone())
3008 }
3009 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
3010 PickTarget::ItemCost(kind, item_id, item_name) => {
3011 self.start_item_cost_query(cli, id.clone(), name.clone(), kind, item_id, item_name)
3012 }
3013 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
3014 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
3015 }
3016 }
3017
3018 fn start_preview_query(
3019 &mut self,
3020 cli: &Arc<DatabricksCli>,
3021 full_name: String,
3022 warehouse_id: String,
3023 warehouse_name: String,
3024 ) {
3025 self.preview = Some(Preview {
3026 name: full_name.clone(),
3027 warehouse: warehouse_name,
3028 warehouse_id: warehouse_id.clone(),
3029 data: None,
3030 scroll: 0,
3031 col: 0,
3032 filter: String::new(),
3033 filter_entry: false,
3034 record: false,
3035 rscroll: 0,
3036 });
3037 let (tx, rx) = oneshot::channel();
3038 self.preview_rx = Some(rx);
3039 let cli = Arc::clone(cli);
3040 tokio::spawn(async move {
3041 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
3042 let _ = tx.send(result);
3043 });
3044 }
3045
3046 pub fn close_preview(&mut self) {
3047 self.preview = None;
3048 self.preview_rx = None;
3049 }
3050
3051 pub fn poll_preview(&mut self) -> bool {
3052 let Some(rx) = &mut self.preview_rx else {
3053 return false;
3054 };
3055 match rx.try_recv() {
3056 Ok(result) => {
3057 if result.is_err() {
3059 self.preview_warehouse = None;
3060 }
3061 if let Some(pv) = &mut self.preview {
3062 pv.data = Some(result);
3063 }
3064 self.preview_rx = None;
3065 true
3066 }
3067 Err(oneshot::error::TryRecvError::Empty) => false,
3068 Err(oneshot::error::TryRecvError::Closed) => {
3069 self.preview_rx = None;
3070 true
3071 }
3072 }
3073 }
3074
3075 pub fn preview_scroll(&mut self, delta: i32) {
3076 if let Some(pv) = &mut self.preview {
3077 if pv.record {
3079 let max = pv.visible_cols().len().saturating_sub(1) as u16;
3080 pv.rscroll = if delta < 0 {
3081 pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
3082 } else {
3083 pv.rscroll.saturating_add(delta as u16).min(max)
3084 };
3085 return;
3086 }
3087 let max = match &pv.data {
3088 Some(Ok(t)) => t.rows.len().saturating_sub(1),
3089 _ => 0,
3090 };
3091 pv.scroll = if delta < 0 {
3092 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
3093 } else {
3094 (pv.scroll + delta as usize).min(max)
3095 };
3096 }
3097 }
3098
3099 pub fn poll_uc(&mut self) -> bool {
3100 let Some(rx) = &mut self.uc_rx else {
3101 return false;
3102 };
3103 match rx.try_recv() {
3104 Ok(result) => {
3105 self.shapes[5] = Some(match result {
3106 Ok(shape) => shape,
3107 Err(e) => Shape::Text(format!("✗ {e}")),
3108 });
3109 self.updated_at[5] = Some(Instant::now());
3110 self.uc_rx = None;
3111 true
3112 }
3113 Err(oneshot::error::TryRecvError::Empty) => false,
3114 Err(oneshot::error::TryRecvError::Closed) => {
3115 self.uc_rx = None;
3116 true
3117 }
3118 }
3119 }
3120
3121 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
3124 if self.focus == Panel::Secrets {
3125 return self.open_secret_acls(cli);
3126 }
3127 let Some(item) = self.selected_item() else {
3128 return;
3129 };
3130 let Some(id) = item.id.clone() else {
3131 return;
3132 };
3133 let (uc, object_type): (bool, &'static str) = match self.focus {
3134 Panel::Catalog => match &item.status {
3135 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
3136 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
3137 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
3138 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
3139 _ => return,
3140 },
3141 Panel::Clusters => (false, "clusters"),
3142 Panel::Jobs => (false, "jobs"),
3143 Panel::Pipelines => (false, "pipelines"),
3144 Panel::Warehouses => (false, "warehouses"),
3145 Panel::Dashboards => (false, "dashboards"),
3146 Panel::Secrets => return,
3148 };
3149 self.detail = Some(Detail {
3150 panel: self.focus,
3151 name: item.name.clone(),
3152 id: id.clone(),
3153 kind: None,
3154 section: "Access",
3155 data: None,
3156 show_raw: false,
3157 scroll: 0,
3158 });
3159 let (tx, rx) = oneshot::channel();
3160 self.detail_rx = Some(rx);
3161 let cli = Arc::clone(cli);
3162 tokio::spawn(async move {
3163 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
3164 let _ = tx.send(data);
3165 });
3166 }
3167
3168 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
3171 let Some(d) = &self.detail else {
3172 return;
3173 };
3174 let panel = d.panel;
3175 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
3176 return;
3177 }
3178 let owner_id = d.id.clone();
3179 self.run_view = Some(RunView {
3180 panel,
3181 owner_name: d.name.clone(),
3182 owner_id: owner_id.clone(),
3183 runs: Vec::new(),
3184 idx: 0,
3185 data: None,
3186 show_raw: false,
3187 scroll: 0,
3188 live: false,
3189 output: None,
3190 show_output: false,
3191 show_timeline: false,
3192 show_dag: false,
3193 show_grid: false,
3194 grid: None,
3195 fetched_at: Instant::now(),
3196 });
3197 let (tx, rx) = oneshot::channel();
3198 self.run_rx = Some(rx);
3199 let cli = Arc::clone(cli);
3200 tokio::spawn(async move {
3201 let result = async {
3202 let runs = if panel == Panel::Jobs {
3203 fetchers::runs::list(&cli, &owner_id).await?
3204 } else {
3205 fetchers::updates::list(&cli, &owner_id).await?
3206 };
3207 let Some((run_id, _, _)) = runs.first().cloned() else {
3208 return Err("no runs recorded yet".to_string());
3209 };
3210 let (data, live) = if panel == Panel::Jobs {
3211 fetchers::runs::fetch(&cli, &run_id).await
3212 } else {
3213 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
3214 };
3215 Ok((runs, data, live))
3216 }
3217 .await;
3218 let _ = tx.send(RunUpdate::Opened(result));
3219 });
3220 }
3221
3222 pub fn close_run(&mut self) {
3223 self.run_view = None;
3224 self.run_rx = None;
3225 self.grid_rx = None;
3226 }
3227
3228 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
3230 if self.run_rx.is_some() {
3231 return;
3232 }
3233 let Some(rv) = &mut self.run_view else {
3234 return;
3235 };
3236 if rv.runs.is_empty() {
3237 return;
3238 }
3239 let new = if delta < 0 {
3240 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
3241 } else {
3242 (rv.idx + delta as usize).min(rv.runs.len() - 1)
3243 };
3244 if new == rv.idx {
3245 return;
3246 }
3247 rv.idx = new;
3248 rv.data = None;
3249 rv.scroll = 0;
3250 rv.show_raw = false;
3251 rv.output = None;
3252 rv.show_output = false;
3253 let run_id = rv.runs[new].0.clone();
3254 self.start_run_fetch(cli, run_id);
3255 }
3256
3257 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
3258 let Some(rv) = &self.run_view else {
3259 return;
3260 };
3261 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
3262 let (tx, rx) = oneshot::channel();
3263 self.run_rx = Some(rx);
3264 let cli = Arc::clone(cli);
3265 tokio::spawn(async move {
3266 let (data, live) = if panel == Panel::Jobs {
3267 fetchers::runs::fetch(&cli, &run_id).await
3268 } else {
3269 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
3270 };
3271 let _ = tx.send(RunUpdate::Detail(data, live));
3272 });
3273 }
3274
3275 pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
3278 let Some(rv) = &mut self.run_view else {
3279 return;
3280 };
3281 if rv.panel != Panel::Jobs {
3282 self.flash = Some((
3283 "✗ output view is for job runs — pipeline events are already inline".to_string(),
3284 Instant::now(),
3285 ));
3286 return;
3287 }
3288 if rv.show_output {
3289 rv.show_output = false;
3290 rv.scroll = 0;
3291 return;
3292 }
3293 if rv.output.is_none() && self.run_rx.is_some() {
3294 self.flash = Some((
3295 "⏳ run still loading — try again in a moment".to_string(),
3296 Instant::now(),
3297 ));
3298 return;
3299 }
3300 rv.show_output = true;
3301 rv.scroll = 0;
3302 if rv.output.is_some() {
3303 return;
3304 }
3305 self.start_output_fetch(cli);
3306 }
3307
3308 fn start_output_fetch(&mut self, cli: &Arc<DatabricksCli>) {
3309 let Some(rv) = &self.run_view else {
3310 return;
3311 };
3312 let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
3313 return;
3314 };
3315 let (tx, rx) = oneshot::channel();
3316 self.run_rx = Some(rx);
3317 let cli = Arc::clone(cli);
3318 tokio::spawn(async move {
3319 let (text, live) = fetchers::runs::full_output(&cli, &run_id).await;
3320 let _ = tx.send(RunUpdate::Output(text, live));
3321 });
3322 }
3323
3324 pub fn request_run_repair(&mut self) {
3326 let Some(rv) = &self.run_view else {
3327 return;
3328 };
3329 if rv.panel != Panel::Jobs {
3330 self.flash = Some((
3331 "✗ repair applies to job runs only".to_string(),
3332 Instant::now(),
3333 ));
3334 return;
3335 }
3336 if rv.live {
3337 self.flash = Some((
3338 "✗ run is still executing — cancel it first (s)".to_string(),
3339 Instant::now(),
3340 ));
3341 return;
3342 }
3343 let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
3344 return;
3345 };
3346 if matches!(status, Status::Success) {
3347 self.flash = Some((
3348 "✗ run succeeded — nothing to repair".to_string(),
3349 Instant::now(),
3350 ));
3351 return;
3352 }
3353 self.confirm = Some(Confirm {
3354 message: format!(
3355 "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
3356 rv.owner_name
3357 ),
3358 args: vec![
3359 "jobs".to_string(),
3360 "repair-run".to_string(),
3361 run_id.clone(),
3362 "--rerun-all-failed-tasks".to_string(),
3363 ],
3364 params: None,
3365 });
3366 }
3367
3368 pub fn run_toggle_timeline(&mut self) {
3370 let Some(rv) = &mut self.run_view else {
3371 return;
3372 };
3373 if rv.panel != Panel::Jobs {
3374 self.flash = Some((
3375 "✗ timeline is for job runs — pipeline events are already inline".to_string(),
3376 Instant::now(),
3377 ));
3378 return;
3379 }
3380 rv.show_timeline = !rv.show_timeline;
3381 rv.show_dag = false;
3382 rv.show_grid = false;
3383 rv.scroll = 0;
3384 }
3385
3386 pub fn run_toggle_dag(&mut self) {
3388 let Some(rv) = &mut self.run_view else {
3389 return;
3390 };
3391 if rv.panel != Panel::Jobs {
3392 self.flash = Some((
3393 "✗ the task DAG is for job runs — pipelines have no task graph here".to_string(),
3394 Instant::now(),
3395 ));
3396 return;
3397 }
3398 rv.show_dag = !rv.show_dag;
3399 rv.show_timeline = false;
3400 rv.show_grid = false;
3401 rv.scroll = 0;
3402 }
3403
3404 pub fn run_toggle_grid(&mut self, cli: &Arc<DatabricksCli>) {
3407 let Some(rv) = &mut self.run_view else {
3408 return;
3409 };
3410 if rv.panel != Panel::Jobs {
3411 self.flash = Some((
3412 "✗ the history grid is for job runs — updates have no task matrix".to_string(),
3413 Instant::now(),
3414 ));
3415 return;
3416 }
3417 rv.show_grid = !rv.show_grid;
3418 rv.show_timeline = false;
3419 rv.show_dag = false;
3420 rv.scroll = 0;
3421 if !rv.show_grid || rv.grid.is_some() || self.grid_rx.is_some() {
3422 return;
3423 }
3424 let job_id = rv.owner_id.clone();
3425 let (tx, rx) = oneshot::channel();
3426 self.grid_rx = Some(rx);
3427 let cli = Arc::clone(cli);
3428 tokio::spawn(async move {
3429 let _ = tx.send(fetchers::runs::grid(&cli, &job_id).await);
3430 });
3431 }
3432
3433 pub fn poll_grid(&mut self) -> bool {
3434 let Some(rx) = &mut self.grid_rx else {
3435 return false;
3436 };
3437 match rx.try_recv() {
3438 Ok(result) => {
3439 self.grid_rx = None;
3440 if let Some(rv) = &mut self.run_view {
3441 rv.grid = Some(result);
3442 }
3443 true
3444 }
3445 Err(oneshot::error::TryRecvError::Empty) => false,
3446 Err(oneshot::error::TryRecvError::Closed) => {
3447 self.grid_rx = None;
3448 true
3449 }
3450 }
3451 }
3452
3453 pub fn run_toggle_raw(&mut self) {
3454 if let Some(rv) = &mut self.run_view {
3455 rv.show_raw = !rv.show_raw;
3456 rv.scroll = 0;
3457 }
3458 }
3459
3460 pub fn run_scroll(&mut self, delta: i32) {
3461 if let Some(rv) = &mut self.run_view {
3462 rv.scroll = if delta < 0 {
3463 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
3464 } else {
3465 rv.scroll.saturating_add(delta as u16)
3466 };
3467 }
3468 }
3469
3470 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3473 if let Some(rx) = &mut self.run_rx {
3474 match rx.try_recv() {
3475 Ok(update) => {
3476 self.run_rx = None;
3477 if let Some(rv) = &mut self.run_view {
3478 match update {
3479 RunUpdate::Opened(Ok((runs, data, live))) => {
3480 rv.runs = runs;
3481 rv.idx = 0;
3482 rv.data = Some(data);
3483 rv.live = live;
3484 }
3485 RunUpdate::Opened(Err(e)) => {
3486 rv.data = Some(DetailData {
3487 summary: Vec::new(),
3488 activity: Vec::new(),
3489 raw: format!("✗ {e}"),
3490 });
3491 rv.live = false;
3492 }
3493 RunUpdate::Detail(data, live) => {
3494 rv.data = Some(data);
3495 rv.live = live;
3496 }
3497 RunUpdate::Output(text, live) => {
3498 rv.output = Some(text);
3499 rv.live = live;
3500 }
3501 }
3502 rv.fetched_at = Instant::now();
3503 }
3504 true
3505 }
3506 Err(oneshot::error::TryRecvError::Empty) => false,
3507 Err(oneshot::error::TryRecvError::Closed) => {
3508 self.run_rx = None;
3509 true
3510 }
3511 }
3512 } else if let Some(rv) = &self.run_view {
3513 if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
3514 if rv.show_output {
3515 if rv.output.is_some() {
3518 self.start_output_fetch(cli);
3519 }
3520 } else if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
3521 self.start_run_fetch(cli, run_id);
3522 }
3523 }
3524 false
3525 } else {
3526 false
3527 }
3528 }
3529
3530 pub fn close_detail(&mut self) {
3531 self.detail = None;
3532 self.detail_rx = None;
3533 }
3534
3535 pub fn toggle_raw(&mut self) {
3536 if let Some(d) = &mut self.detail {
3537 d.show_raw = !d.show_raw;
3538 d.scroll = 0;
3539 }
3540 }
3541
3542 pub fn poll_detail(&mut self) -> bool {
3544 let Some(rx) = &mut self.detail_rx else {
3545 return false;
3546 };
3547 match rx.try_recv() {
3548 Ok(data) => {
3549 if let Some(d) = &mut self.detail {
3550 d.data = Some(data);
3551 }
3552 self.detail_rx = None;
3553 true
3554 }
3555 Err(oneshot::error::TryRecvError::Empty) => false,
3556 Err(oneshot::error::TryRecvError::Closed) => {
3557 self.detail_rx = None;
3558 true
3559 }
3560 }
3561 }
3562
3563 pub fn detail_scroll(&mut self, delta: i32) {
3564 if let Some(d) = &mut self.detail {
3565 let max = match &d.data {
3566 Some(data) if d.show_raw => data.raw.lines().count(),
3567 Some(data) => data.summary.len() + data.activity.len() + 3,
3568 None => 0,
3569 } as u16;
3570 d.scroll = if delta < 0 {
3571 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
3572 } else {
3573 (d.scroll + delta as u16).min(max.saturating_sub(1))
3574 };
3575 }
3576 }
3577
3578 pub fn request_action(&mut self) {
3581 if matches!(
3583 self.focus,
3584 Panel::Dashboards | Panel::Catalog | Panel::Secrets
3585 ) {
3586 return;
3587 }
3588 let Some(item) = self.selected_item() else {
3589 return;
3590 };
3591 let Some(id) = item.id.clone() else {
3592 return;
3593 };
3594 let name = item.name.clone();
3595 let active = matches!(
3596 item.status,
3597 Status::Running | Status::Pending | Status::Success
3598 );
3599 let group = self.focus.cli_group();
3600 let (verb, action): (&str, &str) = match self.focus {
3601 Panel::Jobs => ("Run", "run-now"),
3602 Panel::Clusters if active => ("Stop", "delete"),
3603 Panel::Pipelines if active => ("Stop", "stop"),
3604 Panel::Pipelines => ("Start update for", "start-update"),
3605 _ if active => ("Stop", "stop"),
3606 _ => ("Start", "start"),
3607 };
3608 self.confirm = Some(Confirm {
3609 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
3610 params: (self.focus == Panel::Jobs).then(|| (id.clone(), name.clone())),
3611 args: vec![group.to_string(), action.to_string(), id],
3612 });
3613 }
3614
3615 pub fn request_schedule_toggle(&mut self, cli: &Arc<DatabricksCli>) {
3619 if self.focus != Panel::Jobs {
3620 self.flash = Some((
3621 "✗ schedule pause applies to jobs — focus the Lakeflow pane".to_string(),
3622 Instant::now(),
3623 ));
3624 return;
3625 }
3626 let Some(item) = self.selected_item() else {
3627 return;
3628 };
3629 let Some(id) = item.id.clone() else {
3630 return;
3631 };
3632 let name = item.name.clone();
3633 if self.action_rx.is_some() {
3634 self.flash = Some((
3635 "⏳ another action is still in flight".to_string(),
3636 Instant::now(),
3637 ));
3638 return;
3639 }
3640 self.flash = Some((format!("⏳ toggling schedule of “{name}”…"), Instant::now()));
3641 let (tx, rx) = oneshot::channel();
3642 self.action_rx = Some(rx);
3643 let cli = Arc::clone(cli);
3644 tokio::spawn(async move {
3645 let _ = tx.send(fetchers::jobs::toggle_pause(&cli, &id, &name).await);
3646 });
3647 }
3648
3649 pub fn request_run_cancel(&mut self) {
3651 let Some(rv) = &self.run_view else {
3652 return;
3653 };
3654 if !rv.live {
3655 self.flash = Some((
3656 "✗ nothing to cancel — this run already finished".to_string(),
3657 Instant::now(),
3658 ));
3659 return;
3660 }
3661 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3662 return;
3663 };
3664 let (message, args) = if rv.panel == Panel::Jobs {
3665 (
3666 format!("Cancel run {run_id} of “{}”?", rv.owner_name),
3667 vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
3668 )
3669 } else {
3670 (
3671 format!("Stop “{}” (cancels the active update)?", rv.owner_name),
3672 vec![
3673 "pipelines".to_string(),
3674 "stop".to_string(),
3675 rv.owner_id.clone(),
3676 ],
3677 )
3678 };
3679 self.confirm = Some(Confirm {
3680 message,
3681 args,
3682 params: None,
3683 });
3684 }
3685
3686 pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
3689 let id = self
3690 .sql_stmt
3691 .as_ref()
3692 .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
3693 let Some(id) = id else {
3694 self.flash = Some((
3695 "✗ statement not submitted yet — try again in a moment".to_string(),
3696 Instant::now(),
3697 ));
3698 return;
3699 };
3700 let cli = Arc::clone(cli);
3701 tokio::spawn(async move {
3702 let path = format!("/api/2.0/sql/statements/{id}/cancel");
3703 let _ = cli.run_action(&["api", "post", &path]).await;
3704 });
3705 self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
3706 }
3707
3708 pub fn cancel_confirm(&mut self) {
3709 self.confirm = None;
3710 }
3711
3712 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
3713 let Some(c) = self.confirm.take() else {
3714 return;
3715 };
3716 let base = c.message.trim_end_matches('?').to_string();
3717 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3718
3719 let (tx, rx) = oneshot::channel();
3720 self.action_rx = Some(rx);
3721 let cli = Arc::clone(cli);
3722 tokio::spawn(async move {
3723 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
3724 let result = match cli.run_action(&args).await {
3725 Ok(()) => Ok(format!("✓ {base} — done")),
3726 Err(e) => Err(format!("✗ {e:#}")),
3727 };
3728 let _ = tx.send(result);
3729 });
3730 }
3731
3732 pub fn open_param_form(&mut self, cli: &Arc<DatabricksCli>) {
3735 let Some((job_id, name)) = self.confirm.take().and_then(|c| c.params) else {
3736 return;
3737 };
3738 self.param_form = Some(ParamForm {
3739 job_id: job_id.clone(),
3740 job: name,
3741 input: String::new(),
3742 cursor: 0,
3743 kind: fetchers::jobs::ParamKind::Notebook,
3744 loading: true,
3745 });
3746 let (tx, rx) = oneshot::channel();
3747 self.param_rx = Some(rx);
3748 let cli = Arc::clone(cli);
3749 tokio::spawn(async move {
3750 let _ = tx.send(fetchers::jobs::params(&cli, &job_id).await);
3751 });
3752 }
3753
3754 pub fn poll_param(&mut self) -> bool {
3756 let Some(rx) = &mut self.param_rx else {
3757 return false;
3758 };
3759 match rx.try_recv() {
3760 Ok(result) => {
3761 self.param_rx = None;
3762 let Some(form) = &mut self.param_form else {
3763 return true;
3764 };
3765 match result {
3766 Ok((pairs, kind)) => {
3767 form.input = pairs
3768 .iter()
3769 .map(|(k, v)| format!("{k}={v}"))
3770 .collect::<Vec<_>>()
3771 .join(", ");
3772 form.cursor = form.input.chars().count();
3773 form.kind = kind;
3774 form.loading = false;
3775 }
3776 Err(e) => {
3777 self.param_form = None;
3778 self.flash = Some((e, Instant::now()));
3779 }
3780 }
3781 true
3782 }
3783 Err(oneshot::error::TryRecvError::Empty) => false,
3784 Err(oneshot::error::TryRecvError::Closed) => {
3785 self.param_rx = None;
3786 self.param_form = None;
3787 true
3788 }
3789 }
3790 }
3791
3792 pub fn param_push(&mut self, c: char) {
3793 if let Some(form) = self.param_form.as_mut().filter(|f| !f.loading) {
3794 let at = byte_at(&form.input, form.cursor);
3795 form.input.insert(at, c);
3796 form.cursor += 1;
3797 }
3798 }
3799
3800 pub fn param_pop(&mut self) {
3801 if let Some(form) = self.param_form.as_mut().filter(|f| !f.loading) {
3802 if form.cursor > 0 {
3803 form.cursor -= 1;
3804 let at = byte_at(&form.input, form.cursor);
3805 form.input.remove(at);
3806 }
3807 }
3808 }
3809
3810 pub fn param_left(&mut self) {
3811 if let Some(form) = &mut self.param_form {
3812 form.cursor = form.cursor.saturating_sub(1);
3813 }
3814 }
3815
3816 pub fn param_right(&mut self) {
3817 if let Some(form) = &mut self.param_form {
3818 form.cursor = (form.cursor + 1).min(form.input.chars().count());
3819 }
3820 }
3821
3822 pub fn param_home(&mut self) {
3823 if let Some(form) = &mut self.param_form {
3824 form.cursor = 0;
3825 }
3826 }
3827
3828 pub fn param_end(&mut self) {
3829 if let Some(form) = &mut self.param_form {
3830 form.cursor = form.input.chars().count();
3831 }
3832 }
3833
3834 pub fn param_submit(&mut self, cli: &Arc<DatabricksCli>) {
3837 let Some(form) = &self.param_form else {
3838 return;
3839 };
3840 if form.loading {
3841 return;
3842 }
3843 let pairs = match parse_params(&form.input) {
3844 Ok(pairs) => pairs,
3845 Err(e) => {
3846 self.flash = Some((e, Instant::now()));
3847 return;
3848 }
3849 };
3850 let Some(form) = self.param_form.take() else {
3851 return;
3852 };
3853 let Ok(id) = form.job_id.parse::<u64>() else {
3854 self.flash = Some((format!("✗ bad job id: {}", form.job_id), Instant::now()));
3855 return;
3856 };
3857 let (base, args) = if pairs.is_empty() {
3858 (
3859 format!("Run job “{}”", form.job),
3860 vec!["jobs".to_string(), "run-now".to_string(), form.job_id],
3861 )
3862 } else {
3863 let map: serde_json::Map<String, serde_json::Value> = pairs
3864 .into_iter()
3865 .map(|(k, v)| (k, serde_json::Value::String(v)))
3866 .collect();
3867 let payload =
3868 serde_json::json!({"job_id": id, form.kind.payload_key(): map}).to_string();
3869 (
3870 format!("Run job “{}” with parameters", form.job),
3871 vec![
3872 "jobs".to_string(),
3873 "run-now".to_string(),
3874 "--json".to_string(),
3875 payload,
3876 ],
3877 )
3878 };
3879 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3880 let (tx, rx) = oneshot::channel();
3881 self.action_rx = Some(rx);
3882 let cli = Arc::clone(cli);
3883 tokio::spawn(async move {
3884 let args: Vec<&str> = args.iter().map(String::as_str).collect();
3885 let result = match cli.run_action(&args).await {
3886 Ok(()) => Ok(format!("✓ {base} — done")),
3887 Err(e) => Err(format!("✗ {e:#}")),
3888 };
3889 let _ = tx.send(result);
3890 });
3891 }
3892
3893 pub fn close_param_form(&mut self) {
3894 self.param_form = None;
3895 self.param_rx = None;
3896 }
3897
3898 pub fn toggle_watch(&mut self) {
3901 let Some(rv) = &self.run_view else {
3902 return;
3903 };
3904 if rv.panel != Panel::Jobs {
3905 self.flash = Some((
3906 "✗ watching is for job runs — pipeline updates refresh inline".to_string(),
3907 Instant::now(),
3908 ));
3909 return;
3910 }
3911 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3912 return;
3913 };
3914 if let Some(pos) = self.watched.iter().position(|w| w.run_id == *run_id) {
3915 let w = self.watched.remove(pos);
3916 self.flash = Some((
3917 format!("✓ no longer watching run {} of “{}”", w.run_id, w.job),
3918 Instant::now(),
3919 ));
3920 return;
3921 }
3922 if !rv.live {
3923 self.flash = Some((
3924 "✗ this run already finished — nothing to watch".to_string(),
3925 Instant::now(),
3926 ));
3927 return;
3928 }
3929 self.watched.push(Watched {
3930 run_id: run_id.clone(),
3931 job: rv.owner_name.clone(),
3932 });
3933 self.flash = Some((
3934 format!(
3935 "👁 watching run {} of “{}” — bell when it finishes",
3936 run_id, rv.owner_name
3937 ),
3938 Instant::now(),
3939 ));
3940 }
3941
3942 pub fn poll_watch(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3945 if let Some(rx) = &mut self.watch_rx {
3946 return match rx.try_recv() {
3947 Ok(states) => {
3948 self.watch_rx = None;
3949 let mut done: Vec<(String, Status)> = Vec::new();
3950 for (run_id, state) in states {
3951 if let Ok((status, false)) = state {
3954 if let Some(pos) = self.watched.iter().position(|w| w.run_id == run_id)
3955 {
3956 done.push((self.watched.remove(pos).job, status));
3957 }
3958 }
3959 }
3960 if let Some((job, status)) = done.first() {
3961 let extra = if done.len() > 1 {
3962 format!(" (+{} more)", done.len() - 1)
3963 } else {
3964 String::new()
3965 };
3966 self.flash = Some((
3967 if matches!(status, Status::Failed) {
3968 format!("✗ watched run of “{job}” FAILED{extra} — ! to inspect")
3969 } else {
3970 format!("🔔 “{job}” run finished: {}{extra}", status.label())
3971 },
3972 Instant::now(),
3973 ));
3974 print!("\x07");
3975 let _ = std::io::Write::flush(&mut std::io::stdout());
3976 }
3977 !done.is_empty()
3978 }
3979 Err(oneshot::error::TryRecvError::Empty) => false,
3980 Err(oneshot::error::TryRecvError::Closed) => {
3981 self.watch_rx = None;
3982 false
3983 }
3984 };
3985 }
3986 if self.watched.is_empty() || self.watch_at.elapsed() < WATCH_INTERVAL {
3987 return false;
3988 }
3989 self.watch_at = Instant::now();
3990 let ids: Vec<String> = self.watched.iter().map(|w| w.run_id.clone()).collect();
3991 let (tx, rx) = oneshot::channel();
3992 self.watch_rx = Some(rx);
3993 let cli = Arc::clone(cli);
3994 tokio::spawn(async move {
3995 let mut out = Vec::with_capacity(ids.len());
3996 for id in ids {
3997 out.push((id.clone(), fetchers::runs::state(&cli, &id).await));
3998 }
3999 let _ = tx.send(out);
4000 });
4001 false
4002 }
4003
4004 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
4006 let Some(rx) = &mut self.action_rx else {
4007 return false;
4008 };
4009 match rx.try_recv() {
4010 Ok(result) => {
4011 let ok = result.is_ok();
4012 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
4013 self.action_rx = None;
4014 if ok {
4015 self.start_refresh(cli);
4016 if self.run_rx.is_none() {
4019 let current = self
4020 .run_view
4021 .as_ref()
4022 .and_then(|rv| rv.runs.get(rv.idx).cloned());
4023 if let Some((run_id, _, _)) = current {
4024 self.start_run_fetch(cli, run_id);
4025 }
4026 }
4027 }
4028 true
4029 }
4030 Err(oneshot::error::TryRecvError::Empty) => false,
4031 Err(oneshot::error::TryRecvError::Closed) => {
4032 self.action_rx = None;
4033 true
4034 }
4035 }
4036 }
4037
4038 pub fn expire_flash(&mut self) -> bool {
4040 if let Some((_, since)) = &self.flash {
4041 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
4042 self.flash = None;
4043 return true;
4044 }
4045 }
4046 false
4047 }
4048
4049 pub fn open_in_browser(&self) {
4051 let Some(host) = &self.host else {
4052 return;
4053 };
4054 let (panel, id) = match &self.detail {
4055 Some(d) => (d.panel, Some(d.id.clone())),
4056 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
4057 };
4058 let Some(id) = id else {
4059 return;
4060 };
4061 let path = match panel {
4062 Panel::Clusters => format!("compute/clusters/{id}"),
4063 Panel::Jobs => format!("jobs/{id}"),
4064 Panel::Pipelines => format!("pipelines/{id}"),
4065 Panel::Warehouses => format!("sql/warehouses/{id}"),
4066 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
4067 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
4068 Panel::Secrets => return,
4070 };
4071 let url = format!("{}/{}", host.trim_end_matches('/'), path);
4072 #[cfg(target_os = "macos")]
4073 let opener = "open";
4074 #[cfg(not(target_os = "macos"))]
4075 let opener = "xdg-open";
4076 let _ = std::process::Command::new(opener).arg(url).spawn();
4077 }
4078
4079 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
4081 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
4082 for shape in self.shapes.iter().flatten() {
4083 if let Shape::List(items) = shape {
4084 for item in items {
4085 match item.status {
4086 Status::Running | Status::Success => ok += 1,
4087 Status::Pending => pending += 1,
4088 Status::Failed => failed += 1,
4089 Status::Stopped => idle += 1,
4090 Status::Unknown(_) => {}
4091 }
4092 }
4093 }
4094 }
4095 (ok, pending, failed, idle)
4096 }
4097
4098 pub fn last_refresh_age(&self) -> Duration {
4099 self.last_refresh.elapsed()
4100 }
4101
4102 pub fn spinner(&self) -> &'static str {
4103 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
4104 }
4105
4106 pub fn spinner_frame(&self) -> usize {
4107 self.spinner_frame
4108 }
4109
4110 pub fn busy(&self) -> bool {
4113 self.loading
4114 || self.detail_rx.is_some()
4115 || self.action_rx.is_some()
4116 || self.preview_rx.is_some()
4117 || self.cost_rx.is_some()
4118 || self.item_cost_rx.is_some()
4119 || self.sql_rx.is_some()
4120 || self.run_rx.is_some()
4121 }
4122
4123 pub fn tick_spinner(&mut self) {
4124 self.spinner_frame = self.spinner_frame.wrapping_add(1);
4125 }
4126
4127 pub fn toggle_zoom(&mut self) {
4128 self.zoomed = !self.zoomed;
4129 }
4130
4131 pub fn focus_next(&mut self) {
4132 self.cycle_focus(1);
4133 }
4134
4135 pub fn focus_prev(&mut self) {
4136 self.cycle_focus(-1);
4137 }
4138
4139 fn cycle_focus(&mut self, delta: i32) {
4141 let visible = self.visible_panes();
4142 if visible.is_empty() {
4143 return;
4144 }
4145 let focus_idx = Panel::ALL
4146 .iter()
4147 .position(|p| p == &self.focus)
4148 .unwrap_or(0);
4149 let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
4150 let n = visible.len() as i32;
4151 let next = ((pos as i32 + delta) % n + n) % n;
4152 self.focus = Panel::ALL[visible[next as usize]];
4153 }
4154
4155 pub fn needs_refresh(&self) -> bool {
4156 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
4157 }
4158
4159 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
4160 if self.loading {
4161 return;
4162 }
4163 self.loading = true;
4164 self.error = None;
4165 self.last_refresh = Instant::now();
4166
4167 let (tx, rx) = mpsc::unbounded_channel();
4168 self.pending = Some(rx);
4169 self.in_flight = 8;
4170
4171 macro_rules! spawn_fetch {
4174 ($update:expr, $fetch:path) => {{
4175 let cli = Arc::clone(cli);
4176 let tx = tx.clone();
4177 tokio::spawn(async move {
4178 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
4179 let _ = tx.send($update(result));
4180 });
4181 }};
4182 }
4183
4184 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
4185 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
4186 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
4187 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
4188 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
4189 spawn_fetch!(
4190 |s: Result<Shape, String>| Update::Badge(s.ok()),
4191 fetchers::current_user::fetch
4192 );
4193 {
4194 let cli = Arc::clone(cli);
4195 let tx = tx.clone();
4196 let path = self.uc_path.clone();
4197 tokio::spawn(async move {
4198 let result = fetchers::catalog::fetch(&cli, &path)
4199 .await
4200 .map_err(|e| format!("{e:#}"));
4201 let _ = tx.send(Update::Panel(5, result));
4202 });
4203 }
4204 {
4205 let cli = Arc::clone(cli);
4206 let tx = tx.clone();
4207 let scope = self.secret_scope.clone();
4208 tokio::spawn(async move {
4209 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
4210 .await
4211 .map_err(|e| format!("{e:#}"));
4212 let _ = tx.send(Update::Panel(6, result));
4213 });
4214 }
4215 }
4216
4217 pub fn poll_refresh(&mut self) -> bool {
4219 let Some(rx) = &mut self.pending else {
4220 return false;
4221 };
4222 let mut changed = false;
4223 let mut updated_panes: Vec<usize> = Vec::new();
4224 loop {
4225 match rx.try_recv() {
4226 Ok(Update::Panel(i, result)) => {
4227 match result {
4228 Ok(mut shape) => {
4229 if i != 5 {
4233 if let Shape::List(items) = &mut shape {
4234 items.sort_by_key(|it| {
4235 (it.status.rank(), it.history.is_empty())
4236 });
4237 }
4238 }
4239 self.shapes[i] = Some(shape);
4240 self.updated_at[i] = Some(Instant::now());
4241 updated_panes.push(i);
4242 }
4243 Err(e) => {
4246 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
4247 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
4248 }
4249 }
4250 }
4251 self.in_flight -= 1;
4252 changed = true;
4253 }
4254 Ok(Update::Badge(badge)) => {
4255 if badge.is_some() {
4256 self.user_badge = badge;
4257 }
4258 self.in_flight -= 1;
4259 changed = true;
4260 }
4261 Err(mpsc::error::TryRecvError::Empty) => break,
4262 Err(mpsc::error::TryRecvError::Disconnected) => {
4263 self.in_flight = 0;
4264 break;
4265 }
4266 }
4267 }
4268 for i in updated_panes {
4269 self.alert_new_failures(i);
4270 }
4271 if self.in_flight == 0 {
4272 self.loading = false;
4273 self.pending = None;
4274 changed = true;
4275 }
4276 changed
4277 }
4278}
4279
4280#[cfg(test)]
4281mod tests {
4282 use super::{from_table, parse_params, token_at_cursor};
4283
4284 #[test]
4285 fn parse_params_pairs_and_errors() {
4286 assert_eq!(parse_params(""), Ok(vec![]));
4287 assert_eq!(
4288 parse_params(" date=2026-07-18, mode=full "),
4289 Ok(vec![
4290 ("date".to_string(), "2026-07-18".to_string()),
4291 ("mode".to_string(), "full".to_string())
4292 ])
4293 );
4294 assert_eq!(
4296 parse_params("expr=a=b, flag="),
4297 Ok(vec![
4298 ("expr".to_string(), "a=b".to_string()),
4299 ("flag".to_string(), String::new())
4300 ])
4301 );
4302 assert!(parse_params("no-equals-here").is_err());
4303 assert!(parse_params("=orphan").is_err());
4304 }
4305
4306 #[test]
4307 fn token_bare_word() {
4308 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM ma", 16);
4309 assert_eq!((start, ctx.as_str(), prefix.as_str()), (14, "", "ma"));
4310 }
4311
4312 #[test]
4313 fn token_dotted_path() {
4314 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM main.sales.or", 27);
4315 assert_eq!(
4316 (start, ctx.as_str(), prefix.as_str()),
4317 (25, "main.sales", "or")
4318 );
4319 }
4320
4321 #[test]
4322 fn token_trailing_dot() {
4323 let (start, ctx, prefix) = token_at_cursor("main.", 5);
4324 assert_eq!((start, ctx.as_str(), prefix.as_str()), (5, "main", ""));
4325 }
4326
4327 #[test]
4328 fn token_mid_input() {
4329 let (start, ctx, prefix) = token_at_cursor("SELECT co FROM t", 9);
4331 assert_eq!((start, ctx.as_str(), prefix.as_str()), (7, "", "co"));
4332 }
4333
4334 #[test]
4335 fn from_table_fully_qualified() {
4336 assert_eq!(
4337 from_table("SELECT x FROM main.sales.orders WHERE x > 1").as_deref(),
4338 Some("main.sales.orders")
4339 );
4340 }
4341
4342 #[test]
4343 fn from_table_rejects_partial_names() {
4344 assert_eq!(from_table("SELECT x FROM orders"), None);
4345 assert_eq!(from_table("SELECT x FROM main.sales."), None);
4346 assert_eq!(from_table("SELECT 1"), None);
4347 }
4348}