Skip to main content

gpui_cn/file_picker/
source.rs

1//! Where a file picker gets its entries: a trait any file system, remote
2//! machine, or API can implement, the local disk as the default, and an
3//! in-memory source for fixtures.
4
5use std::{cell::RefCell, collections::HashMap, rc::Rc, time::SystemTime};
6
7use gpui_kit::{App, AppContext as _, SharedString, Task};
8use smol::stream::StreamExt as _;
9
10use crate::path_browser::{CreateFolderError, Entry, ListError, PageToken, PathStyle, SourcePath};
11
12/// What an entry of a listing is.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum FileKind {
16    /// A folder, which a picker can go inside.
17    Folder,
18    /// A file, which a picker can choose.
19    File,
20}
21
22/// A file or a folder in a listing.
23#[derive(Clone, Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct FileEntry {
26    name: SharedString,
27    kind: FileKind,
28    hidden: bool,
29    size: Option<u64>,
30    modified: Option<SystemTime>,
31}
32
33impl FileEntry {
34    fn of(name: impl Into<SharedString>, kind: FileKind) -> Self {
35        let name = name.into();
36        let hidden = name.starts_with('.');
37        Self {
38            name,
39            kind,
40            hidden,
41            size: None,
42            modified: None,
43        }
44    }
45
46    /// A file with a name. A name that starts with a dot is hidden.
47    pub fn file(name: impl Into<SharedString>) -> Self {
48        Self::of(name, FileKind::File)
49    }
50
51    /// A folder with a name. A name that starts with a dot is hidden.
52    pub fn folder(name: impl Into<SharedString>) -> Self {
53        Self::of(name, FileKind::Folder)
54    }
55
56    /// Sets whether the entry is hidden.
57    pub fn with_hidden(mut self, hidden: bool) -> Self {
58        self.hidden = hidden;
59        self
60    }
61
62    /// Sets the size in bytes, which a file's row shows.
63    pub fn with_size(mut self, size: u64) -> Self {
64        self.size = Some(size);
65        self
66    }
67
68    /// Sets the time of the last change.
69    pub fn with_modified(mut self, modified: SystemTime) -> Self {
70        self.modified = Some(modified);
71        self
72    }
73
74    /// The name, without a path.
75    pub fn name(&self) -> &SharedString {
76        &self.name
77    }
78
79    /// Whether the entry is a file or a folder.
80    pub fn kind(&self) -> FileKind {
81        self.kind
82    }
83
84    /// Whether the entry is a folder.
85    pub fn is_folder(&self) -> bool {
86        self.kind == FileKind::Folder
87    }
88
89    /// Whether the entry is hidden.
90    pub fn hidden(&self) -> bool {
91        self.hidden
92    }
93
94    /// The size in bytes, when the source knows it.
95    pub fn size(&self) -> Option<u64> {
96        self.size
97    }
98
99    /// The time of the last change, when the source knows it.
100    pub fn modified(&self) -> Option<SystemTime> {
101        self.modified
102    }
103
104    /// The extension of a file's name, without the dot and in lower case.
105    pub fn extension(&self) -> Option<String> {
106        if self.is_folder() {
107            return None;
108        }
109        let (stem, extension) = self.name.rsplit_once('.')?;
110        (!stem.is_empty() && !extension.is_empty()).then(|| extension.to_lowercase())
111    }
112}
113
114impl Entry for FileEntry {
115    fn name(&self) -> &SharedString {
116        &self.name
117    }
118
119    fn hidden(&self) -> bool {
120        self.hidden
121    }
122
123    fn is_folder(&self) -> bool {
124        self.kind == FileKind::Folder
125    }
126}
127
128/// Folders first, then files, the visible before the hidden in each group,
129/// and each group in alphabetical order without regard to case: the order
130/// of [`LocalFiles`] and [`MemoryFiles`].
131pub(crate) fn sort_entries(entries: &mut [FileEntry], style: &PathStyle) {
132    entries.sort_by(|a, b| {
133        b.is_folder()
134            .cmp(&a.is_folder())
135            .then_with(|| a.hidden.cmp(&b.hidden))
136            .then_with(|| style.compare_names(&a.name, &b.name))
137    });
138}
139
140/// One page of a listing: entries, and where the next page starts.
141#[derive(Clone, Debug, PartialEq, Eq)]
142#[non_exhaustive]
143pub struct FilePage {
144    pub(super) entries: Vec<FileEntry>,
145    pub(super) next: Option<PageToken>,
146}
147
148impl FilePage {
149    /// The last page of a listing, with `entries` in the order to show.
150    pub fn new(entries: impl IntoIterator<Item = FileEntry>) -> Self {
151        Self {
152            entries: entries.into_iter().collect(),
153            next: None,
154        }
155    }
156
157    /// Says that more entries follow, from `token`. The picker shows a
158    /// "Load more" row and asks for that page when the user reaches it.
159    pub fn with_next(mut self, token: PageToken) -> Self {
160        self.next = Some(token);
161        self
162    }
163
164    /// The entries of the page.
165    pub fn entries(&self) -> &[FileEntry] {
166        &self.entries
167    }
168
169    /// Where the next page starts, when there is one.
170    pub fn next(&self) -> Option<&PageToken> {
171        self.next.as_ref()
172    }
173}
174
175/// Where a [`FilePicker`](super::FilePicker) lists entries from: the local
176/// disk, an SSH session, a bucket, an API. The picker never waits for a
177/// source. It calls `list` and awaits the task, and it drops the task to
178/// cancel it.
179///
180/// A source lists one directory a page at a time and gives entries in the
181/// order to show. The picker keeps that order and sorts nothing, so a
182/// source that pages must sort across its pages itself. Entries that
183/// repeat across pages, by name, show once.
184///
185/// ```
186/// use gpui_cn::{FileEntry, FilePage, FileSource, ListError, PageToken, SourcePath};
187/// use gpui_kit::{App, AppContext as _, Task};
188///
189/// /// A source with the same two files in every folder.
190/// struct Fixed;
191///
192/// impl FileSource for Fixed {
193///     fn list(
194///         &self,
195///         _dir: &SourcePath,
196///         _page: Option<PageToken>,
197///         cx: &mut App,
198///     ) -> Task<Result<FilePage, ListError>> {
199///         // Any work goes on a task: a request, a query, a disk read.
200///         cx.background_spawn(async move {
201///             Ok(FilePage::new([
202///                 FileEntry::file("notes.txt").with_size(120),
203///                 FileEntry::file("main.rs").with_size(2048),
204///             ]))
205///         })
206///     }
207/// }
208/// ```
209pub trait FileSource: 'static {
210    /// Lists the entries of `dir`, from the start when `page` is `None`,
211    /// or else from the page `token` a previous page named. An entry whose
212    /// name is empty, `.` or `..`, or has a separator of the source's style
213    /// or a control character is dropped by the picker.
214    fn list(
215        &self,
216        dir: &SourcePath,
217        page: Option<PageToken>,
218        cx: &mut App,
219    ) -> Task<Result<FilePage, ListError>>;
220
221    /// The source's home folder, which a leading `~` in the typed path
222    /// stands for and where a picker with no start of its own begins. The
223    /// default is `None`: a source with no home does not expand `~`, and
224    /// the picker starts at the root. A remote or virtual source gives its
225    /// own, made with [`path_style`](Self::path_style).
226    fn home(&self) -> Option<SourcePath> {
227        None
228    }
229
230    /// How the source writes its paths. The default is the style of the
231    /// machine the application runs on. A source for another machine
232    /// names its own: a Linux server is [`PathStyle::posix`] whatever
233    /// the client is.
234    fn path_style(&self) -> PathStyle {
235        PathStyle::host()
236    }
237
238    /// The value of the variable `name`, for `%NAME%` in a Windows-style
239    /// path that is pasted. The default knows none: a remote source never
240    /// reads the host's environment.
241    fn env(&self, name: &str) -> Option<String> {
242        let _ = name;
243        None
244    }
245
246    /// Whether the source can make folders. The default is `false`, which
247    /// hides the picker's "New folder" button.
248    fn can_create_folders(&self) -> bool {
249        false
250    }
251
252    /// Makes the folder `name` inside `parent` and answers with its path.
253    /// The picker has checked `name` already: it is not empty, has no
254    /// separator, and is not `.` or `..`. Answer
255    /// [`CreateFolderError::Exists`] when something has that name. The
256    /// picker lists the directory again afterward, so the new folder
257    /// shows in the order the source lists. The default answers
258    /// [`CreateFolderError::Unsupported`].
259    fn create_folder(
260        &self,
261        parent: &SourcePath,
262        name: &str,
263        cx: &mut App,
264    ) -> Task<Result<SourcePath, CreateFolderError>> {
265        let _ = (parent, name, cx);
266        Task::ready(Err(CreateFolderError::Unsupported))
267    }
268}
269
270/// The local file system, in one page. It reads on a background task,
271/// counts a symlink to a folder as a folder, and lists in the order of
272/// [`FileEntry`] sorting: folders first, then files.
273#[derive(Clone, Copy, Debug, Default)]
274pub struct LocalFiles;
275
276impl FileSource for LocalFiles {
277    /// The home directory of the user running the application.
278    fn home(&self) -> Option<SourcePath> {
279        std::env::home_dir().map(|home| PathStyle::host().path(home.to_string_lossy()))
280    }
281
282    fn env(&self, name: &str) -> Option<String> {
283        std::env::var(name).ok()
284    }
285
286    fn can_create_folders(&self) -> bool {
287        true
288    }
289
290    fn create_folder(
291        &self,
292        parent: &SourcePath,
293        name: &str,
294        cx: &mut App,
295    ) -> Task<Result<SourcePath, CreateFolderError>> {
296        let path = parent.join(name);
297        let made = path.to_path_buf();
298        cx.background_spawn(async move {
299            smol::fs::create_dir(&made).await?;
300            Ok(path)
301        })
302    }
303
304    fn list(
305        &self,
306        dir: &SourcePath,
307        _page: Option<PageToken>,
308        cx: &mut App,
309    ) -> Task<Result<FilePage, ListError>> {
310        let dir = dir.to_path_buf();
311        cx.background_spawn(async move { Ok(list_local(dir).await?) })
312    }
313}
314
315async fn list_local(dir: std::path::PathBuf) -> std::io::Result<FilePage> {
316    let mut read = smol::fs::read_dir(&dir).await?;
317    let mut entries = Vec::new();
318    while let Some(entry) = read.next().await {
319        let Ok(entry) = entry else { continue };
320        let name = entry.file_name().to_string_lossy().into_owned();
321        let Ok(kind) = entry.file_type().await else {
322            continue;
323        };
324        let meta = if kind.is_symlink() {
325            smol::fs::metadata(entry.path()).await.ok()
326        } else {
327            entry.metadata().await.ok()
328        };
329        let mut found = match &meta {
330            Some(meta) if meta.is_dir() => FileEntry::folder(name),
331            _ => FileEntry::file(name),
332        };
333        if let Some(meta) = meta {
334            if !meta.is_dir() {
335                found = found.with_size(meta.len());
336            }
337            if let Ok(modified) = meta.modified() {
338                found = found.with_modified(modified);
339            }
340        }
341        entries.push(found);
342    }
343    sort_entries(&mut entries, &PathStyle::host());
344    Ok(FilePage::new(entries))
345}
346
347/// A file system held in memory, for fixtures, demos, and tests. It lists
348/// a directory the application named, in the order of [`LocalFiles`], fails
349/// with [`ListError::NotFound`] for any other directory, and makes folders:
350/// a new one shows in its parent's next listing and is an empty directory
351/// itself. Clones share the directories.
352#[derive(Clone, Debug, Default)]
353pub struct MemoryFiles {
354    style: PathStyle,
355    home: Option<SourcePath>,
356    dirs: Rc<RefCell<HashMap<SourcePath, Vec<FileEntry>>>>,
357}
358
359impl MemoryFiles {
360    /// A file system with no directories.
361    pub fn new() -> Self {
362        Self::default()
363    }
364
365    /// Writes the paths of the directories in `style`. Set it before the
366    /// home and the directories. The default is the host's style.
367    pub fn with_style(mut self, style: PathStyle) -> Self {
368        self.style = style;
369        self
370    }
371
372    /// Sets the home folder, which `~` stands for.
373    pub fn with_home(mut self, home: impl AsRef<str>) -> Self {
374        self.home = Some(self.style.path(home));
375        self
376    }
377
378    /// Adds the directory `path` with `entries`.
379    pub fn with_dir(
380        self,
381        path: impl AsRef<str>,
382        entries: impl IntoIterator<Item = FileEntry>,
383    ) -> Self {
384        let mut entries: Vec<FileEntry> = entries.into_iter().collect();
385        sort_entries(&mut entries, &self.style);
386        self.dirs
387            .borrow_mut()
388            .insert(self.style.path(path), entries);
389        self
390    }
391}
392
393impl FileSource for MemoryFiles {
394    fn home(&self) -> Option<SourcePath> {
395        self.home.clone()
396    }
397
398    fn path_style(&self) -> PathStyle {
399        self.style
400    }
401
402    fn list(
403        &self,
404        dir: &SourcePath,
405        _page: Option<PageToken>,
406        cx: &mut App,
407    ) -> Task<Result<FilePage, ListError>> {
408        let found = self.dirs.borrow().get(dir).cloned();
409        cx.background_spawn(async move { found.map(FilePage::new).ok_or(ListError::NotFound) })
410    }
411
412    fn can_create_folders(&self) -> bool {
413        true
414    }
415
416    fn create_folder(
417        &self,
418        parent: &SourcePath,
419        name: &str,
420        _: &mut App,
421    ) -> Task<Result<SourcePath, CreateFolderError>> {
422        let mut dirs = self.dirs.borrow_mut();
423        let style = self.style;
424        let Some(entries) = dirs.get_mut(parent) else {
425            return Task::ready(Err(CreateFolderError::Other(
426                "That directory does not exist".into(),
427            )));
428        };
429        if entries
430            .iter()
431            .any(|entry| style.fold(entry.name()) == style.fold(name))
432        {
433            return Task::ready(Err(CreateFolderError::Exists));
434        }
435        let hidden = style.is_hidden_name(name);
436        entries.push(FileEntry::folder(name.to_string()).with_hidden(hidden));
437        sort_entries(entries, &style);
438        let path = parent.join(name);
439        dirs.insert(path.clone(), Vec::new());
440        Task::ready(Ok(path))
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn names(entries: &[FileEntry]) -> Vec<&str> {
449        entries.iter().map(|e| e.name().as_ref()).collect()
450    }
451
452    #[test]
453    fn folders_come_first_then_files_each_by_name_without_case() {
454        let mut entries = vec![
455            FileEntry::file("beta.txt"),
456            FileEntry::folder("src"),
457            FileEntry::file("Alpha.txt"),
458            FileEntry::folder(".git"),
459            FileEntry::file(".env"),
460            FileEntry::folder("Docs"),
461        ];
462        sort_entries(&mut entries, &PathStyle::posix());
463        assert_eq!(
464            names(&entries),
465            ["Docs", "src", ".git", "Alpha.txt", "beta.txt", ".env"]
466        );
467    }
468
469    #[test]
470    fn a_leading_dot_hides_an_entry_and_can_be_overridden() {
471        assert!(FileEntry::file(".env").hidden());
472        assert!(!FileEntry::file(".env").with_hidden(false).hidden());
473        assert!(FileEntry::folder("src").with_hidden(true).hidden());
474    }
475
476    #[test]
477    fn the_extension_is_lower_case_and_needs_a_stem() {
478        assert_eq!(
479            FileEntry::file("Main.RS").extension().as_deref(),
480            Some("rs")
481        );
482        assert_eq!(
483            FileEntry::file("a.tar.gz").extension().as_deref(),
484            Some("gz")
485        );
486        assert_eq!(FileEntry::file(".env").extension(), None);
487        assert_eq!(FileEntry::file("Makefile").extension(), None);
488        assert_eq!(FileEntry::folder("v1.2").extension(), None);
489    }
490}