notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

directory_picker.rs (16976B)


      1 use crate::path_utils::abbreviate_path;
      2 use crate::ui::keybind_hint::paint_keybind_hint;
      3 use egui::{RichText, Vec2};
      4 use std::collections::HashMap;
      5 use std::path::PathBuf;
      6 
      7 /// Maximum number of recent directories to store
      8 const MAX_RECENT_DIRECTORIES: usize = 10;
      9 
     10 /// Actions that can be triggered from the directory picker
     11 #[derive(Debug, Clone)]
     12 pub enum DirectoryPickerAction {
     13     /// User selected a directory
     14     DirectorySelected(PathBuf),
     15     /// User cancelled the picker
     16     Cancelled,
     17     /// User requested to browse for a directory (opens native dialog)
     18     BrowseRequested,
     19 }
     20 
     21 /// State for the directory picker modal
     22 pub struct DirectoryPicker {
     23     /// List of recently used directories
     24     pub recent_directories: Vec<PathBuf>,
     25     /// Whether the picker is currently open
     26     pub is_open: bool,
     27     /// Text input for manual path entry
     28     path_input: String,
     29     /// Pending async folder picker result
     30     pending_folder_pick: Option<std::sync::mpsc::Receiver<Option<PathBuf>>>,
     31     /// When set, the picker targets a remote host (no browse, event-sourced paths).
     32     pub target_host: Option<String>,
     33     /// Per-host recent paths extracted from kind-31988 session state events.
     34     pub host_recent_paths: HashMap<String, Vec<PathBuf>>,
     35 }
     36 
     37 impl Default for DirectoryPicker {
     38     fn default() -> Self {
     39         Self::new()
     40     }
     41 }
     42 
     43 impl DirectoryPicker {
     44     pub fn new() -> Self {
     45         Self {
     46             recent_directories: Vec::new(),
     47             is_open: false,
     48             path_input: String::new(),
     49             pending_folder_pick: None,
     50             target_host: None,
     51             host_recent_paths: HashMap::new(),
     52         }
     53     }
     54 
     55     /// Open the picker
     56     pub fn open(&mut self) {
     57         self.is_open = true;
     58         self.path_input.clear();
     59     }
     60 
     61     /// Close the picker
     62     pub fn close(&mut self) {
     63         self.is_open = false;
     64         self.pending_folder_pick = None;
     65     }
     66 
     67     /// Populate per-host recent paths from session state events.
     68     /// Also seeds `recent_directories` from the local host's paths.
     69     pub fn seed_host_paths(&mut self, paths: HashMap<String, Vec<PathBuf>>, local_hostname: &str) {
     70         // Seed local recent_directories from this host's event-sourced paths
     71         if let Some(local_paths) = paths.get(local_hostname) {
     72             for path in local_paths {
     73                 if !self.recent_directories.contains(path) {
     74                     self.recent_directories.push(path.clone());
     75                 }
     76             }
     77             self.recent_directories.truncate(MAX_RECENT_DIRECTORIES);
     78         }
     79         self.host_recent_paths = paths;
     80     }
     81 
     82     /// Record a path for a specific host (called when new session state events arrive).
     83     pub fn add_host_path(&mut self, hostname: &str, path: PathBuf) {
     84         let paths = self
     85             .host_recent_paths
     86             .entry(hostname.to_string())
     87             .or_default();
     88         paths.retain(|p| p != &path);
     89         paths.insert(0, path);
     90         paths.truncate(MAX_RECENT_DIRECTORIES);
     91     }
     92 
     93     /// Add a directory to the recent list
     94     pub fn add_recent(&mut self, path: PathBuf) {
     95         // Remove if already exists (we'll re-add at front)
     96         self.recent_directories.retain(|p| p != &path);
     97         // Add to front
     98         self.recent_directories.insert(0, path);
     99         // Trim to max size
    100         self.recent_directories.truncate(MAX_RECENT_DIRECTORIES);
    101     }
    102 
    103     /// Check for pending folder picker result
    104     fn check_pending_pick(&mut self) -> Option<PathBuf> {
    105         if let Some(rx) = &self.pending_folder_pick {
    106             match rx.try_recv() {
    107                 Ok(Some(path)) => {
    108                     self.pending_folder_pick = None;
    109                     return Some(path);
    110                 }
    111                 Ok(None) => {
    112                     // User cancelled the dialog
    113                     self.pending_folder_pick = None;
    114                 }
    115                 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
    116                     self.pending_folder_pick = None;
    117                 }
    118                 Err(std::sync::mpsc::TryRecvError::Empty) => {
    119                     // Still waiting
    120                 }
    121             }
    122         }
    123         None
    124     }
    125 
    126     /// Render the directory picker as a full-panel overlay
    127     /// `has_sessions` indicates whether there are existing sessions (enables cancel)
    128     pub fn overlay_ui(
    129         &mut self,
    130         ui: &mut egui::Ui,
    131         has_sessions: bool,
    132     ) -> Option<DirectoryPickerAction> {
    133         // Check for pending folder pick result first
    134         if let Some(path) = self.check_pending_pick() {
    135             return Some(DirectoryPickerAction::DirectorySelected(path));
    136         }
    137 
    138         let mut action = None;
    139         let is_narrow = notedeck::ui::is_narrow(ui.ctx());
    140         let ctrl_held = ui.input(|i| i.modifiers.ctrl);
    141         let is_remote = self.target_host.is_some();
    142 
    143         // Choose which path list to display based on target host
    144         let display_paths: &[PathBuf] = if let Some(ref host) = self.target_host {
    145             self.host_recent_paths
    146                 .get(host)
    147                 .map(|v| v.as_slice())
    148                 .unwrap_or(&[])
    149         } else {
    150             &self.recent_directories
    151         };
    152 
    153         // Handle keyboard shortcuts for recent directories (Ctrl+1-9)
    154         // Only trigger when Ctrl is held to avoid intercepting TextEdit input
    155         if ctrl_held {
    156             for (idx, path) in display_paths.iter().take(9).enumerate() {
    157                 let key = match idx {
    158                     0 => egui::Key::Num1,
    159                     1 => egui::Key::Num2,
    160                     2 => egui::Key::Num3,
    161                     3 => egui::Key::Num4,
    162                     4 => egui::Key::Num5,
    163                     5 => egui::Key::Num6,
    164                     6 => egui::Key::Num7,
    165                     7 => egui::Key::Num8,
    166                     8 => egui::Key::Num9,
    167                     _ => continue,
    168                 };
    169                 if ui.input(|i| i.key_pressed(key)) {
    170                     return Some(DirectoryPickerAction::DirectorySelected(path.clone()));
    171                 }
    172             }
    173         }
    174 
    175         // Handle Ctrl+B key for browse (track whether we need to trigger it)
    176         // Only trigger when Ctrl is held to avoid intercepting TextEdit input
    177         let trigger_browse = ctrl_held
    178             && ui.input(|i| i.key_pressed(egui::Key::B))
    179             && self.pending_folder_pick.is_none();
    180 
    181         // Full panel frame
    182         egui::Frame::new()
    183             .fill(ui.visuals().panel_fill)
    184             .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20))
    185             .show(ui, |ui| {
    186                 // Header
    187                 ui.horizontal(|ui| {
    188                     // Only show back button if there are existing sessions
    189                     if has_sessions {
    190                         if ui.button("< Back").clicked() {
    191                             action = Some(DirectoryPickerAction::Cancelled);
    192                         }
    193                         ui.add_space(16.0);
    194                     }
    195                     if let Some(ref host) = self.target_host {
    196                         ui.heading(format!("Select Directory on {}", host));
    197                     } else {
    198                         ui.heading("Select Working Directory");
    199                     }
    200                 });
    201 
    202                 ui.add_space(16.0);
    203 
    204                 // Centered content (max width for desktop)
    205                 let max_content_width = if is_narrow {
    206                     ui.available_width()
    207                 } else {
    208                     500.0
    209                 };
    210                 let available_height = ui.available_height();
    211 
    212                 ui.allocate_ui_with_layout(
    213                     egui::vec2(max_content_width, available_height),
    214                     egui::Layout::top_down(egui::Align::LEFT),
    215                     |ui| {
    216                         // Recent directories section
    217                         if !display_paths.is_empty() {
    218                             ui.label(RichText::new("Recent Directories").strong());
    219                             ui.add_space(8.0);
    220 
    221                             // Use more vertical space on mobile
    222                             let scroll_height = if is_narrow {
    223                                 (ui.available_height() - 150.0).max(100.0)
    224                             } else {
    225                                 300.0
    226                             };
    227 
    228                             egui::ScrollArea::vertical()
    229                                 .max_height(scroll_height)
    230                                 .show(ui, |ui| {
    231                                     for (idx, path) in display_paths.iter().enumerate() {
    232                                         let display = abbreviate_path(path);
    233 
    234                                         // Full-width button style with larger touch targets on mobile
    235                                         let button_height = if is_narrow { 44.0 } else { 32.0 };
    236                                         let hint_width =
    237                                             if ctrl_held && idx < 9 { 24.0 } else { 0.0 };
    238                                         let button_width = ui.available_width() - hint_width - 4.0;
    239 
    240                                         ui.horizontal(|ui| {
    241                                             let button = egui::Button::new(
    242                                                 RichText::new(&display).monospace(),
    243                                             )
    244                                             .min_size(Vec2::new(button_width, button_height))
    245                                             .fill(ui.visuals().widgets.inactive.weak_bg_fill);
    246 
    247                                             let response = ui.add(button);
    248 
    249                                             // Show keybind hint when Ctrl is held (for first 9 items)
    250                                             if ctrl_held && idx < 9 {
    251                                                 let hint_text = format!("{}", idx + 1);
    252                                                 let hint_center = response.rect.right_center()
    253                                                     + egui::vec2(hint_width / 2.0 + 2.0, 0.0);
    254                                                 paint_keybind_hint(
    255                                                     ui,
    256                                                     hint_center,
    257                                                     &hint_text,
    258                                                     18.0,
    259                                                 );
    260                                             }
    261 
    262                                             if response
    263                                                 .on_hover_text(path.display().to_string())
    264                                                 .clicked()
    265                                             {
    266                                                 action =
    267                                                     Some(DirectoryPickerAction::DirectorySelected(
    268                                                         path.clone(),
    269                                                     ));
    270                                             }
    271                                         });
    272 
    273                                         ui.add_space(4.0);
    274                                     }
    275                                 });
    276 
    277                             ui.add_space(16.0);
    278                             ui.separator();
    279                             ui.add_space(12.0);
    280                         }
    281 
    282                         // Browse button — only for local targets (can't browse remote fs)
    283                         if !is_remote {
    284                             ui.horizontal(|ui| {
    285                                 let browse_button = egui::Button::new(
    286                                     RichText::new("Browse...").size(if is_narrow {
    287                                         16.0
    288                                     } else {
    289                                         14.0
    290                                     }),
    291                                 )
    292                                 .min_size(Vec2::new(
    293                                     if is_narrow {
    294                                         ui.available_width() - 28.0
    295                                     } else {
    296                                         120.0
    297                                     },
    298                                     if is_narrow { 48.0 } else { 32.0 },
    299                                 ));
    300 
    301                                 let response = ui.add(browse_button);
    302 
    303                                 // Show keybind hint when Ctrl is held
    304                                 if ctrl_held {
    305                                     let hint_center =
    306                                         response.rect.right_center() + egui::vec2(14.0, 0.0);
    307                                     paint_keybind_hint(ui, hint_center, "B", 18.0);
    308                                 }
    309 
    310                                 #[cfg(any(
    311                                     target_os = "windows",
    312                                     target_os = "macos",
    313                                     target_os = "linux"
    314                                 ))]
    315                                 if response
    316                                     .on_hover_text("Open folder picker dialog (B)")
    317                                     .clicked()
    318                                     || trigger_browse
    319                                 {
    320                                     // Spawn async folder picker
    321                                     let (tx, rx) = std::sync::mpsc::channel();
    322                                     let ctx_clone = ui.ctx().clone();
    323                                     std::thread::spawn(move || {
    324                                         let result = rfd::FileDialog::new().pick_folder();
    325                                         let _ = tx.send(result);
    326                                         ctx_clone.request_repaint();
    327                                     });
    328                                     self.pending_folder_pick = Some(rx);
    329                                 }
    330 
    331                                 // On platforms without rfd (e.g., Android), just show the button disabled
    332                                 #[cfg(not(any(
    333                                     target_os = "windows",
    334                                     target_os = "macos",
    335                                     target_os = "linux"
    336                                 )))]
    337                                 {
    338                                     let _ = response;
    339                                     let _ = trigger_browse;
    340                                 }
    341                             });
    342 
    343                             if self.pending_folder_pick.is_some() {
    344                                 ui.horizontal(|ui| {
    345                                     ui.spinner();
    346                                     ui.label("Opening dialog...");
    347                                 });
    348                             }
    349 
    350                             ui.add_space(16.0);
    351                         }
    352 
    353                         // Manual path input
    354                         ui.label("Enter path:");
    355                         ui.add_space(4.0);
    356 
    357                         let response = ui.add(
    358                             egui::TextEdit::singleline(&mut self.path_input)
    359                                 .hint_text("/path/to/project")
    360                                 .desired_width(ui.available_width()),
    361                         );
    362 
    363                         ui.add_space(8.0);
    364 
    365                         let go_button = egui::Button::new("Go").min_size(Vec2::new(
    366                             if is_narrow {
    367                                 ui.available_width()
    368                             } else {
    369                                 50.0
    370                             },
    371                             if is_narrow { 44.0 } else { 28.0 },
    372                         ));
    373 
    374                         if ui.add(go_button).clicked()
    375                             || response.lost_focus()
    376                                 && ui.input(|i| i.key_pressed(egui::Key::Enter))
    377                         {
    378                             let path = PathBuf::from(self.path_input.trim());
    379                             // For remote targets we can't verify the path locally
    380                             if !self.path_input.trim().is_empty()
    381                                 && (is_remote || (path.exists() && path.is_dir()))
    382                             {
    383                                 action = Some(DirectoryPickerAction::DirectorySelected(path));
    384                             }
    385                         }
    386                     },
    387                 );
    388             });
    389 
    390         // Handle Escape key (only if cancellation is allowed)
    391         if has_sessions
    392             && ui
    393                 .ctx()
    394                 .input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
    395         {
    396             action = Some(DirectoryPickerAction::Cancelled);
    397         }
    398 
    399         action
    400     }
    401 }