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