Add project walks through the palette: the device (when other devices are connected), then the source (New project, Local folder, Git URL, or a GitHub/GitLab/Azure DevOps/Bitbucket/Forgejo repository), then a folder browsed from the field, a name, or what to clone. Connected devices list and add folders, new projects and clones on themselves over two new protocol messages. New thread's project rows get their badges and the device they live on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 13 changed files with 1059 additions and 550 deletions.
| @@ -0,0 +1 @@ | ||
| 1 | + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M0 8.877L2.247 5.91l8.405-3.416V.022l7.37 5.393L2.966 8.338v8.225L0 15.707zm24-4.45v14.651l-5.753 4.9-9.303-3.057v3.056l-5.978-7.416 15.057 1.798V5.415z"/></svg> | |
| @@ -0,0 +1 @@ | ||
| 1 | + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg> | |
| @@ -0,0 +1 @@ | ||
| 1 | + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z"/></svg> | |
| @@ -0,0 +1 @@ | ||
| 1 | + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/></svg> | |
| @@ -10,6 +10,7 @@ mod git; | ||
| 10 | 10 | mod http; |
| 11 | 11 | mod models; |
| 12 | 12 | mod orchestrator; |
| 13 | + mod projects; | |
| 13 | 14 | mod remote; |
| 14 | 15 | mod session; |
| 15 | 16 | mod settings; |
| @@ -0,0 +1,256 @@ | ||
| 1 | + //! Where a new project comes from, as the Add project palette offers it: a | |
| 2 | + //! folder already on disk, a new empty repository, or a clone. A host runs | |
| 3 | + //! the same code when a connected device adds a project on it. | |
| 4 | + | |
| 5 | + use std::path::{Path, PathBuf}; | |
| 6 | + use std::process::{Command, Stdio}; | |
| 7 | + | |
| 8 | + use serde::{Deserialize, Serialize}; | |
| 9 | + | |
| 10 | + use crate::ui::folder_browser::expand; | |
| 11 | + use crate::ui::workspace::find_program; | |
| 12 | + | |
| 13 | + /// A hosted Git service to clone `owner/repo` from. | |
| 14 | + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 15 | + #[serde(rename_all = "snake_case")] | |
| 16 | + pub enum Forge { | |
| 17 | + GitHub, | |
| 18 | + GitLab, | |
| 19 | + AzureDevOps, | |
| 20 | + Bitbucket, | |
| 21 | + Forgejo, | |
| 22 | + } | |
| 23 | + | |
| 24 | + impl Forge { | |
| 25 | + pub const ALL: [Forge; 5] = [Forge::GitHub, Forge::GitLab, Forge::AzureDevOps, Forge::Bitbucket, Forge::Forgejo]; | |
| 26 | + | |
| 27 | + pub fn label(self) -> &'static str { | |
| 28 | + match self { | |
| 29 | + Forge::GitHub => "GitHub repository", | |
| 30 | + Forge::GitLab => "GitLab repository", | |
| 31 | + Forge::AzureDevOps => "Azure DevOps repository", | |
| 32 | + Forge::Bitbucket => "Bitbucket repository", | |
| 33 | + Forge::Forgejo => "Forgejo / Gitea repository", | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + /// What the clone field asks for. | |
| 38 | + pub fn placeholder(self) -> &'static str { | |
| 39 | + match self { | |
| 40 | + Forge::AzureDevOps => "organization/project/repo", | |
| 41 | + Forge::Bitbucket => "workspace/repo", | |
| 42 | + _ => "owner/repo", | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + pub fn description(self) -> String { | |
| 47 | + match self { | |
| 48 | + Forge::GitHub => "Clone a GitHub owner/repo".into(), | |
| 49 | + Forge::GitLab => "Clone a GitLab group/repo".into(), | |
| 50 | + Forge::AzureDevOps => "Clone Azure DevOps project/repository".into(), | |
| 51 | + Forge::Bitbucket => "Clone Bitbucket workspace/repository".into(), | |
| 52 | + Forge::Forgejo => "Clone Forgejo / Gitea owner/repo".into(), | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + /// The command-line tool that signs in to it, if cloning needs one. | |
| 57 | + pub fn cli(self) -> Option<&'static str> { | |
| 58 | + match self { | |
| 59 | + Forge::GitHub => Some("gh"), | |
| 60 | + Forge::GitLab => Some("glab"), | |
| 61 | + Forge::Forgejo => Some("tea"), | |
| 62 | + Forge::AzureDevOps | Forge::Bitbucket => None, | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + /// What to do before it can clone, when its tool is missing. | |
| 67 | + pub fn setup_hint(self) -> &'static str { | |
| 68 | + match self { | |
| 69 | + Forge::GitHub => "Install the GitHub CLI (gh) and run gh auth login.", | |
| 70 | + Forge::GitLab => "Install the GitLab CLI (glab) and run glab auth login.", | |
| 71 | + Forge::Forgejo => "Install the Gitea CLI (tea) and run tea login add.", | |
| 72 | + Forge::AzureDevOps | Forge::Bitbucket => "", | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + /// Whether its tool is installed here. | |
| 77 | + pub fn ready(self) -> bool { | |
| 78 | + self.cli().is_none_or(|cli| find_program(&[cli]).is_some()) | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + /// What the user picked in the Add project palette. | |
| 83 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 84 | + #[serde(tag = "kind", rename_all = "snake_case")] | |
| 85 | + pub enum ProjectSource { | |
| 86 | + /// A folder that's already there, as typed: `~/Work/app`. | |
| 87 | + Folder { path: String }, | |
| 88 | + /// A new folder in the base directory, with an empty repository. | |
| 89 | + New { name: String }, | |
| 90 | + /// A clone of a Git URL. | |
| 91 | + Url { url: String }, | |
| 92 | + /// A clone of a repository on a forge. | |
| 93 | + Forge { forge: Forge, repo: String }, | |
| 94 | + } | |
| 95 | + | |
| 96 | + impl ProjectSource { | |
| 97 | + /// Clones take a while, so they run in the background. | |
| 98 | + pub fn is_clone(&self) -> bool { | |
| 99 | + matches!(self, ProjectSource::Url { .. } | ProjectSource::Forge { .. }) | |
| 100 | + } | |
| 101 | + | |
| 102 | + /// The folder a clone or new project lands in, under `base`. | |
| 103 | + pub fn destination(&self, base: &Path) -> Option<PathBuf> { | |
| 104 | + let name = match self { | |
| 105 | + ProjectSource::Folder { .. } => return None, | |
| 106 | + ProjectSource::New { name } => name.trim().to_string(), | |
| 107 | + ProjectSource::Url { url } => repo_name(url)?, | |
| 108 | + ProjectSource::Forge { repo, .. } => repo_name(repo)?, | |
| 109 | + }; | |
| 110 | + Some(base.join(name)) | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// The folder a repository clones into: its URL's last part, without `.git`. | |
| 115 | + pub fn repo_name(url: &str) -> Option<String> { | |
| 116 | + let url = url.trim().trim_end_matches('/'); | |
| 117 | + let last = url.rsplit(['/', ':']).next()?; | |
| 118 | + let name = last.strip_suffix(".git").unwrap_or(last); | |
| 119 | + (!name.is_empty() && name != "." && name != "..").then(|| name.to_string()) | |
| 120 | + } | |
| 121 | + | |
| 122 | + /// Why a new project's name won't do, if it won't. | |
| 123 | + pub fn check_name(name: &str) -> Option<&'static str> { | |
| 124 | + let name = name.trim(); | |
| 125 | + if name.is_empty() { | |
| 126 | + Some("Type a name for the project.") | |
| 127 | + } else if name == "." || name == ".." || name.contains('/') || name.contains('\\') { | |
| 128 | + Some("A project name can't contain slashes.") | |
| 129 | + } else { | |
| 130 | + None | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + /// Makes the source a project folder on this device and returns it. New | |
| 135 | + /// projects and clones go in `base`. Clones block until git is done. | |
| 136 | + pub fn resolve(source: &ProjectSource, base: &Path, home: &Path) -> Result<PathBuf, String> { | |
| 137 | + match source { | |
| 138 | + ProjectSource::Folder { path } => { | |
| 139 | + let path = expand(path.trim(), home); | |
| 140 | + if path.is_dir() { Ok(path) } else { Err(format!("{} isn't a folder.", path.display())) } | |
| 141 | + } | |
| 142 | + ProjectSource::New { name } => { | |
| 143 | + if let Some(problem) = check_name(name) { | |
| 144 | + return Err(problem.into()); | |
| 145 | + } | |
| 146 | + let path = base.join(name.trim()); | |
| 147 | + if path.exists() { | |
| 148 | + return Err(format!("{} already exists.", path.display())); | |
| 149 | + } | |
| 150 | + std::fs::create_dir_all(&path).map_err(|err| format!("Couldn't create {}: {err}", path.display()))?; | |
| 151 | + if let Some(git) = find_program(&["git"]) { | |
| 152 | + run(Command::new(git).arg("init").arg("--quiet").arg(&path))?; | |
| 153 | + } | |
| 154 | + Ok(path) | |
| 155 | + } | |
| 156 | + ProjectSource::Url { url } => { | |
| 157 | + let dest = clone_destination(source, base)?; | |
| 158 | + let git = find_program(&["git"]).ok_or("Git isn't installed.")?; | |
| 159 | + run(Command::new(git).args(["clone", "--quiet", "--", url.trim()]).arg(&dest))?; | |
| 160 | + Ok(dest) | |
| 161 | + } | |
| 162 | + ProjectSource::Forge { forge, repo } => { | |
| 163 | + let dest = clone_destination(source, base)?; | |
| 164 | + let repo = repo.trim().trim_matches('/'); | |
| 165 | + let mut command = match forge.cli() { | |
| 166 | + Some(cli) => { | |
| 167 | + let program = find_program(&[cli]).ok_or_else(|| forge.setup_hint().to_string())?; | |
| 168 | + let mut command = Command::new(program); | |
| 169 | + match forge { | |
| 170 | + Forge::Forgejo => command.args(["clone", repo]).arg(&dest), | |
| 171 | + _ => command.args(["repo", "clone", repo]).arg(&dest), | |
| 172 | + }; | |
| 173 | + command | |
| 174 | + } | |
| 175 | + None => { | |
| 176 | + let url = match forge { | |
| 177 | + Forge::AzureDevOps => { | |
| 178 | + let parts: Vec<&str> = repo.split('/').collect(); | |
| 179 | + let [org, project, name] = parts.as_slice() else { | |
| 180 | + return Err("Type organization/project/repo.".into()); | |
| 181 | + }; | |
| 182 | + format!("https://dev.azure.com/{org}/{project}/_git/{name}") | |
| 183 | + } | |
| 184 | + _ => format!("https://bitbucket.org/{repo}.git"), | |
| 185 | + }; | |
| 186 | + let git = find_program(&["git"]).ok_or("Git isn't installed.")?; | |
| 187 | + let mut command = Command::new(git); | |
| 188 | + command.args(["clone", "--quiet", "--", &url]).arg(&dest); | |
| 189 | + command | |
| 190 | + } | |
| 191 | + }; | |
| 192 | + run(&mut command)?; | |
| 193 | + Ok(dest) | |
| 194 | + } | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + fn clone_destination(source: &ProjectSource, base: &Path) -> Result<PathBuf, String> { | |
| 199 | + let dest = source.destination(base).ok_or("That doesn't look like a repository.")?; | |
| 200 | + if dest.exists() { | |
| 201 | + return Err(format!("{} already exists.", dest.display())); | |
| 202 | + } | |
| 203 | + std::fs::create_dir_all(base).map_err(|err| format!("Couldn't create {}: {err}", base.display()))?; | |
| 204 | + Ok(dest) | |
| 205 | + } | |
| 206 | + | |
| 207 | + /// Runs a command without a terminal, so a missing login fails instead of | |
| 208 | + /// waiting for a password no one can type. | |
| 209 | + fn run(command: &mut Command) -> Result<(), String> { | |
| 210 | + command.stdin(Stdio::null()).env("GIT_TERMINAL_PROMPT", "0"); | |
| 211 | + if std::env::var_os("GIT_SSH_COMMAND").is_none() { | |
| 212 | + command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); | |
| 213 | + } | |
| 214 | + let output = command.output().map_err(|err| err.to_string())?; | |
| 215 | + if output.status.success() { | |
| 216 | + return Ok(()); | |
| 217 | + } | |
| 218 | + let stderr = String::from_utf8_lossy(&output.stderr); | |
| 219 | + let message = stderr.lines().map(str::trim).rfind(|line| !line.is_empty()).unwrap_or("it failed"); | |
| 220 | + Err(message.to_string()) | |
| 221 | + } | |
| 222 | + | |
| 223 | + #[cfg(test)] | |
| 224 | + mod tests { | |
| 225 | + use super::{Forge, ProjectSource, check_name, repo_name}; | |
| 226 | + use std::path::{Path, PathBuf}; | |
| 227 | + | |
| 228 | + #[test] | |
| 229 | + fn names_repositories() { | |
| 230 | + assert_eq!(repo_name("https://github.com/zed-industries/zed.git").as_deref(), Some("zed")); | |
| 231 | + assert_eq!(repo_name("https://github.com/zed-industries/zed/").as_deref(), Some("zed")); | |
| 232 | + assert_eq!(repo_name("git@github.com:owner/app.git").as_deref(), Some("app")); | |
| 233 | + assert_eq!(repo_name("git@host:app.git").as_deref(), Some("app")); | |
| 234 | + assert_eq!(repo_name("owner/repo").as_deref(), Some("repo")); | |
| 235 | + assert_eq!(repo_name(""), None); | |
| 236 | + assert_eq!(repo_name("https://host/.."), None); | |
| 237 | + } | |
| 238 | + | |
| 239 | + #[test] | |
| 240 | + fn checks_names() { | |
| 241 | + assert!(check_name("app").is_none()); | |
| 242 | + assert!(check_name(" ").is_some()); | |
| 243 | + assert!(check_name("a/b").is_some()); | |
| 244 | + assert!(check_name("..").is_some()); | |
| 245 | + } | |
| 246 | + | |
| 247 | + #[test] | |
| 248 | + fn places_clones_in_the_base() { | |
| 249 | + let base = Path::new("/home/ada/Work"); | |
| 250 | + let url = ProjectSource::Url { url: "https://example.com/x/app.git".into() }; | |
| 251 | + assert_eq!(url.destination(base), Some(PathBuf::from("/home/ada/Work/app"))); | |
| 252 | + let forge = ProjectSource::Forge { forge: Forge::GitHub, repo: "owner/tool".into() }; | |
| 253 | + assert_eq!(forge.destination(base), Some(PathBuf::from("/home/ada/Work/tool"))); | |
| 254 | + assert_eq!(ProjectSource::Folder { path: "~/x".into() }.destination(base), None); | |
| 255 | + } | |
| 256 | + } | |
| @@ -10,7 +10,9 @@ | ||
| 10 | 10 | //! and receives each change as a [`ServerMessage::Thread`] carrying the |
| 11 | 11 | //! transcript from the first item that changed. The host also shares its |
| 12 | 12 | //! environment settings in [`ServerMessage::Settings`], which clients can |
| 13 | - //! change with [`ClientMessage::UpdateSettings`]. | |
| 13 | + //! change with [`ClientMessage::UpdateSettings`]. The Add project palette | |
| 14 | + //! browses and adds the host's folders with [`ClientMessage::ListFolders`] | |
| 15 | + //! and [`ClientMessage::AddProject`]. | |
| 14 | 16 | |
| 15 | 17 | pub mod client; |
| 16 | 18 | pub mod server; |
| @@ -20,6 +22,8 @@ use std::path::PathBuf; | ||
| 20 | 22 | use serde::{Deserialize, Serialize}; |
| 21 | 23 | |
| 22 | 24 | use crate::git::TurnChanges; |
| 25 | + use crate::projects::ProjectSource; | |
| 26 | + use crate::ui::folder_browser::Folder; | |
| 23 | 27 | use crate::models::Provider; |
| 24 | 28 | use crate::session::{QueuedMessage, SavedItem}; |
| 25 | 29 | use crate::settings::SettingsPatch; |
| @@ -173,6 +177,11 @@ pub enum ClientMessage { | ||
| 173 | 177 | }, |
| 174 | 178 | /// Changes the host's environment settings or its projects' overrides. |
| 175 | 179 | UpdateSettings { patch: SettingsPatch }, |
| 180 | + /// Lists a folder on the host for the Add project palette, as typed | |
| 181 | + /// there: `~/Work/`. Older hosts don't answer. | |
| 182 | + ListFolders { path: String }, | |
| 183 | + /// Adds a project on the host, cloning or creating it as asked. | |
| 184 | + AddProject { source: ProjectSource }, | |
| 176 | 185 | } |
| 177 | 186 | |
| 178 | 187 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| @@ -189,6 +198,10 @@ pub enum ServerMessage { | ||
| 189 | 198 | /// The host's environment settings and project overrides, sent on |
| 190 | 199 | /// connecting and whenever they change. Older hosts don't send it. |
| 191 | 200 | Settings { values: serde_json::Map<String, serde_json::Value> }, |
| 201 | + /// The subfolders of a folder a client asked for, or why it can't have them. | |
| 202 | + Folders { path: String, folders: Vec<Folder>, truncated: bool, error: Option<String> }, | |
| 203 | + /// The project a client asked for is in the host's list now. | |
| 204 | + ProjectAdded { path: PathBuf }, | |
| 192 | 205 | } |
| 193 | 206 | |
| 194 | 207 | /// A new random key: 24 bytes, hex. |
| @@ -280,6 +293,11 @@ mod tests { | ||
| 280 | 293 | let message = ClientMessage::UpdateSettings { patch }; |
| 281 | 294 | let text = serde_json::to_string(&message).unwrap(); |
| 282 | 295 | assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); |
| 296 | + let message = ClientMessage::AddProject { | |
| 297 | + source: ProjectSource::Forge { forge: crate::projects::Forge::GitHub, repo: "owner/repo".into() }, | |
| 298 | + }; | |
| 299 | + let text = serde_json::to_string(&message).unwrap(); | |
| 300 | + assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); | |
| 283 | 301 | let values = crate::settings::Settings::default().environment_values(); |
| 284 | 302 | let text = serde_json::to_string(&ServerMessage::Settings { values: values.clone() }).unwrap(); |
| 285 | 303 | match serde_json::from_str::<ServerMessage>(&text).unwrap() { |
| @@ -1,405 +1,145 @@ | ||
| 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. | |
| 1 | + //! The folders the Add project palette browses: a path field filters and | |
| 2 | + //! completes the listing below it, like a shell. The listing is of this | |
| 3 | + //! device, or of another one, which sends it on request. | |
| 4 | 4 | |
| 5 | 5 | use std::path::{Component, Path, PathBuf}; |
| 6 | 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 _}; | |
| 7 | + use serde::{Deserialize, Serialize}; | |
| 16 | 8 | |
| 17 | 9 | /// The most folders one listing shows. |
| 18 | 10 | const MAX_FOLDERS: usize = 2000; |
| 19 | 11 | /// The most entries read from one folder, so a folder full of files stays quick. |
| 20 | 12 | const MAX_SCANNED: usize = 20_000; |
| 21 | 13 | |
| 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, | |
| 14 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 15 | + pub struct Folder { | |
| 16 | + pub name: String, | |
| 31 | 17 | /// It has a `.git`, so it's likely a project. |
| 32 | - git: bool, | |
| 18 | + pub git: bool, | |
| 33 | 19 | } |
| 34 | 20 | |
| 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), | |
| 21 | + /// A folder's subfolders, and whether the listing stopped short. | |
| 22 | + pub type Folders = Result<(Vec<Folder>, bool), String>; | |
| 23 | + | |
| 24 | + enum Listing { | |
| 25 | + /// Asked another device for it. | |
| 26 | + Loading, | |
| 27 | + Ready { folders: Vec<Folder>, truncated: bool }, | |
| 28 | + Failed(String), | |
| 40 | 29 | } |
| 41 | 30 | |
| 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, | |
| 31 | + /// The listing behind the path field. | |
| 32 | + pub struct FolderListing { | |
| 33 | + /// The folder part of the field it lists: `~/Work/`. | |
| 34 | + dir: Option<String>, | |
| 35 | + listing: Listing, | |
| 54 | 36 | /// The partial name typed after the folder, which filters the listing. |
| 55 | 37 | filter: String, |
| 56 | - rows: Vec<Row>, | |
| 57 | - highlight: usize, | |
| 58 | - scroll: ScrollHandle, | |
| 59 | - _subscription: Subscription, | |
| 38 | + matches: Vec<usize>, | |
| 60 | 39 | } |
| 61 | 40 | |
| 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) | |
| 41 | + impl Default for FolderListing { | |
| 42 | + fn default() -> Self { | |
| 43 | + Self { dir: None, listing: Listing::Loading, filter: String::new(), matches: Vec::new() } | |
| 67 | 44 | } |
| 68 | 45 | } |
| 69 | 46 | |
| 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 | |
| 47 | + impl FolderListing { | |
| 48 | + /// Follows the field. Returns the folder to list when it changed, for | |
| 49 | + /// the caller to read here or ask the other device for; it's | |
| 50 | + /// [`Loading`](Listing::Loading) until [`receive`](Self::receive)d. | |
| 51 | + pub fn set_text(&mut self, text: &str) -> Option<String> { | |
| 52 | + let (dir, filter) = split_field(text); | |
| 53 | + self.filter = filter; | |
| 54 | + let changed = self.dir.as_deref() != Some(dir.as_str()); | |
| 55 | + if changed { | |
| 56 | + self.dir = Some(dir.clone()); | |
| 57 | + self.listing = Listing::Loading; | |
| 58 | + } | |
| 59 | + self.refilter(); | |
| 60 | + changed.then_some(dir) | |
| 101 | 61 | } |
| 102 | 62 | |
| 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; | |
| 63 | + /// A listing arrived; it's dropped if the field moved on since. | |
| 64 | + pub fn receive(&mut self, dir: &str, folders: Folders) { | |
| 65 | + if self.dir.as_deref() != Some(dir) { | |
| 66 | + return; | |
| 126 | 67 | } |
| 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() | |
| 68 | + self.listing = match folders { | |
| 69 | + Ok((folders, truncated)) => Listing::Ready { folders, truncated }, | |
| 70 | + Err(error) => Listing::Failed(error), | |
| 138 | 71 | }; |
| 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(); | |
| 72 | + self.refilter(); | |
| 148 | 73 | } |
| 149 | 74 | |
| 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); | |
| 75 | + fn refilter(&mut self) { | |
| 76 | + self.matches = match &self.listing { | |
| 77 | + Listing::Ready { folders, .. } => { | |
| 78 | + let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect(); | |
| 79 | + filter_names(&names, &self.filter) | |
| 80 | + } | |
| 81 | + _ => Vec::new(), | |
| 82 | + }; | |
| 163 | 83 | } |
| 164 | 84 | |
| 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 | - } | |
| 85 | + fn folders(&self) -> &[Folder] { | |
| 86 | + match &self.listing { | |
| 87 | + Listing::Ready { folders, .. } => folders, | |
| 88 | + _ => &[], | |
| 185 | 89 | } |
| 186 | 90 | } |
| 187 | 91 | |
| 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); | |
| 92 | + /// The folders that match what's typed, best first. | |
| 93 | + pub fn matches(&self) -> impl Iterator<Item = &Folder> { | |
| 94 | + let folders = self.folders(); | |
| 95 | + self.matches.iter().map(move |&ix| &folders[ix]) | |
| 198 | 96 | } |
| 199 | 97 | |
| 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) | |
| 98 | + /// The field's text for going into one of the folders. | |
| 99 | + pub fn open(&self, name: &str) -> String { | |
| 100 | + format!("{}{name}/", self.dir.as_deref().unwrap_or_default()) | |
| 204 | 101 | } |
| 205 | 102 | |
| 206 | - fn pick(&mut self, path: PathBuf, cx: &mut Context<Self>) { | |
| 207 | - cx.emit(FolderBrowserEvent::Picked(path)); | |
| 103 | + /// Whether the folder the field names exists, as far as the listing | |
| 104 | + /// tells: the folder part does, and the rest is empty or names one of it. | |
| 105 | + pub fn names_folder(&self) -> bool { | |
| 106 | + matches!(self.listing, Listing::Ready { .. }) | |
| 107 | + && (self.filter.is_empty() || self.folders().iter().any(|folder| folder.name == self.filter)) | |
| 208 | 108 | } |
| 209 | 109 | |
| 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() | |
| 110 | + /// Tab: the field completed to the one folder the partial name matches, | |
| 111 | + /// or as far as all its matches agree. | |
| 112 | + pub fn complete(&self) -> Option<String> { | |
| 113 | + let names: Vec<&str> = self.folders().iter().map(|folder| folder.name.as_str()).collect(); | |
| 114 | + let completion = complete_name(&names, &self.filter)?; | |
| 115 | + Some(format!("{}{completion}", self.dir.as_deref().unwrap_or_default())) | |
| 263 | 116 | } |
| 264 | 117 | |
| 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 | |
| 118 | + /// A muted line in place of rows, when there's something to explain. | |
| 119 | + pub fn message(&self) -> Option<String> { | |
| 120 | + match &self.listing { | |
| 121 | + Listing::Loading => Some("Loading…".into()), | |
| 122 | + Listing::Failed(error) => Some(error.clone()), | |
| 123 | + Listing::Ready { truncated: true, .. } => { | |
| 124 | + Some(format!("Showing the first {MAX_FOLDERS} folders. Type to narrow them down.")) | |
| 125 | + } | |
| 126 | + Listing::Ready { folders, .. } if folders.is_empty() => Some("No folders here.".into()), | |
| 127 | + Listing::Ready { .. } if self.matches.is_empty() => Some("No folders match.".into()), | |
| 128 | + Listing::Ready { .. } => None, | |
| 279 | 129 | } |
| 280 | 130 | } |
| 281 | 131 | } |
| 282 | 132 | |
| 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 | - ) | |
| 133 | + /// Lists the folder `dir` names (as typed: `~/Work/`) on this device. | |
| 134 | + pub fn list(dir: &str, home: &Path) -> Folders { | |
| 135 | + let path = expand(dir, home); | |
| 136 | + if !path.is_dir() { | |
| 137 | + return Err("No such folder.".into()); | |
| 402 | 138 | } |
| 139 | + read_folders(&path).map_err(|err| match err.kind() { | |
| 140 | + std::io::ErrorKind::PermissionDenied => "Can't read this folder: permission denied.".into(), | |
| 141 | + _ => format!("Can't read this folder: {err}"), | |
| 142 | + }) | |
| 403 | 143 | } |
| 404 | 144 | |
| 405 | 145 | /// Where the Add project browser opens: the saved "Add project starts in" |
| @@ -413,13 +153,13 @@ pub fn start_dir(setting: Option<&str>) -> PathBuf { | ||
| 413 | 153 | } |
| 414 | 154 | } |
| 415 | 155 | |
| 416 | - fn home_dir() -> PathBuf { | |
| 156 | + pub fn home_dir() -> PathBuf { | |
| 417 | 157 | std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")) |
| 418 | 158 | } |
| 419 | 159 | |
| 420 | 160 | /// Turns what's typed into an absolute path: `~` is `home`, a relative path |
| 421 | 161 | /// is taken from `home`, and `.` and `..` are resolved. |
| 422 | - fn expand(text: &str, home: &Path) -> PathBuf { | |
| 162 | + pub fn expand(text: &str, home: &Path) -> PathBuf { | |
| 423 | 163 | let path = if text == "~" { |
| 424 | 164 | home.to_path_buf() |
| 425 | 165 | } else if let Some(rest) = text.strip_prefix("~/") { |
| @@ -441,7 +181,7 @@ fn expand(text: &str, home: &Path) -> PathBuf { | ||
| 441 | 181 | } |
| 442 | 182 | |
| 443 | 183 | /// A path as the field shows it, with `~` for the home folder. |
| 444 | - fn tilde(path: &Path, home: &Path) -> String { | |
| 184 | + pub fn tilde(path: &Path, home: &Path) -> String { | |
| 445 | 185 | match path.strip_prefix(home) { |
| 446 | 186 | Ok(rest) if home.parent().is_some() && rest.as_os_str().is_empty() => "~".into(), |
| 447 | 187 | Ok(rest) if home.parent().is_some() => format!("~/{}", rest.display()), |
| @@ -450,18 +190,18 @@ fn tilde(path: &Path, home: &Path) -> String { | ||
| 450 | 190 | } |
| 451 | 191 | |
| 452 | 192 | /// The field's text for listing `dir`: its path with a trailing slash. |
| 453 | - fn field_text(dir: &Path, home: &Path) -> String { | |
| 193 | + pub fn field_text(dir: &Path, home: &Path) -> String { | |
| 454 | 194 | let text = tilde(dir, home); |
| 455 | 195 | if text.ends_with('/') { text } else { format!("{text}/") } |
| 456 | 196 | } |
| 457 | 197 | |
| 458 | 198 | /// 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) { | |
| 199 | + /// `~/Work/vec` is `~/Work/` filtered by `vec`. A bare `~` is the home folder. | |
| 200 | + fn split_field(text: &str) -> (String, String) { | |
| 461 | 201 | 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()), | |
| 202 | + _ if text == "~" => ("~/".into(), String::new()), | |
| 203 | + Some(ix) => (text[..=ix].to_string(), text[ix + 1..].to_string()), | |
| 204 | + None => (String::new(), text.to_string()), | |
| 465 | 205 | } |
| 466 | 206 | } |
| 467 | 207 | |
| @@ -552,7 +292,7 @@ fn read_folders(dir: &Path) -> std::io::Result<(Vec<Folder>, bool)> { | ||
| 552 | 292 | #[cfg(test)] |
| 553 | 293 | mod tests { |
| 554 | 294 | // 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}; | |
| 295 | + use super::{FolderListing, complete_name, expand, field_text, filter_names, home_dir, split_field, start_dir, tilde}; | |
| 556 | 296 | use std::path::{Path, PathBuf}; |
| 557 | 297 | |
| 558 | 298 | const HOME: &str = "/home/ada"; |
| @@ -592,12 +332,31 @@ mod tests { | ||
| 592 | 332 | |
| 593 | 333 | #[::core::prelude::v1::test] |
| 594 | 334 | 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())); | |
| 335 | + assert_eq!(split_field("~/Work/vec"), ("~/Work/".into(), "vec".into())); | |
| 336 | + assert_eq!(split_field("~/Work/"), ("~/Work/".into(), String::new())); | |
| 337 | + assert_eq!(split_field("~"), ("~/".into(), String::new())); | |
| 338 | + assert_eq!(split_field(""), (String::new(), String::new())); | |
| 339 | + assert_eq!(split_field("/e"), ("/".into(), "e".into())); | |
| 340 | + } | |
| 341 | + | |
| 342 | + #[::core::prelude::v1::test] | |
| 343 | + fn follows_the_field() { | |
| 344 | + let folder = |name: &str| super::Folder { name: name.into(), git: false }; | |
| 345 | + let mut listing = FolderListing::default(); | |
| 346 | + assert_eq!(listing.set_text("~/Work/ve"), Some("~/Work/".into())); | |
| 347 | + assert_eq!(listing.message().as_deref(), Some("Loading…")); | |
| 348 | + listing.receive("~/Work/", Ok((vec![folder("rust"), folder("vectrum")], false))); | |
| 349 | + assert_eq!(listing.matches().map(|folder| folder.name.as_str()).collect::<Vec<_>>(), ["vectrum"]); | |
| 350 | + assert_eq!(listing.complete().as_deref(), Some("~/Work/vectrum/")); | |
| 351 | + assert!(!listing.names_folder()); | |
| 352 | + // The same folder isn't asked for twice. | |
| 353 | + assert_eq!(listing.set_text("~/Work/rust"), None); | |
| 354 | + assert!(listing.names_folder()); | |
| 355 | + assert_eq!(listing.open("rust"), "~/Work/rust/"); | |
| 356 | + // A late answer for another folder is dropped. | |
| 357 | + listing.set_text("~/"); | |
| 358 | + listing.receive("~/Work/", Ok((Vec::new(), false))); | |
| 359 | + assert_eq!(listing.message().as_deref(), Some("Loading…")); | |
| 601 | 360 | } |
| 602 | 361 | |
| 603 | 362 | #[::core::prelude::v1::test] |
| @@ -26,6 +26,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 26 | 26 | FolderGit2, |
| 27 | 27 | FolderPlus, |
| 28 | 28 | Gauge, |
| 29 | + Github, | |
| 29 | 30 | HardDrive, |
| 30 | 31 | GitBranch, |
| 31 | 32 | GitCommitHorizontal, |
| @@ -56,6 +57,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 56 | 57 | Server, |
| 57 | 58 | Settings2, |
| 58 | 59 | ShieldCheck, |
| 60 | + SquareDashed, | |
| 59 | 61 | SquareSlash, |
| 60 | 62 | SlidersHorizontal, |
| 61 | 63 | Space, |
| @@ -118,11 +120,28 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 118 | 120 | SquareTerminal, |
| 119 | 121 | ]); |
| 120 | 122 | |
| 121 | - /// The app's asset source: the extra icons, then gpui-component's defaults. | |
| 123 | + /// Forge logos Lucide doesn't have, from Simple Icons (CC0). | |
| 124 | + pub const GITLAB: &str = "icons/brands/gitlab.svg"; | |
| 125 | + pub const AZURE_DEVOPS: &str = "icons/brands/azuredevops.svg"; | |
| 126 | + pub const BITBUCKET: &str = "icons/brands/bitbucket.svg"; | |
| 127 | + pub const FORGEJO: &str = "icons/brands/forgejo.svg"; | |
| 128 | + | |
| 129 | + const BRANDS: &[(&str, &[u8])] = &[ | |
| 130 | + (GITLAB, include_bytes!("../../assets/icons/brands/gitlab.svg")), | |
| 131 | + (AZURE_DEVOPS, include_bytes!("../../assets/icons/brands/azuredevops.svg")), | |
| 132 | + (BITBUCKET, include_bytes!("../../assets/icons/brands/bitbucket.svg")), | |
| 133 | + (FORGEJO, include_bytes!("../../assets/icons/brands/forgejo.svg")), | |
| 134 | + ]; | |
| 135 | + | |
| 136 | + /// The app's asset source: the forge logos, the extra icons, then | |
| 137 | + /// gpui-component's defaults. | |
| 122 | 138 | pub struct AppAssets; |
| 123 | 139 | |
| 124 | 140 | impl AssetSource for AppAssets { |
| 125 | 141 | fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> { |
| 142 | + if let Some((_, data)) = BRANDS.iter().find(|(brand, _)| *brand == path) { | |
| 143 | + return Ok(Some(Cow::Borrowed(data))); | |
| 144 | + } | |
| 126 | 145 | if let Some(data) = ExtraIcons.load(path)? { |
| 127 | 146 | return Ok(Some(data)); |
| 128 | 147 | } |
| @@ -529,11 +529,6 @@ pub fn keycap(text: impl Into<SharedString>, p: &Palette) -> Div { | ||
| 529 | 529 | .child(text.into()) |
| 530 | 530 | } |
| 531 | 531 | |
| 532 | - /// A row of keycaps for one shortcut. | |
| 533 | - pub fn shortcut(keys: &[&'static str], p: &Palette) -> Div { | |
| 534 | - h_flex().gap(px(3.)).children(keys.iter().map(|key| keycap(*key, p))) | |
| 535 | - } | |
| 536 | - | |
| 537 | 532 | /// A switch: a sunk track with a raised knob, lit green when on. |
| 538 | 533 | pub fn toggle( |
| 539 | 534 | id: impl Into<ElementId>, |
| @@ -33,7 +33,9 @@ use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode}; | ||
| 33 | 33 | use crate::store::{self, AppState, SavedProject}; |
| 34 | 34 | use crate::ui::browser::{BrowserPanel, BrowserPanelEvent}; |
| 35 | 35 | use crate::ui::chat::{ChatEvent, ChatView, display_path}; |
| 36 | - use crate::ui::folder_browser::{self, FolderBrowser, FolderBrowserEvent}; | |
| 36 | + use crate::projects::{self, ProjectSource}; | |
| 37 | + use crate::remote::ClientMessage; | |
| 38 | + use crate::ui::folder_browser; | |
| 37 | 39 | use crate::ui::icons::Lucide; |
| 38 | 40 | use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; |
| 39 | 41 | use crate::ui::settings::{SettingsEvent, SettingsView}; |
| @@ -272,7 +274,6 @@ pub struct Workspace { | ||
| 272 | 274 | settings_view: Entity<SettingsView>, |
| 273 | 275 | settings_open: bool, |
| 274 | 276 | /// The Add project browser, while it's open. |
| 275 | - folder_browser: Option<(Entity<FolderBrowser>, Subscription)>, | |
| 276 | 277 | activity: HashMap<EntityId, Activity>, |
| 277 | 278 | /// The thread whose settle button waits for its confirming click. |
| 278 | 279 | settle_armed: Option<(EntityId, Instant)>, |
| @@ -410,7 +411,6 @@ impl Workspace { | ||
| 410 | 411 | hostname: hostname().into(), |
| 411 | 412 | settings_view, |
| 412 | 413 | settings_open: false, |
| 413 | - folder_browser: None, | |
| 414 | 414 | activity: HashMap::new(), |
| 415 | 415 | settle_armed: None, |
| 416 | 416 | archive_armed: None, |
| @@ -1548,42 +1548,49 @@ impl Workspace { | ||
| 1548 | 1548 | cx.notify(); |
| 1549 | 1549 | } |
| 1550 | 1550 | |
| 1551 | - /// Opens the Add project browser over the window, in the folder the | |
| 1552 | - /// settings name. | |
| 1551 | + /// Opens Add project in the palette: the devices to add it on, or this | |
| 1552 | + /// device's sources when there are no others. | |
| 1553 | 1553 | fn prompt_for_project(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 1554 | + self.close_palette(window, cx); | |
| 1555 | + let view = self.add_project_view(); | |
| 1556 | + self.open_palette(view, window, cx); | |
| 1557 | + } | |
| 1558 | + | |
| 1559 | + /// Adds the project the palette picked, on this device or another, and | |
| 1560 | + /// starts a thread in it. Clones run in the background. | |
| 1561 | + fn add_project_from(&mut self, env: Option<String>, source: ProjectSource, window: &mut Window, cx: &mut Context<Self>) { | |
| 1562 | + let label = source.destination(Path::new("")).map(|name| name.display().to_string()); | |
| 1563 | + if let Some(env) = env { | |
| 1564 | + let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) else { return }; | |
| 1565 | + if source.is_clone() { | |
| 1566 | + let name = environment.host.label(); | |
| 1567 | + let what = label.unwrap_or_else(|| "the repository".into()); | |
| 1568 | + window.push_notification(Notification::info(format!("Cloning {what} on {name}…")), cx); | |
| 1569 | + } | |
| 1570 | + environment.client.send(&ClientMessage::AddProject { source }); | |
| 1571 | + return; | |
| 1572 | + } | |
| 1554 | 1573 | let saved = settings::get(|settings| settings.add_project_dir.clone()); |
| 1555 | - let start = folder_browser::start_dir(Some(saved.as_str())); | |
| 1556 | - let browser = cx.new(|cx| FolderBrowser::new(start, window, cx)); | |
| 1557 | - let subscription = cx.subscribe_in(&browser, window, |this, _, event: &FolderBrowserEvent, window, cx| { | |
| 1558 | - this.close_folder_browser(window, cx); | |
| 1559 | - match event { | |
| 1560 | - FolderBrowserEvent::Picked(path) => this.add_project(path.clone(), window, cx), | |
| 1561 | - FolderBrowserEvent::Cancelled => {} | |
| 1562 | - FolderBrowserEvent::SystemDialog => this.prompt_with_system_dialog(window, cx), | |
| 1574 | + let base = folder_browser::start_dir(Some(&saved)); | |
| 1575 | + let home = folder_browser::home_dir(); | |
| 1576 | + if !source.is_clone() { | |
| 1577 | + match projects::resolve(&source, &base, &home) { | |
| 1578 | + Ok(path) => self.add_project(path, window, cx), | |
| 1579 | + Err(message) => window.push_notification(Notification::error(message).title("Couldn't add the project"), cx), | |
| 1563 | 1580 | } |
| 1564 | - }); | |
| 1565 | - browser.focus_handle(cx).focus(window, cx); | |
| 1566 | - self.folder_browser = Some((browser, subscription)); | |
| 1567 | - cx.notify(); | |
| 1568 | - } | |
| 1569 | - | |
| 1570 | - fn close_folder_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 1571 | - self.folder_browser = None; | |
| 1572 | - self.focus_handle.focus(window, cx); | |
| 1573 | - cx.notify(); | |
| 1574 | - } | |
| 1575 | - | |
| 1576 | - fn prompt_with_system_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 1577 | - let paths = cx.prompt_for_paths(PathPromptOptions { | |
| 1578 | - files: false, | |
| 1579 | - directories: true, | |
| 1580 | - multiple: false, | |
| 1581 | - prompt: Some("Open project".into()), | |
| 1582 | - }); | |
| 1581 | + return; | |
| 1582 | + } | |
| 1583 | + let what = label.unwrap_or_else(|| "the repository".into()); | |
| 1584 | + window.push_notification(Notification::info(format!("Cloning {what}…")), cx); | |
| 1585 | + let clone = cx.background_executor().spawn(async move { projects::resolve(&source, &base, &home) }); | |
| 1583 | 1586 | cx.spawn_in(window, async move |this, cx| { |
| 1584 | - let Ok(Ok(Some(paths))) = paths.await else { return }; | |
| 1585 | - let Some(path) = paths.into_iter().next() else { return }; | |
| 1586 | - let _ = this.update_in(cx, |this, window, cx| this.add_project(path, window, cx)); | |
| 1587 | + let result = clone.await; | |
| 1588 | + let _ = this.update_in(cx, |this, window, cx| match result { | |
| 1589 | + Ok(path) => this.add_project(path, window, cx), | |
| 1590 | + Err(message) => { | |
| 1591 | + window.push_notification(Notification::error(message).title(format!("Couldn't clone {what}")), cx) | |
| 1592 | + } | |
| 1593 | + }); | |
| 1587 | 1594 | }) |
| 1588 | 1595 | .detach(); |
| 1589 | 1596 | } |
| @@ -2273,24 +2280,6 @@ impl Render for Workspace { | ||
| 2273 | 2280 | .with_priority(2), |
| 2274 | 2281 | ) |
| 2275 | 2282 | }) |
| 2276 | - // The Add project browser floats over everything; a click beside it cancels. | |
| 2277 | - .when_some(self.folder_browser.as_ref().map(|(browser, _)| browser.clone()), |this, browser| { | |
| 2278 | - this.child( | |
| 2279 | - div() | |
| 2280 | - .absolute() | |
| 2281 | - .inset_0() | |
| 2282 | - .flex() | |
| 2283 | - .items_center() | |
| 2284 | - .justify_center() | |
| 2285 | - .bg(black().opacity(if p.dark { 0.4 } else { 0.16 })) | |
| 2286 | - .occlude() | |
| 2287 | - .on_mouse_down( | |
| 2288 | - MouseButton::Left, | |
| 2289 | - cx.listener(|this, _, window, cx| this.close_folder_browser(window, cx)), | |
| 2290 | - ) | |
| 2291 | - .child(browser), | |
| 2292 | - ) | |
| 2293 | - }) | |
| 2294 | 2283 | } |
| 2295 | 2284 | } |
| 2296 | 2285 | |
| @@ -1,13 +1,16 @@ | ||
| 1 | 1 | //! The command palette (Ctrl+K), as T3 Code has it: Actions and Recent |
| 2 | 2 | //! Threads at rest, plus Projects, Settings and Threads while searching, |
| 3 | 3 | //! with submenus for starting a thread in a project, the theme and the |
| 4 | - //! appearance. A leading ">" searches actions only. | |
| 4 | + //! appearance. A leading ">" searches actions only. Add project walks | |
| 5 | + //! through submenus too: the device, then the source, then a folder browsed | |
| 6 | + //! from the field, a name for a new repository, or what to clone. | |
| 5 | 7 | |
| 8 | + use std::path::PathBuf; | |
| 6 | 9 | use std::rc::Rc; |
| 7 | 10 | |
| 8 | 11 | use gpui_kit::component::{ |
| 9 | 12 | Icon, IconName, WindowExt as _, h_flex, |
| 10 | - input::{Input, InputEvent, InputState}, | |
| 13 | + input::{IndentInline, Input, InputEvent, InputState}, | |
| 11 | 14 | notification::Notification, |
| 12 | 15 | v_flex, |
| 13 | 16 | }; |
| @@ -16,21 +19,66 @@ use gpui_kit::*; | ||
| 16 | 19 | |
| 17 | 20 | use super::actions::relative_time_long; |
| 18 | 21 | use super::Workspace; |
| 22 | + use crate::projects::{self, Forge, ProjectSource}; | |
| 23 | + use crate::remote::client::ConnectionStatus; | |
| 19 | 24 | use crate::settings::{self, ThemeMode}; |
| 20 | - use crate::ui::icons::Lucide; | |
| 21 | - use crate::ui::metal::{self, MetalStyled as _}; | |
| 25 | + use crate::ui::folder_browser::{self, FolderListing, Folders}; | |
| 26 | + use crate::ui::icons::{self, Lucide}; | |
| 27 | + use crate::ui::metal::{self, MetalButton, MetalStyled as _}; | |
| 22 | 28 | use crate::ui::themes; |
| 23 | 29 | use crate::{Dismiss, PaletteBack, SelectNext, SelectPrevious}; |
| 24 | 30 | |
| 25 | 31 | const RECENT_THREADS: usize = 12; |
| 26 | 32 | |
| 27 | 33 | /// Where the palette is: its root, or one of its submenus. |
| 28 | - #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 34 | + #[derive(Clone, Debug, PartialEq, Eq)] | |
| 29 | 35 | pub(crate) enum PaletteView { |
| 30 | 36 | Root, |
| 31 | 37 | NewThreadIn, |
| 32 | 38 | Themes, |
| 33 | 39 | Appearance, |
| 40 | + /// Add project: the device it goes on. | |
| 41 | + AddProject, | |
| 42 | + /// Add project: where it comes from, on a device (`None` for this one). | |
| 43 | + ProjectSources(Option<String>), | |
| 44 | + /// Add project: a folder on the device, browsed from the field. | |
| 45 | + Directories(Option<String>), | |
| 46 | + /// Add project: the name of a new repository. | |
| 47 | + NewProject(Option<String>), | |
| 48 | + /// Add project: what to clone, from a Git URL or a forge. | |
| 49 | + CloneRepo(Option<String>, Option<Forge>), | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// What the field is for. | |
| 53 | + #[derive(Clone, Copy, PartialEq, Eq)] | |
| 54 | + enum Field { | |
| 55 | + /// It filters the rows. | |
| 56 | + Search, | |
| 57 | + /// It's a path; the rows are the folders in it. | |
| 58 | + Path, | |
| 59 | + /// It's text the rows act on: a name, a URL. | |
| 60 | + Text, | |
| 61 | + } | |
| 62 | + | |
| 63 | + impl PaletteView { | |
| 64 | + fn field(&self) -> Field { | |
| 65 | + match self { | |
| 66 | + PaletteView::Directories(_) => Field::Path, | |
| 67 | + PaletteView::NewProject(_) | PaletteView::CloneRepo(..) => Field::Text, | |
| 68 | + _ => Field::Search, | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + fn placeholder(&self) -> &'static str { | |
| 73 | + match self { | |
| 74 | + PaletteView::Root => "Search commands, projects, and threads...", | |
| 75 | + PaletteView::Directories(_) => "~/", | |
| 76 | + PaletteView::NewProject(_) => "Project name", | |
| 77 | + PaletteView::CloneRepo(_, None) => "https://github.com/owner/repo.git", | |
| 78 | + PaletteView::CloneRepo(_, Some(forge)) => forge.placeholder(), | |
| 79 | + _ => "Search...", | |
| 80 | + } | |
| 81 | + } | |
| 34 | 82 | } |
| 35 | 83 | |
| 36 | 84 | pub(super) enum PaletteEvent { |
| @@ -39,6 +87,29 @@ pub(super) enum PaletteEvent { | ||
| 39 | 87 | |
| 40 | 88 | type Run = Rc<dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>)>; |
| 41 | 89 | |
| 90 | + /// What leads a row. | |
| 91 | + #[derive(Clone)] | |
| 92 | + enum Glyph { | |
| 93 | + Icon(Icon), | |
| 94 | + /// A project's initials in its tint. | |
| 95 | + Badge(SharedString, usize), | |
| 96 | + /// An icon in its own color, like a forge's logo. | |
| 97 | + Brand(Icon, Hsla), | |
| 98 | + } | |
| 99 | + | |
| 100 | + /// What choosing a row does. | |
| 101 | + #[derive(Clone)] | |
| 102 | + enum Action { | |
| 103 | + /// Nothing: the row only explains. | |
| 104 | + None, | |
| 105 | + Run(Run), | |
| 106 | + Submenu(PaletteView), | |
| 107 | + /// Goes into a folder: the path field's new text. | |
| 108 | + Browse(String), | |
| 109 | + /// Says what's missing, in a toast, and stays open. | |
| 110 | + Hint(SharedString), | |
| 111 | + } | |
| 112 | + | |
| 42 | 113 | /// One row of the palette. |
| 43 | 114 | #[derive(Clone)] |
| 44 | 115 | pub(super) struct PaletteItem { |
| @@ -47,14 +118,16 @@ pub(super) struct PaletteItem { | ||
| 47 | 118 | /// Set in bold after the title: "New thread in **project**". |
| 48 | 119 | emphasis: Option<SharedString>, |
| 49 | 120 | description: Option<SharedString>, |
| 50 | - icon: Icon, | |
| 121 | + /// Shown before the description: the laptop of `XPS · ~/Work/app`. | |
| 122 | + description_icon: Option<Icon>, | |
| 123 | + glyph: Glyph, | |
| 51 | 124 | shortcut: Option<SharedString>, |
| 52 | 125 | timestamp: Option<String>, |
| 53 | - /// Marks the theme or appearance in use. | |
| 54 | - current: bool, | |
| 55 | - /// Opens a submenu instead of running. | |
| 56 | - submenu: Option<PaletteView>, | |
| 57 | - run: Option<Run>, | |
| 126 | + /// An engraved tag: `Current` on the theme in use, `git` on a repository. | |
| 127 | + tag: Option<SharedString>, | |
| 128 | + /// An amber tag: `Setup Required`. | |
| 129 | + warning: Option<SharedString>, | |
| 130 | + action: Action, | |
| 58 | 131 | /// What a search matches, most important first. |
| 59 | 132 | terms: Vec<String>, |
| 60 | 133 | } |
| @@ -68,22 +141,40 @@ impl PaletteItem { | ||
| 68 | 141 | title, |
| 69 | 142 | emphasis: None, |
| 70 | 143 | description: None, |
| 71 | - icon: icon.into(), | |
| 144 | + description_icon: None, | |
| 145 | + glyph: Glyph::Icon(icon.into()), | |
| 72 | 146 | shortcut: None, |
| 73 | 147 | timestamp: None, |
| 74 | - current: false, | |
| 75 | - submenu: None, | |
| 76 | - run: None, | |
| 148 | + tag: None, | |
| 149 | + warning: None, | |
| 150 | + action: Action::None, | |
| 77 | 151 | } |
| 78 | 152 | } |
| 79 | 153 | |
| 80 | 154 | fn run(mut self, run: impl Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + 'static) -> Self { |
| 81 | - self.run = Some(Rc::new(run)); | |
| 155 | + self.action = Action::Run(Rc::new(run)); | |
| 82 | 156 | self |
| 83 | 157 | } |
| 84 | 158 | |
| 85 | 159 | fn submenu(mut self, view: PaletteView) -> Self { |
| 86 | - self.submenu = Some(view); | |
| 160 | + self.action = Action::Submenu(view); | |
| 161 | + self | |
| 162 | + } | |
| 163 | + | |
| 164 | + fn hint(mut self, hint: impl Into<SharedString>) -> Self { | |
| 165 | + self.action = Action::Hint(hint.into()); | |
| 166 | + self | |
| 167 | + } | |
| 168 | + | |
| 169 | + fn badge(mut self, initials: impl Into<SharedString>, tint: usize) -> Self { | |
| 170 | + self.glyph = Glyph::Badge(initials.into(), tint); | |
| 171 | + self | |
| 172 | + } | |
| 173 | + | |
| 174 | + /// A description that names a device: `XPS · ~/Work/app`. | |
| 175 | + fn on_device(mut self, description: impl Into<SharedString>) -> Self { | |
| 176 | + self = self.description(description); | |
| 177 | + self.description_icon = Some(Icon::new(Lucide::Laptop)); | |
| 87 | 178 | self |
| 88 | 179 | } |
| 89 | 180 | |
| @@ -216,8 +307,9 @@ impl Workspace { | ||
| 216 | 307 | self.settings_changed(window, cx); |
| 217 | 308 | } |
| 218 | 309 | |
| 219 | - /// The palette's rows for a view. | |
| 220 | - fn palette_items(&self, view: PaletteView, searching: bool, cx: &App) -> Vec<PaletteItem> { | |
| 310 | + /// The palette's rows for a view, given what's in the field. | |
| 311 | + fn palette_items(&self, view: &PaletteView, query: &str, cx: &App) -> Vec<PaletteItem> { | |
| 312 | + let searching = !query.trim().is_empty(); | |
| 221 | 313 | match view { |
| 222 | 314 | PaletteView::Root => { |
| 223 | 315 | let mut items = self.palette_actions(cx); |
| @@ -252,7 +344,7 @@ impl Workspace { | ||
| 252 | 344 | themes::pick(&themes::all()[ix], window, cx); |
| 253 | 345 | this.settings_changed(window, cx); |
| 254 | 346 | }); |
| 255 | - item.current = current; | |
| 347 | + item.tag = current.then(|| "Current".into()); | |
| 256 | 348 | item |
| 257 | 349 | }) |
| 258 | 350 | .collect() |
| @@ -266,11 +358,17 @@ impl Workspace { | ||
| 266 | 358 | themes::set_mode(option, window, cx); |
| 267 | 359 | this.settings_changed(window, cx); |
| 268 | 360 | }); |
| 269 | - item.current = option == mode; | |
| 361 | + item.tag = (option == mode).then(|| "Current".into()); | |
| 270 | 362 | item |
| 271 | 363 | }) |
| 272 | 364 | .collect() |
| 273 | 365 | } |
| 366 | + PaletteView::AddProject => self.palette_environments(), | |
| 367 | + PaletteView::ProjectSources(env) => self.palette_sources(env), | |
| 368 | + // The palette lists the folders itself. | |
| 369 | + PaletteView::Directories(_) => Vec::new(), | |
| 370 | + PaletteView::NewProject(env) => self.palette_new_project(env, query), | |
| 371 | + PaletteView::CloneRepo(env, forge) => self.palette_clone(env, *forge, query), | |
| 274 | 372 | } |
| 275 | 373 | } |
| 276 | 374 | |
| @@ -331,8 +429,8 @@ impl Workspace { | ||
| 331 | 429 | } |
| 332 | 430 | items.push( |
| 333 | 431 | PaletteItem::new("Actions", "Add project", Lucide::FolderPlus) |
| 334 | - .terms(&["open folder", "local folder"]) | |
| 335 | - .run(|this, window, cx| this.prompt_for_project(window, cx)), | |
| 432 | + .terms(&["open folder", "local folder", "clone", "new project"]) | |
| 433 | + .submenu(self.add_project_view()), | |
| 336 | 434 | ); |
| 337 | 435 | items.push( |
| 338 | 436 | PaletteItem::new("Actions", "Change theme", IconName::Palette) |
| @@ -379,16 +477,21 @@ impl Workspace { | ||
| 379 | 477 | format!("{machine} · {}", super::display_path(&project.path)) |
| 380 | 478 | } |
| 381 | 479 | |
| 480 | + /// A project's row, which starts a thread in it. | |
| 481 | + fn project_item(&self, ix: usize) -> PaletteItem { | |
| 482 | + let project = &self.projects[ix]; | |
| 483 | + PaletteItem::new("Projects", project.name.clone(), IconName::Folder) | |
| 484 | + .badge(project.initials.clone(), project.tint) | |
| 485 | + .on_device(self.project_description(ix)) | |
| 486 | + .run(move |this, window, cx| this.new_thread(ix, window, cx)) | |
| 487 | + } | |
| 488 | + | |
| 382 | 489 | fn palette_projects(&self, cx: &App) -> Vec<PaletteItem> { |
| 383 | 490 | let scratch = super::scratch_dir(); |
| 384 | 491 | self.ordered_projects(cx) |
| 385 | 492 | .into_iter() |
| 386 | 493 | .filter(|&ix| Some(&self.projects[ix].path) != scratch.as_ref()) |
| 387 | - .map(|ix| { | |
| 388 | - PaletteItem::new("Projects", self.projects[ix].name.clone(), IconName::Folder) | |
| 389 | - .description(self.project_description(ix)) | |
| 390 | - .run(move |this, window, cx| this.new_thread(ix, window, cx)) | |
| 391 | - }) | |
| 494 | + .map(|ix| self.project_item(ix)) | |
| 392 | 495 | .collect() |
| 393 | 496 | } |
| 394 | 497 | |
| @@ -413,9 +516,7 @@ impl Workspace { | ||
| 413 | 516 | items.push(self.no_project_item()); |
| 414 | 517 | } |
| 415 | 518 | number += 1; |
| 416 | - let mut item = PaletteItem::new("Projects", self.projects[ix].name.clone(), IconName::Folder) | |
| 417 | - .description(self.project_description(ix)) | |
| 418 | - .run(move |this, window, cx| this.new_thread(ix, window, cx)); | |
| 519 | + let mut item = self.project_item(ix); | |
| 419 | 520 | if number <= 9 { |
| 420 | 521 | item = item.shortcut(format!("Ctrl+{number}")); |
| 421 | 522 | } |
| @@ -428,7 +529,7 @@ impl Workspace { | ||
| 428 | 529 | } |
| 429 | 530 | |
| 430 | 531 | fn no_project_item(&self) -> PaletteItem { |
| 431 | - PaletteItem::new("Projects", "No project", Lucide::MessageSquareDashed) | |
| 532 | + PaletteItem::new("Projects", "No project", Lucide::SquareDashed) | |
| 432 | 533 | .shortcut("Ctrl+Alt+N") |
| 433 | 534 | .run(|this, window, cx| this.new_thread_without_project(window, cx)) |
| 434 | 535 | } |
| @@ -469,6 +570,130 @@ impl Workspace { | ||
| 469 | 570 | .collect() |
| 470 | 571 | } |
| 471 | 572 | |
| 573 | + /// Where Add project starts: the devices, when there are others to | |
| 574 | + /// add one on, or this device's sources. | |
| 575 | + pub(super) fn add_project_view(&self) -> PaletteView { | |
| 576 | + if self.remote.environments.is_empty() { PaletteView::ProjectSources(None) } else { PaletteView::AddProject } | |
| 577 | + } | |
| 578 | + | |
| 579 | + fn palette_environments(&self) -> Vec<PaletteItem> { | |
| 580 | + let mut items = vec![ | |
| 581 | + PaletteItem::new("Environments", self.hostname.clone(), Lucide::Laptop) | |
| 582 | + .description("This device") | |
| 583 | + .submenu(PaletteView::ProjectSources(None)), | |
| 584 | + ]; | |
| 585 | + for environment in &self.remote.environments { | |
| 586 | + let label = environment.host.label(); | |
| 587 | + let item = PaletteItem::new("Environments", label.clone(), Lucide::Laptop); | |
| 588 | + items.push(match &environment.status { | |
| 589 | + ConnectionStatus::Connected(_) => item | |
| 590 | + .description(environment.host.address.clone()) | |
| 591 | + .submenu(PaletteView::ProjectSources(Some(environment.host.id.clone()))), | |
| 592 | + ConnectionStatus::Connecting => item.description("Connecting…").hint(format!("{label} isn't connected yet.")), | |
| 593 | + ConnectionStatus::Failed(_) | ConnectionStatus::Denied(_) => { | |
| 594 | + item.description("Not connected").hint(format!("{label} isn't connected.")) | |
| 595 | + } | |
| 596 | + }); | |
| 597 | + } | |
| 598 | + items | |
| 599 | + } | |
| 600 | + | |
| 601 | + fn palette_sources(&self, env: &Option<String>) -> Vec<PaletteItem> { | |
| 602 | + let browse = match env { | |
| 603 | + Some(env) => format!("Browse a folder on {}", self.environment_name(env)), | |
| 604 | + None => "Browse a folder on disk".into(), | |
| 605 | + }; | |
| 606 | + let mut items = vec![ | |
| 607 | + PaletteItem::new("Sources", "New project", Lucide::FolderPlus) | |
| 608 | + .description("Start a new Git repository from a name") | |
| 609 | + .terms(&["create", "init"]) | |
| 610 | + .submenu(PaletteView::NewProject(env.clone())), | |
| 611 | + PaletteItem::new("Sources", "Local folder", IconName::FolderOpen) | |
| 612 | + .description(browse) | |
| 613 | + .terms(&["open folder", "directory"]) | |
| 614 | + .submenu(PaletteView::Directories(env.clone())), | |
| 615 | + PaletteItem::new("Sources", "Git URL", Lucide::Link) | |
| 616 | + .description("Clone from a remote URL") | |
| 617 | + .terms(&["clone"]) | |
| 618 | + .submenu(PaletteView::CloneRepo(env.clone(), None)), | |
| 619 | + ]; | |
| 620 | + for forge in Forge::ALL { | |
| 621 | + let mut item = PaletteItem::new("Sources", forge.label(), IconName::Folder) | |
| 622 | + .description(forge.description()) | |
| 623 | + .terms(&["clone"]); | |
| 624 | + item.glyph = forge_glyph(forge); | |
| 625 | + // Another device checks its own tools when it clones. | |
| 626 | + item = if env.is_none() && !forge.ready() { | |
| 627 | + item.warning = Some("Setup Required".into()); | |
| 628 | + item.hint(forge.setup_hint()) | |
| 629 | + } else { | |
| 630 | + item.submenu(PaletteView::CloneRepo(env.clone(), Some(forge))) | |
| 631 | + }; | |
| 632 | + items.push(item); | |
| 633 | + } | |
| 634 | + items | |
| 635 | + } | |
| 636 | + | |
| 637 | + /// Where new projects and clones go, as rows describe it. | |
| 638 | + fn project_base(&self, env: &Option<String>) -> String { | |
| 639 | + match env { | |
| 640 | + Some(env) => format!("the base folder on {}", self.environment_name(env)), | |
| 641 | + None => { | |
| 642 | + let saved = settings::get(|settings| settings.add_project_dir.clone()); | |
| 643 | + let home = folder_browser::home_dir(); | |
| 644 | + folder_browser::tilde(&folder_browser::start_dir(Some(&saved)), &home) | |
| 645 | + } | |
| 646 | + } | |
| 647 | + } | |
| 648 | + | |
| 649 | + fn palette_new_project(&self, env: &Option<String>, query: &str) -> Vec<PaletteItem> { | |
| 650 | + let name = query.trim(); | |
| 651 | + let base = self.project_base(env); | |
| 652 | + let item = match projects::check_name(name) { | |
| 653 | + Some(problem) => PaletteItem::new("New project", problem, Lucide::FolderPlus).description(format!("It goes in {base}")), | |
| 654 | + None => { | |
| 655 | + let (env, source) = (env.clone(), ProjectSource::New { name: name.to_string() }); | |
| 656 | + let place = if env.is_some() { format!("In {base}") } else { format!("{base}/{name}") }; | |
| 657 | + PaletteItem::new("New project", format!("Create {name}"), Lucide::FolderPlus) | |
| 658 | + .description(format!("{place} · a new Git repository")) | |
| 659 | + .run(move |this, window, cx| this.add_project_from(env.clone(), source.clone(), window, cx)) | |
| 660 | + } | |
| 661 | + }; | |
| 662 | + vec![item] | |
| 663 | + } | |
| 664 | + | |
| 665 | + fn palette_clone(&self, env: &Option<String>, forge: Option<Forge>, query: &str) -> Vec<PaletteItem> { | |
| 666 | + let input = query.trim(); | |
| 667 | + let base = self.project_base(env); | |
| 668 | + let row = |title: String| { | |
| 669 | + let mut item = PaletteItem::new("Clone", title, Lucide::Link); | |
| 670 | + if let Some(forge) = forge { | |
| 671 | + item.glyph = forge_glyph(forge); | |
| 672 | + } | |
| 673 | + item | |
| 674 | + }; | |
| 675 | + let item = match projects::repo_name(input) { | |
| 676 | + _ if input.is_empty() => row(match forge { | |
| 677 | + Some(forge) => format!("Type {}", forge.placeholder()), | |
| 678 | + None => "Paste a Git URL".into(), | |
| 679 | + }) | |
| 680 | + .description(format!("It clones into {base}")), | |
| 681 | + None => row("That doesn't look like a repository.".into()), | |
| 682 | + Some(name) => { | |
| 683 | + let source = match forge { | |
| 684 | + Some(forge) => ProjectSource::Forge { forge, repo: input.to_string() }, | |
| 685 | + None => ProjectSource::Url { url: input.to_string() }, | |
| 686 | + }; | |
| 687 | + let env = env.clone(); | |
| 688 | + let place = if env.is_some() { format!("Into {base}") } else { format!("Into {base}/{name}") }; | |
| 689 | + row(format!("Clone {name}")) | |
| 690 | + .description(place) | |
| 691 | + .run(move |this, window, cx| this.add_project_from(env.clone(), source.clone(), window, cx)) | |
| 692 | + } | |
| 693 | + }; | |
| 694 | + vec![item] | |
| 695 | + } | |
| 696 | + | |
| 472 | 697 | /// Starts a thread without a project, in the scratch folder. |
| 473 | 698 | pub(super) fn new_thread_without_project(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 474 | 699 | let Some(path) = super::scratch_dir() else { return }; |
| @@ -477,6 +702,18 @@ impl Workspace { | ||
| 477 | 702 | } |
| 478 | 703 | } |
| 479 | 704 | |
| 705 | + /// A forge's logo in its color, bright enough to read on dark menus. | |
| 706 | + fn forge_glyph(forge: Forge) -> Glyph { | |
| 707 | + let (path, color) = match forge { | |
| 708 | + Forge::GitHub => return Glyph::Icon(Icon::new(Lucide::Github)), | |
| 709 | + Forge::GitLab => (icons::GITLAB, 0xfc6d26), | |
| 710 | + Forge::AzureDevOps => (icons::AZURE_DEVOPS, 0x2f8fe8), | |
| 711 | + Forge::Bitbucket => (icons::BITBUCKET, 0x2684ff), | |
| 712 | + Forge::Forgejo => (icons::FORGEJO, 0xfb923c), | |
| 713 | + }; | |
| 714 | + Glyph::Brand(Icon::default().path(path), rgb(color).into()) | |
| 715 | + } | |
| 716 | + | |
| 480 | 717 | fn mode_label(mode: ThemeMode) -> &'static str { |
| 481 | 718 | match mode { |
| 482 | 719 | ThemeMode::System => "System", |
| @@ -491,6 +728,11 @@ pub(super) struct CommandPalette { | ||
| 491 | 728 | input: Entity<InputState>, |
| 492 | 729 | stack: Vec<PaletteView>, |
| 493 | 730 | highlight: usize, |
| 731 | + /// In Directories, whether the arrows picked a folder; until they do, | |
| 732 | + /// Enter adds the folder in the field. | |
| 733 | + browsing: bool, | |
| 734 | + listing: FolderListing, | |
| 735 | + home: PathBuf, | |
| 494 | 736 | scroll: ScrollHandle, |
| 495 | 737 | _subscription: Subscription, |
| 496 | 738 | } |
| @@ -503,37 +745,111 @@ impl CommandPalette { | ||
| 503 | 745 | let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event { |
| 504 | 746 | InputEvent::Change => { |
| 505 | 747 | this.highlight = 0; |
| 748 | + this.browsing = false; | |
| 749 | + this.sync_listing(cx); | |
| 506 | 750 | cx.notify(); |
| 507 | 751 | } |
| 508 | 752 | InputEvent::PressEnter { .. } => this.confirm(window, cx), |
| 509 | 753 | _ => {} |
| 510 | 754 | }); |
| 511 | - let mut stack = vec![PaletteView::Root]; | |
| 755 | + let mut palette = Self { | |
| 756 | + workspace, | |
| 757 | + input, | |
| 758 | + stack: vec![PaletteView::Root], | |
| 759 | + highlight: 0, | |
| 760 | + browsing: false, | |
| 761 | + listing: FolderListing::default(), | |
| 762 | + home: folder_browser::home_dir(), | |
| 763 | + scroll: ScrollHandle::new(), | |
| 764 | + _subscription: subscription, | |
| 765 | + }; | |
| 512 | 766 | if view != PaletteView::Root { |
| 513 | - stack.push(view); | |
| 767 | + palette.stack.push(view); | |
| 514 | 768 | } |
| 515 | - let palette = Self { workspace, input, stack, highlight: 0, scroll: ScrollHandle::new(), _subscription: subscription }; | |
| 516 | 769 | palette.sync_placeholder(window, cx); |
| 517 | 770 | palette |
| 518 | 771 | } |
| 519 | 772 | |
| 520 | 773 | fn view(&self) -> PaletteView { |
| 521 | - *self.stack.last().unwrap_or(&PaletteView::Root) | |
| 774 | + self.stack.last().cloned().unwrap_or(PaletteView::Root) | |
| 522 | 775 | } |
| 523 | 776 | |
| 524 | 777 | fn sync_placeholder(&self, window: &mut Window, cx: &mut Context<Self>) { |
| 525 | - let placeholder = if self.view() == PaletteView::Root { "Search commands, projects, and threads..." } else { "Search..." }; | |
| 778 | + let placeholder = self.view().placeholder(); | |
| 526 | 779 | self.input.update(cx, |input, cx| input.set_placeholder(placeholder, window, cx)); |
| 527 | 780 | } |
| 528 | 781 | |
| 782 | + /// Opens a submenu, with the field emptied, or set to the folder to | |
| 783 | + /// start browsing in. | |
| 784 | + fn push_view(&mut self, view: PaletteView, window: &mut Window, cx: &mut Context<Self>) { | |
| 785 | + let text = match &view { | |
| 786 | + PaletteView::Directories(None) => { | |
| 787 | + let saved = settings::get(|settings| settings.add_project_dir.clone()); | |
| 788 | + folder_browser::field_text(&folder_browser::start_dir(Some(&saved)), &self.home) | |
| 789 | + } | |
| 790 | + PaletteView::Directories(Some(_)) => "~/".into(), | |
| 791 | + _ => String::new(), | |
| 792 | + }; | |
| 793 | + self.stack.push(view); | |
| 794 | + self.listing = FolderListing::default(); | |
| 795 | + self.show_view(text, window, cx); | |
| 796 | + } | |
| 797 | + | |
| 798 | + fn pop_view(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 799 | + if self.stack.len() > 1 { | |
| 800 | + self.stack.pop(); | |
| 801 | + self.show_view(String::new(), window, cx); | |
| 802 | + } | |
| 803 | + } | |
| 804 | + | |
| 805 | + fn show_view(&mut self, text: String, window: &mut Window, cx: &mut Context<Self>) { | |
| 806 | + self.highlight = 0; | |
| 807 | + self.browsing = false; | |
| 808 | + self.input.update(cx, |input, cx| input.set_value(text, window, cx)); | |
| 809 | + self.sync_placeholder(window, cx); | |
| 810 | + self.sync_listing(cx); | |
| 811 | + cx.notify(); | |
| 812 | + } | |
| 813 | + | |
| 814 | + /// In Directories, lists the folder the field points at: here right | |
| 815 | + /// away, or by asking the other device. | |
| 816 | + fn sync_listing(&mut self, cx: &mut Context<Self>) { | |
| 817 | + let PaletteView::Directories(env) = self.view() else { return }; | |
| 818 | + let text = self.input.read(cx).value().to_string(); | |
| 819 | + let Some(dir) = self.listing.set_text(&text) else { return }; | |
| 820 | + match env { | |
| 821 | + None => { | |
| 822 | + let folders = folder_browser::list(&dir, &self.home); | |
| 823 | + self.listing.receive(&dir, folders); | |
| 824 | + } | |
| 825 | + Some(env) => { | |
| 826 | + let _ = self.workspace.update(cx, |workspace, _| workspace.request_folders(&env, dir)); | |
| 827 | + } | |
| 828 | + } | |
| 829 | + } | |
| 830 | + | |
| 831 | + /// Another device answered a listing. | |
| 832 | + pub(super) fn receive_folders(&mut self, env: &str, dir: &str, folders: Folders, cx: &mut Context<Self>) { | |
| 833 | + if self.view() == PaletteView::Directories(Some(env.to_string())) { | |
| 834 | + self.listing.receive(dir, folders); | |
| 835 | + cx.notify(); | |
| 836 | + } | |
| 837 | + } | |
| 838 | + | |
| 529 | 839 | /// The rows that match the query, in their groups, best first. |
| 530 | 840 | fn results(&self, cx: &App) -> Vec<PaletteItem> { |
| 531 | 841 | let Some(workspace) = self.workspace.upgrade() else { return Vec::new() }; |
| 842 | + let view = self.view(); | |
| 532 | 843 | let raw = self.input.read(cx).value().to_string(); |
| 533 | - let actions_only = self.view() == PaletteView::Root && raw.trim_start().starts_with('>'); | |
| 844 | + match view.field() { | |
| 845 | + Field::Path => return self.directory_items(), | |
| 846 | + Field::Text => return workspace.read(cx).palette_items(&view, &raw, cx), | |
| 847 | + Field::Search => {} | |
| 848 | + } | |
| 849 | + let actions_only = view == PaletteView::Root && raw.trim_start().starts_with('>'); | |
| 534 | 850 | let query = raw.trim_start().trim_start_matches('>').trim().to_lowercase(); |
| 535 | 851 | let words: Vec<String> = query.split_whitespace().map(str::to_string).collect(); |
| 536 | - let items = workspace.read(cx).palette_items(self.view(), !words.is_empty(), cx); | |
| 852 | + let items = workspace.read(cx).palette_items(&view, &query, cx); | |
| 537 | 853 | let items: Vec<PaletteItem> = items.into_iter().filter(|item| !actions_only || item.group == "Actions").collect(); |
| 538 | 854 | if words.is_empty() { |
| 539 | 855 | return items; |
| @@ -559,12 +875,31 @@ impl CommandPalette { | ||
| 559 | 875 | scored.into_iter().map(|(_, _, _, item)| item).collect() |
| 560 | 876 | } |
| 561 | 877 | |
| 878 | + /// The folders in the path field's folder that match it. | |
| 879 | + fn directory_items(&self) -> Vec<PaletteItem> { | |
| 880 | + self.listing | |
| 881 | + .matches() | |
| 882 | + .map(|folder| { | |
| 883 | + let mut item = PaletteItem::new("Directories", folder.name.clone(), IconName::Folder); | |
| 884 | + item.tag = folder.git.then(|| "git".into()); | |
| 885 | + item.action = Action::Browse(self.listing.open(&folder.name)); | |
| 886 | + item | |
| 887 | + }) | |
| 888 | + .collect() | |
| 889 | + } | |
| 890 | + | |
| 562 | 891 | fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) { |
| 563 | 892 | let results = self.results(cx); |
| 564 | 893 | if results.is_empty() { |
| 565 | 894 | return; |
| 566 | 895 | } |
| 567 | - self.highlight = (self.highlight as isize + delta).rem_euclid(results.len() as isize) as usize; | |
| 896 | + if self.view().field() == Field::Path && !self.browsing { | |
| 897 | + // The first press lands on the first or last folder. | |
| 898 | + self.browsing = true; | |
| 899 | + self.highlight = if delta > 0 { 0 } else { results.len() - 1 }; | |
| 900 | + } else { | |
| 901 | + self.highlight = (self.highlight as isize + delta).rem_euclid(results.len() as isize) as usize; | |
| 902 | + } | |
| 568 | 903 | // The list's children include a heading per group. |
| 569 | 904 | let headings = results[..=self.highlight] |
| 570 | 905 | .iter() |
| @@ -575,34 +910,62 @@ impl CommandPalette { | ||
| 575 | 910 | cx.notify(); |
| 576 | 911 | } |
| 577 | 912 | |
| 913 | + /// Enter. In Directories it adds the folder in the field, unless the | |
| 914 | + /// arrows picked one to go into, or the field only starts a name, which | |
| 915 | + /// it goes into the best match of. | |
| 578 | 916 | fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 917 | + if self.view().field() == Field::Path && !self.browsing && self.listing.names_folder() { | |
| 918 | + self.add_folder(window, cx); | |
| 919 | + return; | |
| 920 | + } | |
| 579 | 921 | let results = self.results(cx); |
| 580 | 922 | if let Some(item) = results.get(self.highlight).cloned() { |
| 581 | 923 | self.execute(item, window, cx); |
| 582 | 924 | } |
| 583 | 925 | } |
| 584 | 926 | |
| 927 | + /// Adds the folder the path field names as a project. | |
| 928 | + fn add_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 929 | + let PaletteView::Directories(env) = self.view() else { return }; | |
| 930 | + let path = self.input.read(cx).value().trim().to_string(); | |
| 931 | + cx.emit(PaletteEvent::Dismiss); | |
| 932 | + let _ = self.workspace.update(cx, |this, cx| this.add_project_from(env, ProjectSource::Folder { path }, window, cx)); | |
| 933 | + } | |
| 934 | + | |
| 935 | + /// Tab in Directories completes the folder name. | |
| 936 | + fn complete(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 937 | + let Some(text) = self.listing.complete() else { return }; | |
| 938 | + self.input.update(cx, |input, cx| input.set_value(text, window, cx)); | |
| 939 | + self.sync_listing(cx); | |
| 940 | + cx.notify(); | |
| 941 | + } | |
| 942 | + | |
| 585 | 943 | fn execute(&mut self, item: PaletteItem, window: &mut Window, cx: &mut Context<Self>) { |
| 586 | - if let Some(view) = item.submenu { | |
| 587 | - self.stack.push(view); | |
| 588 | - self.highlight = 0; | |
| 589 | - self.input.update(cx, |input, cx| input.set_value("", window, cx)); | |
| 590 | - self.sync_placeholder(window, cx); | |
| 591 | - cx.notify(); | |
| 592 | - return; | |
| 944 | + match item.action { | |
| 945 | + Action::None => {} | |
| 946 | + Action::Submenu(view) => self.push_view(view, window, cx), | |
| 947 | + Action::Browse(text) => { | |
| 948 | + self.input.update(cx, |input, cx| { | |
| 949 | + input.set_value(text, window, cx); | |
| 950 | + input.focus(window, cx); | |
| 951 | + }); | |
| 952 | + self.highlight = 0; | |
| 953 | + self.browsing = false; | |
| 954 | + self.sync_listing(cx); | |
| 955 | + cx.notify(); | |
| 956 | + } | |
| 957 | + Action::Hint(hint) => window.push_notification(Notification::info(hint).title(item.title), cx), | |
| 958 | + Action::Run(run) => { | |
| 959 | + cx.emit(PaletteEvent::Dismiss); | |
| 960 | + let _ = self.workspace.update(cx, |this, cx| run(this, window, cx)); | |
| 961 | + } | |
| 593 | 962 | } |
| 594 | - let Some(run) = item.run.clone() else { return }; | |
| 595 | - cx.emit(PaletteEvent::Dismiss); | |
| 596 | - let _ = self.workspace.update(cx, |this, cx| run(this, window, cx)); | |
| 597 | 963 | } |
| 598 | 964 | |
| 599 | 965 | /// Backspace on an empty query leaves a submenu. |
| 600 | 966 | fn back(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 601 | 967 | if self.input.read(cx).value().is_empty() && self.stack.len() > 1 { |
| 602 | - self.stack.pop(); | |
| 603 | - self.highlight = 0; | |
| 604 | - self.sync_placeholder(window, cx); | |
| 605 | - cx.notify(); | |
| 968 | + self.pop_view(window, cx); | |
| 606 | 969 | } else { |
| 607 | 970 | cx.propagate(); |
| 608 | 971 | } |
| @@ -622,15 +985,90 @@ impl CommandPalette { | ||
| 622 | 985 | self.execute(item, window, cx); |
| 623 | 986 | } |
| 624 | 987 | } |
| 988 | + | |
| 989 | + fn render_row(&self, ix: usize, item: &PaletteItem, selected: bool, cx: &mut Context<Self>) -> AnyElement { | |
| 990 | + let p = metal::palette(cx); | |
| 991 | + let run_item = item.clone(); | |
| 992 | + let glyph = match item.glyph.clone() { | |
| 993 | + Glyph::Icon(icon) => icon.size(px(16.)).text_color(p.icon).into_any_element(), | |
| 994 | + Glyph::Brand(icon, color) => icon.size(px(16.)).text_color(color).into_any_element(), | |
| 995 | + Glyph::Badge(initials, tint) => metal::badge(initials, tint, p).into_any_element(), | |
| 996 | + }; | |
| 997 | + let inert = matches!(item.action, Action::None); | |
| 998 | + h_flex() | |
| 999 | + .id(("palette-row", ix)) | |
| 1000 | + .flex_none() | |
| 1001 | + .min_h(px(36.)) | |
| 1002 | + .px(px(10.)) | |
| 1003 | + .py(px(6.)) | |
| 1004 | + .gap(px(10.)) | |
| 1005 | + .rounded(px(10.)) | |
| 1006 | + .when(!inert, |this| this.cursor_pointer().hover(|style| style.bg(p.menu_row_hover))) | |
| 1007 | + .when(selected && !inert, |this| this.bg(p.menu_row_hover)) | |
| 1008 | + .on_click(cx.listener(move |this, _, window, cx| this.execute(run_item.clone(), window, cx))) | |
| 1009 | + .child(div().flex().flex_none().w(px(20.)).justify_center().child(glyph)) | |
| 1010 | + .child( | |
| 1011 | + v_flex() | |
| 1012 | + .flex_1() | |
| 1013 | + .min_w_0() | |
| 1014 | + .child( | |
| 1015 | + h_flex() | |
| 1016 | + .min_w_0() | |
| 1017 | + .text_size(px(13.5)) | |
| 1018 | + .text_color(if inert { p.ink2 } else { p.ink }) | |
| 1019 | + .child(div().flex_none().whitespace_nowrap().child(item.title.clone())) | |
| 1020 | + .when_some(item.emphasis.clone(), |this, emphasis| { | |
| 1021 | + this.child(div().min_w_0().truncate().font_weight(FontWeight::SEMIBOLD).child(emphasis)) | |
| 1022 | + }), | |
| 1023 | + ) | |
| 1024 | + .when_some(item.description.clone(), |this, description| { | |
| 1025 | + this.child( | |
| 1026 | + h_flex() | |
| 1027 | + .min_w_0() | |
| 1028 | + .gap(px(4.)) | |
| 1029 | + .text_size(px(12.)) | |
| 1030 | + .text_color(p.ink3) | |
| 1031 | + .when_some(item.description_icon.clone(), |this, icon| { | |
| 1032 | + this.child(icon.size(px(12.)).text_color(p.ink3)) | |
| 1033 | + }) | |
| 1034 | + .child(div().min_w_0().truncate().child(description)), | |
| 1035 | + ) | |
| 1036 | + }), | |
| 1037 | + ) | |
| 1038 | + .when_some(item.tag.clone(), |this, tag| this.child(metal::tag(tag, p))) | |
| 1039 | + .when_some(item.warning.clone(), |this, warning| { | |
| 1040 | + this.child( | |
| 1041 | + div() | |
| 1042 | + .flex_none() | |
| 1043 | + .px(px(6.)) | |
| 1044 | + .py(px(1.)) | |
| 1045 | + .rounded(px(5.)) | |
| 1046 | + .bg(p.amber.opacity(0.14)) | |
| 1047 | + .text_size(px(11.)) | |
| 1048 | + .text_color(p.amber_ink) | |
| 1049 | + .child(warning), | |
| 1050 | + ) | |
| 1051 | + }) | |
| 1052 | + .when_some(item.timestamp.clone(), |this, timestamp| { | |
| 1053 | + this.child(div().flex_none().min_w(px(48.)).text_right().text_size(px(12.)).text_color(p.ink3).child(timestamp)) | |
| 1054 | + }) | |
| 1055 | + .when_some(item.shortcut.clone(), |this, shortcut| this.child(metal::keycap(shortcut, p))) | |
| 1056 | + .when(matches!(item.action, Action::Submenu(_)), |this| { | |
| 1057 | + this.child(Icon::new(IconName::ChevronRight).size(px(16.)).text_color(p.ink3)) | |
| 1058 | + }) | |
| 1059 | + .into_any_element() | |
| 1060 | + } | |
| 625 | 1061 | } |
| 626 | 1062 | |
| 627 | 1063 | impl Render for CommandPalette { |
| 628 | 1064 | fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { |
| 629 | 1065 | let p = metal::palette(cx); |
| 1066 | + let view = self.view(); | |
| 1067 | + let field = view.field(); | |
| 630 | 1068 | let results = self.results(cx); |
| 631 | 1069 | let highlight = self.highlight.min(results.len().saturating_sub(1)); |
| 632 | 1070 | let in_submenu = self.stack.len() > 1; |
| 633 | - let actions_only = self.input.read(cx).value().trim_start().starts_with('>'); | |
| 1071 | + let actions_only = field == Field::Search && self.input.read(cx).value().trim_start().starts_with('>'); | |
| 634 | 1072 | let mut rows: Vec<AnyElement> = Vec::new(); |
| 635 | 1073 | let mut last_group = None; |
| 636 | 1074 | for (ix, item) in results.iter().enumerate() { |
| @@ -647,53 +1085,17 @@ impl Render for CommandPalette { | ||
| 647 | 1085 | .into_any_element(), |
| 648 | 1086 | ); |
| 649 | 1087 | } |
| 650 | - let selected = ix == highlight; | |
| 651 | - let item = item.clone(); | |
| 652 | - let run_item = item.clone(); | |
| 653 | - rows.push( | |
| 654 | - h_flex() | |
| 655 | - .id(("palette-row", ix)) | |
| 656 | - .flex_none() | |
| 657 | - .min_h(px(36.)) | |
| 658 | - .px(px(10.)) | |
| 659 | - .py(px(6.)) | |
| 660 | - .gap(px(10.)) | |
| 661 | - .rounded(px(10.)) | |
| 662 | - .cursor_pointer() | |
| 663 | - .when(selected, |this| this.bg(p.menu_row_hover)) | |
| 664 | - .hover(|style| style.bg(p.menu_row_hover)) | |
| 665 | - .on_click(cx.listener(move |this, _, window, cx| this.execute(run_item.clone(), window, cx))) | |
| 666 | - .child(item.icon.clone().size(px(16.)).text_color(p.icon)) | |
| 667 | - .child( | |
| 668 | - v_flex() | |
| 669 | - .flex_1() | |
| 670 | - .min_w_0() | |
| 671 | - .child( | |
| 672 | - h_flex() | |
| 673 | - .min_w_0() | |
| 674 | - .text_size(px(13.5)) | |
| 675 | - .text_color(p.ink) | |
| 676 | - .child(div().flex_none().whitespace_nowrap().child(item.title.clone())) | |
| 677 | - .when_some(item.emphasis.clone(), |this, emphasis| { | |
| 678 | - this.child(div().min_w_0().truncate().font_weight(FontWeight::SEMIBOLD).child(emphasis)) | |
| 679 | - }), | |
| 680 | - ) | |
| 681 | - .when_some(item.description.clone(), |this, description| { | |
| 682 | - this.child(div().truncate().text_size(px(12.)).text_color(p.ink3).child(description)) | |
| 683 | - }), | |
| 684 | - ) | |
| 685 | - .when(item.current, |this| this.child(metal::tag("Current", p))) | |
| 686 | - .when_some(item.timestamp.clone(), |this, timestamp| { | |
| 687 | - this.child(div().flex_none().min_w(px(48.)).text_right().text_size(px(12.)).text_color(p.ink3).child(timestamp)) | |
| 688 | - }) | |
| 689 | - .when_some(item.shortcut.clone(), |this, shortcut| this.child(metal::keycap(shortcut, p))) | |
| 690 | - .when(item.submenu.is_some(), |this| { | |
| 691 | - this.child(Icon::new(IconName::ChevronRight).size(px(16.)).text_color(p.ink3)) | |
| 692 | - }) | |
| 693 | - .into_any_element(), | |
| 694 | - ); | |
| 1088 | + let selected = ix == highlight && (field != Field::Path || self.browsing); | |
| 1089 | + rows.push(self.render_row(ix, item, selected, cx)); | |
| 695 | 1090 | } |
| 696 | - let empty = results.is_empty(); | |
| 1091 | + let empty = match field { | |
| 1092 | + _ if !results.is_empty() => None, | |
| 1093 | + Field::Path => self.listing.message(), | |
| 1094 | + _ if actions_only => Some("No matching actions.".into()), | |
| 1095 | + _ if view == PaletteView::Root => Some("No matching commands, projects, or threads.".into()), | |
| 1096 | + _ => Some("No matches.".into()), | |
| 1097 | + }; | |
| 1098 | + let can_add = field == Field::Path && self.listing.names_folder(); | |
| 697 | 1099 | let hint = |keys: Vec<AnyElement>, label: &'static str| { |
| 698 | 1100 | h_flex().gap(px(4.)).children(keys).child(div().text_size(px(12.)).text_color(p.ink3).child(label)) |
| 699 | 1101 | }; |
| @@ -703,6 +1105,12 @@ impl Render for CommandPalette { | ||
| 703 | 1105 | .on_action(cx.listener(|this, _: &SelectNext, _, cx| this.move_highlight(1, cx))) |
| 704 | 1106 | .on_action(cx.listener(|_, _: &Dismiss, _, cx| cx.emit(PaletteEvent::Dismiss))) |
| 705 | 1107 | .on_action(cx.listener(|this, _: &PaletteBack, window, cx| this.back(window, cx))) |
| 1108 | + .when(field == Field::Path, |this| { | |
| 1109 | + this.capture_action(cx.listener(|this, _: &IndentInline, window, cx| { | |
| 1110 | + cx.stop_propagation(); | |
| 1111 | + this.complete(window, cx); | |
| 1112 | + })) | |
| 1113 | + }) | |
| 706 | 1114 | .on_key_down(cx.listener(Self::on_key_down)) |
| 707 | 1115 | .w(px(600.)) |
| 708 | 1116 | .max_w_full() |
| @@ -719,18 +1127,29 @@ impl Render for CommandPalette { | ||
| 719 | 1127 | div() |
| 720 | 1128 | .id("palette-back") |
| 721 | 1129 | .cursor_pointer() |
| 722 | - .on_click(cx.listener(|this, _, window, cx| { | |
| 723 | - this.stack.pop(); | |
| 724 | - this.highlight = 0; | |
| 725 | - this.sync_placeholder(window, cx); | |
| 726 | - cx.notify(); | |
| 727 | - })) | |
| 1130 | + .on_click(cx.listener(|this, _, window, cx| this.pop_view(window, cx))) | |
| 728 | 1131 | .child(Icon::new(IconName::ArrowLeft).size(px(16.)).text_color(p.ink2)) |
| 729 | 1132 | .into_any_element() |
| 730 | 1133 | } else { |
| 731 | 1134 | Icon::new(IconName::Search).size(px(16.)).text_color(p.field_ink).into_any_element() |
| 732 | 1135 | }) |
| 733 | - .child(div().flex_1().text_size(px(14.)).child(Input::new(&self.input).appearance(false))), | |
| 1136 | + .child(div().flex_1().min_w_0().text_size(px(14.)).child(Input::new(&self.input).appearance(false))) | |
| 1137 | + .when(field == Field::Path, |this| { | |
| 1138 | + this.child( | |
| 1139 | + h_flex() | |
| 1140 | + .flex_none() | |
| 1141 | + .gap(px(6.)) | |
| 1142 | + .child( | |
| 1143 | + MetalButton::new("palette-add-folder") | |
| 1144 | + .mini() | |
| 1145 | + .label("Add") | |
| 1146 | + .tooltip("Add the folder in the field as a project") | |
| 1147 | + .disabled(!can_add) | |
| 1148 | + .on_click(cx.listener(|this, _, window, cx| this.add_folder(window, cx))), | |
| 1149 | + ) | |
| 1150 | + .child(metal::keycap("Enter", p)), | |
| 1151 | + ) | |
| 1152 | + }), | |
| 734 | 1153 | ) |
| 735 | 1154 | .child(metal::rule(p)) |
| 736 | 1155 | .child( |
| @@ -740,14 +1159,9 @@ impl Render for CommandPalette { | ||
| 740 | 1159 | .max_h(px(448.)) |
| 741 | 1160 | .overflow_y_scroll() |
| 742 | 1161 | .p(px(6.)) |
| 743 | - .when(empty, |this| { | |
| 744 | - this.child( | |
| 745 | - div().py(px(40.)).flex().justify_center().text_size(px(13.)).text_color(p.ink3).child(if actions_only { | |
| 746 | - "No matching actions." | |
| 747 | - } else { | |
| 748 | - "No matching commands, projects, or threads." | |
| 749 | - }), | |
| 750 | - ) | |
| 1162 | + .when(field == Field::Path, |this| this.h(px(360.))) | |
| 1163 | + .when_some(empty, |this, message| { | |
| 1164 | + this.child(div().py(px(40.)).flex().justify_center().text_size(px(13.)).text_color(p.ink3).child(message)) | |
| 751 | 1165 | }) |
| 752 | 1166 | .children(rows), |
| 753 | 1167 | ) |
| @@ -764,7 +1178,10 @@ impl Render for CommandPalette { | ||
| 764 | 1178 | ], |
| 765 | 1179 | "Navigate", |
| 766 | 1180 | )) |
| 767 | - .child(hint(vec![metal::keycap("Enter", p).into_any_element()], "Select")) | |
| 1181 | + .map(|this| match field { | |
| 1182 | + Field::Path => this.child(hint(vec![metal::keycap("Tab", p).into_any_element()], "Complete")), | |
| 1183 | + _ => this.child(hint(vec![metal::keycap("Enter", p).into_any_element()], "Select")), | |
| 1184 | + }) | |
| 768 | 1185 | .when(in_submenu, |this| this.child(hint(vec![metal::keycap("Backspace", p).into_any_element()], "Back"))) |
| 769 | 1186 | .child(hint(vec![metal::keycap("Esc", p).into_any_element()], "Close")), |
| 770 | 1187 | ) |
| @@ -25,7 +25,9 @@ use crate::remote::{ | ||
| 25 | 25 | use crate::session::{ |
| 26 | 26 | NewSession, PermissionDecision, Reasoning, RemoteLink, Session, from_millis, store_uploads, |
| 27 | 27 | }; |
| 28 | + use crate::projects; | |
| 28 | 29 | use crate::settings::{self, RemoteHost, Settings, SettingsPatch, WorkspaceMode}; |
| 30 | + use crate::ui::folder_browser; | |
| 29 | 31 | use crate::ui::metal::MetalButton; |
| 30 | 32 | |
| 31 | 33 | /// How often changed threads are sent to subscribed clients. |
| @@ -490,6 +492,38 @@ impl Workspace { | ||
| 490 | 492 | // Tells the open conversations, and every client, this one too. |
| 491 | 493 | self.settings_changed(window, cx); |
| 492 | 494 | } |
| 495 | + ClientMessage::ListFolders { path } => { | |
| 496 | + let home = folder_browser::home_dir(); | |
| 497 | + let dir = path.clone(); | |
| 498 | + let listing = cx.background_executor().spawn(async move { folder_browser::list(&dir, &home) }); | |
| 499 | + cx.spawn(async move |this, cx| { | |
| 500 | + let (folders, truncated, error) = match listing.await { | |
| 501 | + Ok((folders, truncated)) => (folders, truncated, None), | |
| 502 | + Err(error) => (Vec::new(), false, Some(error)), | |
| 503 | + }; | |
| 504 | + let _ = this.update(cx, |this, _| this.reply(conn, ServerMessage::Folders { path, folders, truncated, error })); | |
| 505 | + }) | |
| 506 | + .detach(); | |
| 507 | + } | |
| 508 | + ClientMessage::AddProject { source } => { | |
| 509 | + let saved = settings::get(|settings| settings.add_project_dir.clone()); | |
| 510 | + let base = folder_browser::start_dir(Some(&saved)); | |
| 511 | + let home = folder_browser::home_dir(); | |
| 512 | + let added = cx.background_executor().spawn(async move { projects::resolve(&source, &base, &home) }); | |
| 513 | + cx.spawn(async move |this, cx| { | |
| 514 | + let result = added.await; | |
| 515 | + let _ = this.update(cx, |this, cx| match result { | |
| 516 | + Ok(path) => { | |
| 517 | + this.ensure_project(path.clone(), None); | |
| 518 | + this.remote.state_dirty = true; | |
| 519 | + cx.notify(); | |
| 520 | + this.reply(conn, ServerMessage::ProjectAdded { path }); | |
| 521 | + } | |
| 522 | + Err(message) => this.reply(conn, ServerMessage::Error { message }), | |
| 523 | + }); | |
| 524 | + }) | |
| 525 | + .detach(); | |
| 526 | + } | |
| 493 | 527 | } |
| 494 | 528 | } |
| 495 | 529 | |
| @@ -655,10 +689,27 @@ impl Workspace { | ||
| 655 | 689 | } |
| 656 | 690 | cx.notify(); |
| 657 | 691 | } |
| 692 | + ClientEvent::Message(ServerMessage::Folders { path, folders, truncated, error }) => { | |
| 693 | + if let Some((palette, _)) = &self.palette { | |
| 694 | + let folders = error.map_or(Ok((folders, truncated)), Err); | |
| 695 | + palette.update(cx, |palette, cx| palette.receive_folders(env, &path, folders, cx)); | |
| 696 | + } | |
| 697 | + } | |
| 698 | + ClientEvent::Message(ServerMessage::ProjectAdded { path }) => { | |
| 699 | + let ix = self.ensure_remote_project(env, path, None); | |
| 700 | + self.new_thread(ix, window, cx); | |
| 701 | + } | |
| 658 | 702 | ClientEvent::Message(ServerMessage::Welcome { .. } | ServerMessage::Denied { .. }) => {} |
| 659 | 703 | } |
| 660 | 704 | } |
| 661 | 705 | |
| 706 | + /// Asks another device for a folder's listing, for the Add project palette. | |
| 707 | + pub(super) fn request_folders(&self, env: &str, path: String) { | |
| 708 | + if let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) { | |
| 709 | + environment.client.send(&ClientMessage::ListFolders { path }); | |
| 710 | + } | |
| 711 | + } | |
| 712 | + | |
| 662 | 713 | fn environment_sessions(&self, env: &str) -> Vec<Entity<Session>> { |
| 663 | 714 | self.projects |
| 664 | 715 | .iter() |