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