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 }