Skip to main content

ragrig_gpui/
paths.rs

1//! Path display and file dialogs with a starting directory.
2//!
3//! GPUI's [`App::prompt_for_paths`] has no option for the folder a dialog
4//! opens in. On Linux the XDG portal does, so [`prompt_for_paths_at`] asks the
5//! portal directly; other platforms use GPUI's prompt, whose default is the
6//! process working directory — which Scico points at the workspace.
7//!
8//! The helpers here also keep long home-directory paths readable: settings
9//! show `~/paper` while the application continues to store absolute paths.
10
11use std::future::Future;
12use std::path::{Path, PathBuf};
13use std::pin::Pin;
14
15use gpui_kit::{App, PathPromptOptions};
16
17/// Render `path` below the home directory as `~/…`.
18///
19/// Paths outside the home directory are returned unchanged. The function is
20/// display-only; [`expand_home`] turns the result back into a path.
21pub fn home_relative(path: &Path) -> String {
22    let Some(home) = dirs::home_dir() else {
23        return path.display().to_string();
24    };
25    match path.strip_prefix(&home) {
26        Ok(rest) if rest.as_os_str().is_empty() => "~".to_owned(),
27        Ok(rest) => format!("~{}{}", std::path::MAIN_SEPARATOR, rest.display()),
28        Err(_) => path.display().to_string(),
29    }
30}
31
32/// Expand a leading `~` to the home directory; other values are kept as given.
33pub fn expand_home(value: &str) -> PathBuf {
34    let value = value.trim();
35    if value == "~" {
36        return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
37    }
38    if let Some(rest) = value
39        .strip_prefix("~/")
40        .or_else(|| value.strip_prefix("~\\"))
41        && let Some(home) = dirs::home_dir()
42    {
43        return home.join(rest);
44    }
45    PathBuf::from(value)
46}
47
48/// The folder a **Browse…** dialog opens in.
49///
50/// The current path when it names a directory, otherwise its existing parent,
51/// otherwise the process working directory (the workspace in Scico), then the
52/// home directory.
53pub(crate) fn start_directory(current: &str) -> Option<PathBuf> {
54    let path = expand_home(current);
55    if path.as_os_str().is_empty() {
56        return std::env::current_dir().ok().or_else(dirs::home_dir);
57    }
58    if path.is_dir() {
59        return Some(path);
60    }
61    path.parent()
62        .filter(|parent| parent.is_dir())
63        .map(Path::to_path_buf)
64        .or_else(|| std::env::current_dir().ok())
65        .or_else(dirs::home_dir)
66}
67
68/// The future returned by [`prompt_for_paths_at`].
69pub type PathPrompt = Pin<Box<dyn Future<Output = Result<Option<Vec<PathBuf>>, String>> + 'static>>;
70
71/// Prompt for files or directories, preferring `start` as the initial folder.
72///
73/// The future is `'static`, so a GPUI host can run it on its executor.
74/// Cancelling the dialog yields `Ok(None)`; a dialog that cannot be opened
75/// yields a readable error.
76pub fn prompt_for_paths_at(
77    app: &App,
78    start: Option<PathBuf>,
79    options: PathPromptOptions,
80) -> PathPrompt {
81    #[cfg(target_os = "linux")]
82    {
83        let _ = app;
84        Box::pin(portal::prompt(start, options))
85    }
86    #[cfg(not(target_os = "linux"))]
87    {
88        // The platform prompt has no starting-folder argument; it begins at
89        // the process working directory, which the host points at the
90        // workspace.
91        let _ = start;
92        let receiver = app.prompt_for_paths(options);
93        Box::pin(async move {
94            match receiver.await {
95                Ok(Ok(paths)) => Ok(paths),
96                Ok(Err(error)) => Err(format!("{error:#}")),
97                Err(_) => Err("the file dialog stopped without a result".to_owned()),
98            }
99        })
100    }
101}
102
103#[cfg(target_os = "linux")]
104mod portal {
105    use super::*;
106    use ashpd::desktop::file_chooser::OpenFileRequest;
107
108    pub(super) async fn prompt(
109        start: Option<PathBuf>,
110        options: PathPromptOptions,
111    ) -> Result<Option<Vec<PathBuf>>, String> {
112        let title = if options.directories {
113            "Open Folder"
114        } else {
115            "Open File"
116        };
117        let mut request = OpenFileRequest::default()
118            .modal(true)
119            .title(title)
120            .multiple(options.multiple)
121            .directory(options.directories);
122        if let Some(start) = start {
123            request = request
124                .current_folder(start)
125                .map_err(|error| format!("invalid start folder: {error}"))?;
126        }
127        let selected = request
128            .send()
129            .await
130            .map_err(|error| format!("cannot open the file dialog: {error}"))?
131            .response()
132            .map_err(|error| format!("the file dialog failed: {error}"))?;
133        let paths: Vec<PathBuf> = selected
134            .uris()
135            .iter()
136            .filter_map(|uri| url::Url::parse(uri.as_str()).ok())
137            .filter_map(|url| url.to_file_path().ok())
138            .collect();
139        Ok((!paths.is_empty()).then_some(paths))
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn home_paths_are_shown_with_a_tilde() {
149        let Some(home) = dirs::home_dir() else {
150            return;
151        };
152        assert_eq!(home_relative(&home), "~");
153        assert_eq!(
154            home_relative(&home.join("paper")),
155            format!("~{}{}", std::path::MAIN_SEPARATOR, "paper")
156        );
157        assert_eq!(expand_home("~/paper"), home.join("paper"));
158        assert_eq!(expand_home("~"), home);
159        assert_eq!(
160            expand_home("/outside/paper"),
161            PathBuf::from("/outside/paper")
162        );
163    }
164
165    #[test]
166    fn a_browse_dialog_starts_at_the_current_path() {
167        let dir = std::env::temp_dir().join(format!(
168            "ragrig-gpui-paths-{}-{}",
169            std::process::id(),
170            line!()
171        ));
172        std::fs::create_dir_all(&dir).expect("temp dir");
173        assert_eq!(
174            start_directory(&dir.display().to_string()),
175            Some(dir.clone())
176        );
177
178        let file = dir.join("paper.md");
179        std::fs::write(&file, "# Paper").expect("paper");
180        assert_eq!(
181            start_directory(&file.display().to_string()),
182            Some(dir.clone())
183        );
184
185        let _ = std::fs::remove_dir_all(dir);
186    }
187}