| @@ -0,0 +1,623 @@ |
| 1 | + //! The Add project browser: an in-app folder picker, since the system dialog |
| 2 | + //! can't be told which folder to start in. A path field on top filters and |
| 3 | + //! completes the listing below it, like a shell. |
| 4 | + |
| 5 | + use std::path::{Component, Path, PathBuf}; |
| 6 | + |
| 7 | + use gpui_kit::component::{ |
| 8 | + Icon, IconName, h_flex, |
| 9 | + input::{Enter, Escape, IndentInline, Input, InputEvent, InputState, MoveDown, MoveUp}, |
| 10 | + v_flex, |
| 11 | + }; |
| 12 | + use gpui_kit::prelude::FluentBuilder as _; |
| 13 | + use gpui_kit::*; |
| 14 | + |
| 15 | + use crate::ui::metal::{self, MetalButton, MetalStyled as _}; |
| 16 | + |
| 17 | + /// The most folders one listing shows. |
| 18 | + const MAX_FOLDERS: usize = 2000; |
| 19 | + /// The most entries read from one folder, so a folder full of files stays quick. |
| 20 | + const MAX_SCANNED: usize = 20_000; |
| 21 | + |
| 22 | + pub enum FolderBrowserEvent { |
| 23 | + Picked(PathBuf), |
| 24 | + Cancelled, |
| 25 | + /// The user would rather use the system's folder dialog. |
| 26 | + SystemDialog, |
| 27 | + } |
| 28 | + |
| 29 | + struct Folder { |
| 30 | + name: String, |
| 31 | + /// It has a `.git`, so it's likely a project. |
| 32 | + git: bool, |
| 33 | + } |
| 34 | + |
| 35 | + /// One row of the list: the parent folder, or a folder of the listing. |
| 36 | + #[derive(Clone, Copy, PartialEq, Eq)] |
| 37 | + enum Row { |
| 38 | + Up, |
| 39 | + Folder(usize), |
| 40 | + } |
| 41 | + |
| 42 | + pub struct FolderBrowser { |
| 43 | + input: Entity<InputState>, |
| 44 | + home: PathBuf, |
| 45 | + /// The folder listed. |
| 46 | + dir: PathBuf, |
| 47 | + folders: Vec<Folder>, |
| 48 | + /// Why `dir` couldn't be read. |
| 49 | + error: Option<String>, |
| 50 | + /// The listing stopped at `MAX_FOLDERS`. |
| 51 | + truncated: bool, |
| 52 | + /// The field names a folder that doesn't exist. |
| 53 | + missing: bool, |
| 54 | + /// The partial name typed after the folder, which filters the listing. |
| 55 | + filter: String, |
| 56 | + rows: Vec<Row>, |
| 57 | + highlight: usize, |
| 58 | + scroll: ScrollHandle, |
| 59 | + _subscription: Subscription, |
| 60 | + } |
| 61 | + |
| 62 | + impl EventEmitter<FolderBrowserEvent> for FolderBrowser {} |
| 63 | + |
| 64 | + impl Focusable for FolderBrowser { |
| 65 | + fn focus_handle(&self, cx: &App) -> FocusHandle { |
| 66 | + self.input.read(cx).focus_handle(cx) |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + impl FolderBrowser { |
| 71 | + /// Opens on `start`, or on the home folder if `start` isn't a folder. |
| 72 | + pub fn new(start: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self { |
| 73 | + let home = home_dir(); |
| 74 | + let start = if start.is_dir() { start } else { home.clone() }; |
| 75 | + let input = cx.new(|cx| { |
| 76 | + let mut input = InputState::new(window, cx).placeholder("~/"); |
| 77 | + input.set_value(field_text(&start, &home), window, cx); |
| 78 | + input |
| 79 | + }); |
| 80 | + let subscription = cx.subscribe(&input, |this, _, event: &InputEvent, cx| { |
| 81 | + if matches!(event, InputEvent::Change) { |
| 82 | + this.sync(cx); |
| 83 | + } |
| 84 | + }); |
| 85 | + let mut browser = Self { |
| 86 | + input, |
| 87 | + home, |
| 88 | + dir: PathBuf::new(), |
| 89 | + folders: Vec::new(), |
| 90 | + error: None, |
| 91 | + truncated: false, |
| 92 | + missing: false, |
| 93 | + filter: String::new(), |
| 94 | + rows: Vec::new(), |
| 95 | + highlight: 0, |
| 96 | + scroll: ScrollHandle::new(), |
| 97 | + _subscription: subscription, |
| 98 | + }; |
| 99 | + browser.sync(cx); |
| 100 | + browser |
| 101 | + } |
| 102 | + |
| 103 | + /// Lists the folder the field points at, filtered by the partial name |
| 104 | + /// after its last slash. |
| 105 | + fn sync(&mut self, cx: &mut Context<Self>) { |
| 106 | + let text = self.input.read(cx).value(); |
| 107 | + let (dir, filter) = split_query(&text, &self.home); |
| 108 | + self.missing = !dir.is_dir(); |
| 109 | + if !self.missing && dir != self.dir { |
| 110 | + match read_folders(&dir) { |
| 111 | + Ok((folders, truncated)) => { |
| 112 | + self.folders = folders; |
| 113 | + self.truncated = truncated; |
| 114 | + self.error = None; |
| 115 | + } |
| 116 | + Err(err) => { |
| 117 | + self.folders.clear(); |
| 118 | + self.truncated = false; |
| 119 | + self.error = Some(match err.kind() { |
| 120 | + std::io::ErrorKind::PermissionDenied => "Can't read this folder: permission denied.".into(), |
| 121 | + _ => format!("Can't read this folder: {err}"), |
| 122 | + }); |
| 123 | + } |
| 124 | + } |
| 125 | + self.dir = dir; |
| 126 | + } |
| 127 | + self.filter = filter; |
| 128 | + |
| 129 | + let names: Vec<&str> = self.folders.iter().map(|folder| folder.name.as_str()).collect(); |
| 130 | + let up = !self.missing && self.filter.is_empty() && self.dir.parent().is_some(); |
| 131 | + self.rows = if self.missing { |
| 132 | + Vec::new() |
| 133 | + } else { |
| 134 | + up.then_some(Row::Up) |
| 135 | + .into_iter() |
| 136 | + .chain(filter_names(&names, &self.filter).into_iter().map(Row::Folder)) |
| 137 | + .collect() |
| 138 | + }; |
| 139 | + // Start on the first folder rather than "..". |
| 140 | + let first = self.rows.iter().position(|row| matches!(row, Row::Folder(_))).unwrap_or(0); |
| 141 | + self.set_highlight(first, cx); |
| 142 | + } |
| 143 | + |
| 144 | + fn set_highlight(&mut self, ix: usize, cx: &mut Context<Self>) { |
| 145 | + self.highlight = ix.min(self.rows.len().saturating_sub(1)); |
| 146 | + self.scroll.scroll_to_item(self.highlight); |
| 147 | + cx.notify(); |
| 148 | + } |
| 149 | + |
| 150 | + fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) { |
| 151 | + let ix = self.highlight.saturating_add_signed(delta); |
| 152 | + self.set_highlight(ix, cx); |
| 153 | + } |
| 154 | + |
| 155 | + /// Lists `dir`, with the field pointing at it. |
| 156 | + fn navigate(&mut self, dir: &Path, window: &mut Window, cx: &mut Context<Self>) { |
| 157 | + let text = field_text(dir, &self.home); |
| 158 | + self.input.update(cx, |input, cx| { |
| 159 | + input.set_value(text, window, cx); |
| 160 | + input.focus(window, cx); |
| 161 | + }); |
| 162 | + self.sync(cx); |
| 163 | + } |
| 164 | + |
| 165 | + /// Opens a row: up a level (keeping the folder we came from highlighted), |
| 166 | + /// or into the folder. |
| 167 | + fn open(&mut self, row: Row, window: &mut Window, cx: &mut Context<Self>) { |
| 168 | + match row { |
| 169 | + Row::Up => { |
| 170 | + let Some(parent) = self.dir.parent().map(Path::to_path_buf) else { return }; |
| 171 | + let from = self.dir.file_name().map(|name| name.to_string_lossy().into_owned()); |
| 172 | + self.navigate(&parent, window, cx); |
| 173 | + let came_from = self.rows.iter().position(|row| match row { |
| 174 | + Row::Folder(ix) => Some(&self.folders[*ix].name) == from.as_ref(), |
| 175 | + Row::Up => false, |
| 176 | + }); |
| 177 | + if let Some(ix) = came_from { |
| 178 | + self.set_highlight(ix, cx); |
| 179 | + } |
| 180 | + } |
| 181 | + Row::Folder(ix) => { |
| 182 | + let dir = self.dir.join(&self.folders[ix].name); |
| 183 | + self.navigate(&dir, window, cx); |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + /// Tab: completes the partial name to the one folder it matches, or as |
| 189 | + /// far as all its matches agree. |
| 190 | + fn complete(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 191 | + let names: Vec<&str> = self.folders.iter().map(|folder| folder.name.as_str()).collect(); |
| 192 | + let Some(completion) = complete_name(&names, &self.filter) else { return }; |
| 193 | + let text = self.input.read(cx).value(); |
| 194 | + let head = text.rfind('/').map_or("", |ix| &text[..=ix]); |
| 195 | + let text = format!("{head}{completion}"); |
| 196 | + self.input.update(cx, |input, cx| input.set_value(text, window, cx)); |
| 197 | + self.sync(cx); |
| 198 | + } |
| 199 | + |
| 200 | + /// The folder the field names, if it exists. "Add project" adds this one. |
| 201 | + fn target(&self, cx: &App) -> Option<PathBuf> { |
| 202 | + let path = expand(&self.input.read(cx).value(), &self.home); |
| 203 | + path.is_dir().then_some(path) |
| 204 | + } |
| 205 | + |
| 206 | + fn pick(&mut self, path: PathBuf, cx: &mut Context<Self>) { |
| 207 | + cx.emit(FolderBrowserEvent::Picked(path)); |
| 208 | + } |
| 209 | + |
| 210 | + fn render_row(&self, ix: usize, row: Row, cx: &mut Context<Self>) -> AnyElement { |
| 211 | + let p = metal::palette(cx); |
| 212 | + let highlighted = ix == self.highlight; |
| 213 | + let group = SharedString::from(format!("folder-row-{ix}")); |
| 214 | + let (icon, name, git) = match row { |
| 215 | + Row::Up => (IconName::ArrowUp, "..".to_string(), false), |
| 216 | + Row::Folder(folder) => { |
| 217 | + let folder = &self.folders[folder]; |
| 218 | + (IconName::Folder, folder.name.clone(), folder.git) |
| 219 | + } |
| 220 | + }; |
| 221 | + h_flex() |
| 222 | + .id(("folder-row", ix)) |
| 223 | + .group(group.clone()) |
| 224 | + .flex_none() |
| 225 | + .h(px(32.)) |
| 226 | + .px(px(10.)) |
| 227 | + .gap(px(10.)) |
| 228 | + .rounded(px(10.)) |
| 229 | + .cursor_pointer() |
| 230 | + .hover(|style| style.bg(p.menu_row_hover)) |
| 231 | + .when(highlighted, |this| this.bg(p.menu_row_hover)) |
| 232 | + .on_click(cx.listener(move |this, _, window, cx| this.open(row, window, cx))) |
| 233 | + .child(Icon::new(icon).size(px(15.)).text_color(p.icon)) |
| 234 | + .child( |
| 235 | + div() |
| 236 | + .flex_1() |
| 237 | + .min_w_0() |
| 238 | + .truncate() |
| 239 | + .text_size(px(13.5)) |
| 240 | + .font_weight(FontWeight::MEDIUM) |
| 241 | + .text_color(p.ink) |
| 242 | + .child(name.clone()), |
| 243 | + ) |
| 244 | + .when(git, |this| this.child(metal::tag("git", p))) |
| 245 | + .when(row != Row::Up, |this| { |
| 246 | + let path = self.dir.join(&name); |
| 247 | + this.child( |
| 248 | + div() |
| 249 | + .when(!highlighted, |this| this.invisible().group_hover(group, |this| this.visible())) |
| 250 | + .child( |
| 251 | + MetalButton::new(("folder-add", ix)) |
| 252 | + .mini() |
| 253 | + .label("Add") |
| 254 | + .tooltip("Add this folder as a project") |
| 255 | + .on_click(cx.listener(move |this, _, _, cx| { |
| 256 | + cx.stop_propagation(); |
| 257 | + this.pick(path.clone(), cx); |
| 258 | + })), |
| 259 | + ), |
| 260 | + ) |
| 261 | + }) |
| 262 | + .into_any_element() |
| 263 | + } |
| 264 | + |
| 265 | + /// A muted line under the rows, when there's something to explain. |
| 266 | + fn message(&self) -> Option<String> { |
| 267 | + if self.missing { |
| 268 | + Some("No such folder.".into()) |
| 269 | + } else if let Some(error) = &self.error { |
| 270 | + Some(error.clone()) |
| 271 | + } else if self.truncated { |
| 272 | + Some(format!("Showing the first {MAX_FOLDERS} folders. Type to narrow them down.")) |
| 273 | + } else if self.folders.is_empty() { |
| 274 | + Some("No folders here.".into()) |
| 275 | + } else if !self.rows.iter().any(|row| matches!(row, Row::Folder(_))) { |
| 276 | + Some("No folders match.".into()) |
| 277 | + } else { |
| 278 | + None |
| 279 | + } |
| 280 | + } |
| 281 | + } |
| 282 | + |
| 283 | + impl Render for FolderBrowser { |
| 284 | + fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { |
| 285 | + let p = metal::palette(cx); |
| 286 | + let target = self.target(cx); |
| 287 | + let footer_path = match &target { |
| 288 | + Some(path) => tilde(path, &self.home), |
| 289 | + None => "Pick a folder".into(), |
| 290 | + }; |
| 291 | + let rows: Vec<AnyElement> = |
| 292 | + self.rows.clone().into_iter().enumerate().map(|(ix, row)| self.render_row(ix, row, cx)).collect(); |
| 293 | + let message = self.message(); |
| 294 | + |
| 295 | + v_flex() |
| 296 | + .w(px(560.)) |
| 297 | + .max_h(px(520.)) |
| 298 | + .p(px(8.)) |
| 299 | + .gap(px(6.)) |
| 300 | + .rounded(px(18.)) |
| 301 | + .bg(p.menu_bg) |
| 302 | + .shadow(p.raise.clone()) |
| 303 | + // Clicks in the panel shouldn't reach the backdrop behind it. |
| 304 | + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) |
| 305 | + // The field keeps focus; these keys drive the list instead. |
| 306 | + .capture_action(cx.listener(|this, _: &MoveUp, _, cx| { |
| 307 | + cx.stop_propagation(); |
| 308 | + this.move_highlight(-1, cx); |
| 309 | + })) |
| 310 | + .capture_action(cx.listener(|this, _: &MoveDown, _, cx| { |
| 311 | + cx.stop_propagation(); |
| 312 | + this.move_highlight(1, cx); |
| 313 | + })) |
| 314 | + .capture_action(cx.listener(|this, _: &IndentInline, window, cx| { |
| 315 | + cx.stop_propagation(); |
| 316 | + this.complete(window, cx); |
| 317 | + })) |
| 318 | + .capture_action(cx.listener(|_, _: &Escape, _, cx| { |
| 319 | + cx.stop_propagation(); |
| 320 | + cx.emit(FolderBrowserEvent::Cancelled); |
| 321 | + })) |
| 322 | + .capture_action(cx.listener(|this, enter: &Enter, window, cx| { |
| 323 | + cx.stop_propagation(); |
| 324 | + if enter.secondary { |
| 325 | + if let Some(path) = this.target(cx) { |
| 326 | + this.pick(path, cx); |
| 327 | + } |
| 328 | + } else if let Some(&row) = this.rows.get(this.highlight) { |
| 329 | + this.open(row, window, cx); |
| 330 | + } |
| 331 | + })) |
| 332 | + .child( |
| 333 | + h_flex() |
| 334 | + .flex_none() |
| 335 | + .h(px(38.)) |
| 336 | + .pl(px(11.)) |
| 337 | + .pr(px(8.)) |
| 338 | + .gap(px(8.)) |
| 339 | + .rounded(px(13.)) |
| 340 | + .well(p) |
| 341 | + .font_family(metal::MONO) |
| 342 | + .text_size(px(13.)) |
| 343 | + .text_color(p.ink) |
| 344 | + .child(Icon::new(IconName::FolderOpen).size(px(14.)).text_color(p.field_ink)) |
| 345 | + .child(div().flex_1().min_w_0().child(Input::new(&self.input).appearance(false))) |
| 346 | + .child(metal::shortcut(&["Tab"], p)), |
| 347 | + ) |
| 348 | + .child( |
| 349 | + v_flex() |
| 350 | + .id("folder-list") |
| 351 | + .h(px(340.)) |
| 352 | + .overflow_y_scroll() |
| 353 | + .track_scroll(&self.scroll) |
| 354 | + .gap(px(1.)) |
| 355 | + .children(rows) |
| 356 | + .when_some(message, |this, message| { |
| 357 | + this.child( |
| 358 | + div() |
| 359 | + .px(px(10.)) |
| 360 | + .py(px(10.)) |
| 361 | + .text_size(px(12.5)) |
| 362 | + .line_height(px(18.)) |
| 363 | + .text_color(p.ink3) |
| 364 | + .child(message), |
| 365 | + ) |
| 366 | + }), |
| 367 | + ) |
| 368 | + .child(div().mx(px(4.)).child(metal::rule(p))) |
| 369 | + .child( |
| 370 | + h_flex() |
| 371 | + .flex_none() |
| 372 | + .gap(px(6.)) |
| 373 | + .pl(px(6.)) |
| 374 | + .child(div().flex_1().min_w_0().truncate().engraved(p).child(footer_path)) |
| 375 | + .child( |
| 376 | + MetalButton::new("folder-system-dialog") |
| 377 | + .ghost() |
| 378 | + .compact() |
| 379 | + .label("Use system dialog…") |
| 380 | + .on_click(cx.listener(|_, _, _, cx| cx.emit(FolderBrowserEvent::SystemDialog))), |
| 381 | + ) |
| 382 | + .child( |
| 383 | + MetalButton::new("folder-cancel") |
| 384 | + .compact() |
| 385 | + .label("Cancel") |
| 386 | + .on_click(cx.listener(|_, _, _, cx| cx.emit(FolderBrowserEvent::Cancelled))), |
| 387 | + ) |
| 388 | + .child( |
| 389 | + MetalButton::new("folder-pick") |
| 390 | + .primary() |
| 391 | + .compact() |
| 392 | + .label("Add project") |
| 393 | + .tooltip("Add the folder in the field (Ctrl+Enter)") |
| 394 | + .disabled(target.is_none()) |
| 395 | + .on_click(cx.listener(move |this, _, _, cx| { |
| 396 | + if let Some(path) = target.clone() { |
| 397 | + this.pick(path, cx); |
| 398 | + } |
| 399 | + })), |
| 400 | + ), |
| 401 | + ) |
| 402 | + } |
| 403 | + } |
| 404 | + |
| 405 | + /// Where the Add project browser opens: the saved "Add project starts in" |
| 406 | + /// folder (`~` and `~/…` expanded), or the home folder when that's empty or |
| 407 | + /// isn't a folder. |
| 408 | + pub fn start_dir(setting: Option<&str>) -> PathBuf { |
| 409 | + let home = home_dir(); |
| 410 | + match setting.map(str::trim).filter(|setting| !setting.is_empty()) { |
| 411 | + Some(setting) => Some(expand(setting, &home)).filter(|dir| dir.is_dir()).unwrap_or(home), |
| 412 | + None => home, |
| 413 | + } |
| 414 | + } |
| 415 | + |
| 416 | + fn home_dir() -> PathBuf { |
| 417 | + std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")) |
| 418 | + } |
| 419 | + |
| 420 | + /// Turns what's typed into an absolute path: `~` is `home`, a relative path |
| 421 | + /// is taken from `home`, and `.` and `..` are resolved. |
| 422 | + fn expand(text: &str, home: &Path) -> PathBuf { |
| 423 | + let path = if text == "~" { |
| 424 | + home.to_path_buf() |
| 425 | + } else if let Some(rest) = text.strip_prefix("~/") { |
| 426 | + home.join(rest) |
| 427 | + } else { |
| 428 | + home.join(text) |
| 429 | + }; |
| 430 | + let mut normal = PathBuf::new(); |
| 431 | + for component in path.components() { |
| 432 | + match component { |
| 433 | + Component::CurDir => {} |
| 434 | + Component::ParentDir => { |
| 435 | + normal.pop(); |
| 436 | + } |
| 437 | + component => normal.push(component), |
| 438 | + } |
| 439 | + } |
| 440 | + normal |
| 441 | + } |
| 442 | + |
| 443 | + /// A path as the field shows it, with `~` for the home folder. |
| 444 | + fn tilde(path: &Path, home: &Path) -> String { |
| 445 | + match path.strip_prefix(home) { |
| 446 | + Ok(rest) if home.parent().is_some() && rest.as_os_str().is_empty() => "~".into(), |
| 447 | + Ok(rest) if home.parent().is_some() => format!("~/{}", rest.display()), |
| 448 | + _ => path.display().to_string(), |
| 449 | + } |
| 450 | + } |
| 451 | + |
| 452 | + /// The field's text for listing `dir`: its path with a trailing slash. |
| 453 | + fn field_text(dir: &Path, home: &Path) -> String { |
| 454 | + let text = tilde(dir, home); |
| 455 | + if text.ends_with('/') { text } else { format!("{text}/") } |
| 456 | + } |
| 457 | + |
| 458 | + /// Splits the field into the folder to list and the partial name after it: |
| 459 | + /// `~/Work/vec` is `$HOME/Work` filtered by `vec`. |
| 460 | + fn split_query(text: &str, home: &Path) -> (PathBuf, String) { |
| 461 | + match text.rfind('/') { |
| 462 | + _ if text == "~" => (home.to_path_buf(), String::new()), |
| 463 | + Some(ix) => (expand(&text[..=ix], home), text[ix + 1..].to_string()), |
| 464 | + None => (home.to_path_buf(), text.to_string()), |
| 465 | + } |
| 466 | + } |
| 467 | + |
| 468 | + /// The names matching `filter`, case-insensitively: those starting with it |
| 469 | + /// first, then those containing it. Dot-folders only show when the filter |
| 470 | + /// starts with a dot. |
| 471 | + fn filter_names(names: &[&str], filter: &str) -> Vec<usize> { |
| 472 | + let needle = filter.to_lowercase(); |
| 473 | + let (mut starts, mut contains) = (Vec::new(), Vec::new()); |
| 474 | + for (ix, name) in names.iter().enumerate() { |
| 475 | + if name.starts_with('.') && !filter.starts_with('.') { |
| 476 | + continue; |
| 477 | + } |
| 478 | + let name = name.to_lowercase(); |
| 479 | + if name.starts_with(&needle) { |
| 480 | + starts.push(ix); |
| 481 | + } else if name.contains(&needle) { |
| 482 | + contains.push(ix); |
| 483 | + } |
| 484 | + } |
| 485 | + starts.extend(contains); |
| 486 | + starts |
| 487 | + } |
| 488 | + |
| 489 | + /// What Tab turns `filter` into: the one name starting with it plus a slash, |
| 490 | + /// or the longest prefix the names starting with it share. `None` when |
| 491 | + /// that adds nothing. |
| 492 | + fn complete_name(names: &[&str], filter: &str) -> Option<String> { |
| 493 | + let needle = filter.to_lowercase(); |
| 494 | + let matches: Vec<&str> = names |
| 495 | + .iter() |
| 496 | + .copied() |
| 497 | + .filter(|name| !name.starts_with('.') || filter.starts_with('.')) |
| 498 | + .filter(|name| name.to_lowercase().starts_with(&needle)) |
| 499 | + .collect(); |
| 500 | + match matches.as_slice() { |
| 501 | + [] => None, |
| 502 | + [only] => Some(format!("{only}/")), |
| 503 | + [first, rest @ ..] => { |
| 504 | + let len = rest.iter().fold(first.len(), |len, name| common_prefix_len(&first[..len], name)); |
| 505 | + let prefix = &first[..len]; |
| 506 | + (prefix.chars().count() > filter.chars().count()).then(|| prefix.to_string()) |
| 507 | + } |
| 508 | + } |
| 509 | + } |
| 510 | + |
| 511 | + /// How many bytes of `a` start `b` too, ignoring case. |
| 512 | + fn common_prefix_len(a: &str, b: &str) -> usize { |
| 513 | + let mut len = 0; |
| 514 | + for ((ix, x), y) in a.char_indices().zip(b.chars()) { |
| 515 | + if !x.to_lowercase().eq(y.to_lowercase()) { |
| 516 | + return ix; |
| 517 | + } |
| 518 | + len = ix + x.len_utf8(); |
| 519 | + } |
| 520 | + len |
| 521 | + } |
| 522 | + |
| 523 | + /// The subfolders of `dir`, sorted case-insensitively, and whether the |
| 524 | + /// listing stopped short. |
| 525 | + fn read_folders(dir: &Path) -> std::io::Result<(Vec<Folder>, bool)> { |
| 526 | + let mut folders = Vec::new(); |
| 527 | + let mut truncated = false; |
| 528 | + for (scanned, entry) in std::fs::read_dir(dir)?.enumerate() { |
| 529 | + if scanned >= MAX_SCANNED || folders.len() >= MAX_FOLDERS { |
| 530 | + truncated = true; |
| 531 | + break; |
| 532 | + } |
| 533 | + let Ok(entry) = entry else { continue }; |
| 534 | + let path = entry.path(); |
| 535 | + // Follow symlinks, so a linked folder lists like any other. |
| 536 | + let is_dir = match entry.file_type() { |
| 537 | + Ok(kind) if kind.is_symlink() => path.is_dir(), |
| 538 | + Ok(kind) => kind.is_dir(), |
| 539 | + Err(_) => false, |
| 540 | + }; |
| 541 | + if is_dir { |
| 542 | + folders.push(Folder { |
| 543 | + name: entry.file_name().to_string_lossy().into_owned(), |
| 544 | + git: path.join(".git").exists(), |
| 545 | + }); |
| 546 | + } |
| 547 | + } |
| 548 | + folders.sort_by_cached_key(|folder| folder.name.to_lowercase()); |
| 549 | + Ok((folders, truncated)) |
| 550 | + } |
| 551 | + |
| 552 | + #[cfg(test)] |
| 553 | + mod tests { |
| 554 | + // Not a glob: GPUI exports its own `test` attribute. |
| 555 | + use super::{complete_name, expand, field_text, filter_names, home_dir, split_query, start_dir, tilde}; |
| 556 | + use std::path::{Path, PathBuf}; |
| 557 | + |
| 558 | + const HOME: &str = "/home/ada"; |
| 559 | + |
| 560 | + #[::core::prelude::v1::test] |
| 561 | + fn expands_paths() { |
| 562 | + let home = Path::new(HOME); |
| 563 | + assert_eq!(expand("~", home), PathBuf::from(HOME)); |
| 564 | + assert_eq!(expand("~/", home), PathBuf::from(HOME)); |
| 565 | + assert_eq!(expand("~/Work/rust/", home), PathBuf::from("/home/ada/Work/rust")); |
| 566 | + assert_eq!(expand("/etc", home), PathBuf::from("/etc")); |
| 567 | + assert_eq!(expand("Work", home), PathBuf::from("/home/ada/Work")); |
| 568 | + assert_eq!(expand("~/Work/../src/./x", home), PathBuf::from("/home/ada/src/x")); |
| 569 | + assert_eq!(expand("/..", home), PathBuf::from("/")); |
| 570 | + } |
| 571 | + |
| 572 | + #[::core::prelude::v1::test] |
| 573 | + fn start_dir_falls_back_to_home() { |
| 574 | + assert_eq!(start_dir(None), home_dir()); |
| 575 | + assert_eq!(start_dir(Some(" ")), home_dir()); |
| 576 | + assert_eq!(start_dir(Some("~")), home_dir()); |
| 577 | + assert_eq!(start_dir(Some("/no/such/folder/here")), home_dir()); |
| 578 | + assert_eq!(start_dir(Some("/")), PathBuf::from("/")); |
| 579 | + } |
| 580 | + |
| 581 | + #[::core::prelude::v1::test] |
| 582 | + fn shows_home_as_tilde() { |
| 583 | + let home = Path::new(HOME); |
| 584 | + assert_eq!(tilde(Path::new(HOME), home), "~"); |
| 585 | + assert_eq!(tilde(Path::new("/home/ada/Work"), home), "~/Work"); |
| 586 | + assert_eq!(tilde(Path::new("/home/adam"), home), "/home/adam"); |
| 587 | + assert_eq!(tilde(Path::new("/etc"), Path::new("/")), "/etc"); |
| 588 | + assert_eq!(field_text(Path::new(HOME), home), "~/"); |
| 589 | + assert_eq!(field_text(Path::new("/"), home), "/"); |
| 590 | + assert_eq!(field_text(Path::new("/etc"), home), "/etc/"); |
| 591 | + } |
| 592 | + |
| 593 | + #[::core::prelude::v1::test] |
| 594 | + fn splits_the_field() { |
| 595 | + let home = Path::new(HOME); |
| 596 | + assert_eq!(split_query("~/Work/vec", home), (PathBuf::from("/home/ada/Work"), "vec".into())); |
| 597 | + assert_eq!(split_query("~/Work/", home), (PathBuf::from("/home/ada/Work"), String::new())); |
| 598 | + assert_eq!(split_query("~", home), (PathBuf::from(HOME), String::new())); |
| 599 | + assert_eq!(split_query("", home), (PathBuf::from(HOME), String::new())); |
| 600 | + assert_eq!(split_query("/e", home), (PathBuf::from("/"), "e".into())); |
| 601 | + } |
| 602 | + |
| 603 | + #[::core::prelude::v1::test] |
| 604 | + fn filters_names() { |
| 605 | + let names = [".config", "Desktop", "docs", "my-docs", "Work"]; |
| 606 | + assert_eq!(filter_names(&names, ""), vec![1, 2, 3, 4]); |
| 607 | + assert_eq!(filter_names(&names, "D"), vec![1, 2, 3]); |
| 608 | + assert_eq!(filter_names(&names, "docs"), vec![2, 3]); |
| 609 | + assert_eq!(filter_names(&names, "."), vec![0]); |
| 610 | + assert_eq!(filter_names(&names, "zzz"), Vec::<usize>::new()); |
| 611 | + } |
| 612 | + |
| 613 | + #[::core::prelude::v1::test] |
| 614 | + fn completes_names() { |
| 615 | + let names = [".cargo", "Workbench", "workshop", "Writing"]; |
| 616 | + assert_eq!(complete_name(&names, "wr"), Some("Writing/".into())); |
| 617 | + assert_eq!(complete_name(&names, "wo"), Some("Work".into())); |
| 618 | + assert_eq!(complete_name(&names, "work"), None); |
| 619 | + assert_eq!(complete_name(&names, "x"), None); |
| 620 | + assert_eq!(complete_name(&names, "."), Some(".cargo/".into())); |
| 621 | + assert_eq!(complete_name(&names, ""), Some("W".into())); |
| 622 | + } |
| 623 | + } |