Vectrum
Sign In

Add project and New thread palettes like T3 Code's

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>
JjohannSocommitted 1 parent 01cfadacommitd0f7519

Showing 13 changed files with 1059 additions and 550 deletions.

+1−0assets/icons/brands/azuredevops.svgaddedView File
@@ -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>
+1−0assets/icons/brands/bitbucket.svgaddedView File
@@ -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>
+1−0assets/icons/brands/forgejo.svgaddedView File
@@ -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>
+1−0assets/icons/brands/gitlab.svgaddedView File
@@ -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>
+1−0src/main.rsView File
@@ -10,6 +10,7 @@ mod git;
1010 mod http;
1111 mod models;
1212 mod orchestrator;
13+ mod projects;
1314 mod remote;
1415 mod session;
1516 mod settings;
+256−0src/projects.rsaddedView File
@@ -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+ }
+19−1src/remote/mod.rsView File
@@ -10,7 +10,9 @@
1010 //! and receives each change as a [`ServerMessage::Thread`] carrying the
1111 //! transcript from the first item that changed. The host also shares its
1212 //! 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`].
1416
1517 pub mod client;
1618 pub mod server;
@@ -20,6 +22,8 @@ use std::path::PathBuf;
2022 use serde::{Deserialize, Serialize};
2123
2224 use crate::git::TurnChanges;
25+ use crate::projects::ProjectSource;
26+ use crate::ui::folder_browser::Folder;
2327 use crate::models::Provider;
2428 use crate::session::{QueuedMessage, SavedItem};
2529 use crate::settings::SettingsPatch;
@@ -173,6 +177,11 @@ pub enum ClientMessage {
173177 },
174178 /// Changes the host's environment settings or its projects' overrides.
175179 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 },
176185 }
177186
178187 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -189,6 +198,10 @@ pub enum ServerMessage {
189198 /// The host's environment settings and project overrides, sent on
190199 /// connecting and whenever they change. Older hosts don't send it.
191200 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 },
192205 }
193206
194207 /// A new random key: 24 bytes, hex.
@@ -280,6 +293,11 @@ mod tests {
280293 let message = ClientMessage::UpdateSettings { patch };
281294 let text = serde_json::to_string(&message).unwrap();
282295 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);
283301 let values = crate::settings::Settings::default().environment_values();
284302 let text = serde_json::to_string(&ServerMessage::Settings { values: values.clone() }).unwrap();
285303 match serde_json::from_str::<ServerMessage>(&text).unwrap() {
+132−373src/ui/folder_browser.rsView File
@@ -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.
44
55 use std::path::{Component, Path, PathBuf};
66
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};
168
179 /// The most folders one listing shows.
1810 const MAX_FOLDERS: usize = 2000;
1911 /// The most entries read from one folder, so a folder full of files stays quick.
2012 const MAX_SCANNED: usize = 20_000;
2113
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,
3117 /// It has a `.git`, so it's likely a project.
32- git: bool,
18+ pub git: bool,
3319 }
3420
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),
4029 }
4130
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,
5436 /// The partial name typed after the folder, which filters the listing.
5537 filter: String,
56- rows: Vec<Row>,
57- highlight: usize,
58- scroll: ScrollHandle,
59- _subscription: Subscription,
38+ matches: Vec<usize>,
6039 }
6140
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() }
6744 }
6845 }
6946
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)
10161 }
10262
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;
12667 }
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),
13871 };
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();
14873 }
14974
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+ };
16383 }
16484
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+ _ => &[],
18589 }
18690 }
18791
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])
19896 }
19997
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())
204101 }
205102
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))
208108 }
209109
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()))
263116 }
264117
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,
279129 }
280130 }
281131 }
282132
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());
402138 }
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+ })
403143 }
404144
405145 /// Where the Add project browser opens: the saved "Add project starts in"
@@ -413,13 +153,13 @@ pub fn start_dir(setting: Option<&str>) -> PathBuf {
413153 }
414154 }
415155
416- fn home_dir() -> PathBuf {
156+ pub fn home_dir() -> PathBuf {
417157 std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/"))
418158 }
419159
420160 /// Turns what's typed into an absolute path: `~` is `home`, a relative path
421161 /// 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 {
423163 let path = if text == "~" {
424164 home.to_path_buf()
425165 } else if let Some(rest) = text.strip_prefix("~/") {
@@ -441,7 +181,7 @@ fn expand(text: &str, home: &Path) -> PathBuf {
441181 }
442182
443183 /// 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 {
445185 match path.strip_prefix(home) {
446186 Ok(rest) if home.parent().is_some() && rest.as_os_str().is_empty() => "~".into(),
447187 Ok(rest) if home.parent().is_some() => format!("~/{}", rest.display()),
@@ -450,18 +190,18 @@ fn tilde(path: &Path, home: &Path) -> String {
450190 }
451191
452192 /// 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 {
454194 let text = tilde(dir, home);
455195 if text.ends_with('/') { text } else { format!("{text}/") }
456196 }
457197
458198 /// 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) {
461201 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()),
465205 }
466206 }
467207
@@ -552,7 +292,7 @@ fn read_folders(dir: &Path) -> std::io::Result<(Vec<Folder>, bool)> {
552292 #[cfg(test)]
553293 mod tests {
554294 // 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};
556296 use std::path::{Path, PathBuf};
557297
558298 const HOME: &str = "/home/ada";
@@ -592,12 +332,31 @@ mod tests {
592332
593333 #[::core::prelude::v1::test]
594334 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…"));
601360 }
602361
603362 #[::core::prelude::v1::test]
+20−1src/ui/icons.rsView File
@@ -26,6 +26,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
2626 FolderGit2,
2727 FolderPlus,
2828 Gauge,
29+ Github,
2930 HardDrive,
3031 GitBranch,
3132 GitCommitHorizontal,
@@ -56,6 +57,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
5657 Server,
5758 Settings2,
5859 ShieldCheck,
60+ SquareDashed,
5961 SquareSlash,
6062 SlidersHorizontal,
6163 Space,
@@ -118,11 +120,28 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
118120 SquareTerminal,
119121 ]);
120122
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.
122138 pub struct AppAssets;
123139
124140 impl AssetSource for AppAssets {
125141 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+ }
126145 if let Some(data) = ExtraIcons.load(path)? {
127146 return Ok(Some(data));
128147 }
+0−5src/ui/metal.rsView File
@@ -529,11 +529,6 @@ pub fn keycap(text: impl Into<SharedString>, p: &Palette) -> Div {
529529 .child(text.into())
530530 }
531531
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-
537532 /// A switch: a sunk track with a raised knob, lit green when on.
538533 pub fn toggle(
539534 id: impl Into<ElementId>,
+42−53src/ui/workspace.rsView File
@@ -33,7 +33,9 @@ use crate::settings::{self, BackgroundActivity, ProjectOrder, WorkspaceMode};
3333 use crate::store::{self, AppState, SavedProject};
3434 use crate::ui::browser::{BrowserPanel, BrowserPanelEvent};
3535 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;
3739 use crate::ui::icons::Lucide;
3840 use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
3941 use crate::ui::settings::{SettingsEvent, SettingsView};
@@ -272,7 +274,6 @@ pub struct Workspace {
272274 settings_view: Entity<SettingsView>,
273275 settings_open: bool,
274276 /// The Add project browser, while it's open.
275- folder_browser: Option<(Entity<FolderBrowser>, Subscription)>,
276277 activity: HashMap<EntityId, Activity>,
277278 /// The thread whose settle button waits for its confirming click.
278279 settle_armed: Option<(EntityId, Instant)>,
@@ -410,7 +411,6 @@ impl Workspace {
410411 hostname: hostname().into(),
411412 settings_view,
412413 settings_open: false,
413- folder_browser: None,
414414 activity: HashMap::new(),
415415 settle_armed: None,
416416 archive_armed: None,
@@ -1548,42 +1548,49 @@ impl Workspace {
15481548 cx.notify();
15491549 }
15501550
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.
15531553 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+ }
15541573 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),
15631580 }
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) });
15831586 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+ });
15871594 })
15881595 .detach();
15891596 }
@@ -2273,24 +2280,6 @@ impl Render for Workspace {
22732280 .with_priority(2),
22742281 )
22752282 })
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- })
22942283 }
22952284 }
22962285
+534−117src/ui/workspace/palette.rsView File
@@ -1,13 +1,16 @@
11 //! The command palette (Ctrl+K), as T3 Code has it: Actions and Recent
22 //! Threads at rest, plus Projects, Settings and Threads while searching,
33 //! 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.
57
8+ use std::path::PathBuf;
69 use std::rc::Rc;
710
811 use gpui_kit::component::{
912 Icon, IconName, WindowExt as _, h_flex,
10- input::{Input, InputEvent, InputState},
13+ input::{IndentInline, Input, InputEvent, InputState},
1114 notification::Notification,
1215 v_flex,
1316 };
@@ -16,21 +19,66 @@ use gpui_kit::*;
1619
1720 use super::actions::relative_time_long;
1821 use super::Workspace;
22+ use crate::projects::{self, Forge, ProjectSource};
23+ use crate::remote::client::ConnectionStatus;
1924 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 _};
2228 use crate::ui::themes;
2329 use crate::{Dismiss, PaletteBack, SelectNext, SelectPrevious};
2430
2531 const RECENT_THREADS: usize = 12;
2632
2733 /// Where the palette is: its root, or one of its submenus.
28- #[derive(Clone, Copy, Debug, PartialEq, Eq)]
34+ #[derive(Clone, Debug, PartialEq, Eq)]
2935 pub(crate) enum PaletteView {
3036 Root,
3137 NewThreadIn,
3238 Themes,
3339 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+ }
3482 }
3583
3684 pub(super) enum PaletteEvent {
@@ -39,6 +87,29 @@ pub(super) enum PaletteEvent {
3987
4088 type Run = Rc<dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>)>;
4189
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+
42113 /// One row of the palette.
43114 #[derive(Clone)]
44115 pub(super) struct PaletteItem {
@@ -47,14 +118,16 @@ pub(super) struct PaletteItem {
47118 /// Set in bold after the title: "New thread in **project**".
48119 emphasis: Option<SharedString>,
49120 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,
51124 shortcut: Option<SharedString>,
52125 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,
58131 /// What a search matches, most important first.
59132 terms: Vec<String>,
60133 }
@@ -68,22 +141,40 @@ impl PaletteItem {
68141 title,
69142 emphasis: None,
70143 description: None,
71- icon: icon.into(),
144+ description_icon: None,
145+ glyph: Glyph::Icon(icon.into()),
72146 shortcut: None,
73147 timestamp: None,
74- current: false,
75- submenu: None,
76- run: None,
148+ tag: None,
149+ warning: None,
150+ action: Action::None,
77151 }
78152 }
79153
80154 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));
82156 self
83157 }
84158
85159 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));
87178 self
88179 }
89180
@@ -216,8 +307,9 @@ impl Workspace {
216307 self.settings_changed(window, cx);
217308 }
218309
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();
221313 match view {
222314 PaletteView::Root => {
223315 let mut items = self.palette_actions(cx);
@@ -252,7 +344,7 @@ impl Workspace {
252344 themes::pick(&themes::all()[ix], window, cx);
253345 this.settings_changed(window, cx);
254346 });
255- item.current = current;
347+ item.tag = current.then(|| "Current".into());
256348 item
257349 })
258350 .collect()
@@ -266,11 +358,17 @@ impl Workspace {
266358 themes::set_mode(option, window, cx);
267359 this.settings_changed(window, cx);
268360 });
269- item.current = option == mode;
361+ item.tag = (option == mode).then(|| "Current".into());
270362 item
271363 })
272364 .collect()
273365 }
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),
274372 }
275373 }
276374
@@ -331,8 +429,8 @@ impl Workspace {
331429 }
332430 items.push(
333431 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()),
336434 );
337435 items.push(
338436 PaletteItem::new("Actions", "Change theme", IconName::Palette)
@@ -379,16 +477,21 @@ impl Workspace {
379477 format!("{machine} · {}", super::display_path(&project.path))
380478 }
381479
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+
382489 fn palette_projects(&self, cx: &App) -> Vec<PaletteItem> {
383490 let scratch = super::scratch_dir();
384491 self.ordered_projects(cx)
385492 .into_iter()
386493 .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))
392495 .collect()
393496 }
394497
@@ -413,9 +516,7 @@ impl Workspace {
413516 items.push(self.no_project_item());
414517 }
415518 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);
419520 if number <= 9 {
420521 item = item.shortcut(format!("Ctrl+{number}"));
421522 }
@@ -428,7 +529,7 @@ impl Workspace {
428529 }
429530
430531 fn no_project_item(&self) -> PaletteItem {
431- PaletteItem::new("Projects", "No project", Lucide::MessageSquareDashed)
532+ PaletteItem::new("Projects", "No project", Lucide::SquareDashed)
432533 .shortcut("Ctrl+Alt+N")
433534 .run(|this, window, cx| this.new_thread_without_project(window, cx))
434535 }
@@ -469,6 +570,130 @@ impl Workspace {
469570 .collect()
470571 }
471572
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+
472697 /// Starts a thread without a project, in the scratch folder.
473698 pub(super) fn new_thread_without_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
474699 let Some(path) = super::scratch_dir() else { return };
@@ -477,6 +702,18 @@ impl Workspace {
477702 }
478703 }
479704
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+
480717 fn mode_label(mode: ThemeMode) -> &'static str {
481718 match mode {
482719 ThemeMode::System => "System",
@@ -491,6 +728,11 @@ pub(super) struct CommandPalette {
491728 input: Entity<InputState>,
492729 stack: Vec<PaletteView>,
493730 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,
494736 scroll: ScrollHandle,
495737 _subscription: Subscription,
496738 }
@@ -503,37 +745,111 @@ impl CommandPalette {
503745 let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| match event {
504746 InputEvent::Change => {
505747 this.highlight = 0;
748+ this.browsing = false;
749+ this.sync_listing(cx);
506750 cx.notify();
507751 }
508752 InputEvent::PressEnter { .. } => this.confirm(window, cx),
509753 _ => {}
510754 });
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+ };
512766 if view != PaletteView::Root {
513- stack.push(view);
767+ palette.stack.push(view);
514768 }
515- let palette = Self { workspace, input, stack, highlight: 0, scroll: ScrollHandle::new(), _subscription: subscription };
516769 palette.sync_placeholder(window, cx);
517770 palette
518771 }
519772
520773 fn view(&self) -> PaletteView {
521- *self.stack.last().unwrap_or(&PaletteView::Root)
774+ self.stack.last().cloned().unwrap_or(PaletteView::Root)
522775 }
523776
524777 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();
526779 self.input.update(cx, |input, cx| input.set_placeholder(placeholder, window, cx));
527780 }
528781
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+
529839 /// The rows that match the query, in their groups, best first.
530840 fn results(&self, cx: &App) -> Vec<PaletteItem> {
531841 let Some(workspace) = self.workspace.upgrade() else { return Vec::new() };
842+ let view = self.view();
532843 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('>');
534850 let query = raw.trim_start().trim_start_matches('>').trim().to_lowercase();
535851 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);
537853 let items: Vec<PaletteItem> = items.into_iter().filter(|item| !actions_only || item.group == "Actions").collect();
538854 if words.is_empty() {
539855 return items;
@@ -559,12 +875,31 @@ impl CommandPalette {
559875 scored.into_iter().map(|(_, _, _, item)| item).collect()
560876 }
561877
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+
562891 fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
563892 let results = self.results(cx);
564893 if results.is_empty() {
565894 return;
566895 }
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+ }
568903 // The list's children include a heading per group.
569904 let headings = results[..=self.highlight]
570905 .iter()
@@ -575,34 +910,62 @@ impl CommandPalette {
575910 cx.notify();
576911 }
577912
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.
578916 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+ }
579921 let results = self.results(cx);
580922 if let Some(item) = results.get(self.highlight).cloned() {
581923 self.execute(item, window, cx);
582924 }
583925 }
584926
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+
585943 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+ }
593962 }
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));
597963 }
598964
599965 /// Backspace on an empty query leaves a submenu.
600966 fn back(&mut self, window: &mut Window, cx: &mut Context<Self>) {
601967 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);
606969 } else {
607970 cx.propagate();
608971 }
@@ -622,15 +985,90 @@ impl CommandPalette {
622985 self.execute(item, window, cx);
623986 }
624987 }
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+ }
6251061 }
6261062
6271063 impl Render for CommandPalette {
6281064 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
6291065 let p = metal::palette(cx);
1066+ let view = self.view();
1067+ let field = view.field();
6301068 let results = self.results(cx);
6311069 let highlight = self.highlight.min(results.len().saturating_sub(1));
6321070 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('>');
6341072 let mut rows: Vec<AnyElement> = Vec::new();
6351073 let mut last_group = None;
6361074 for (ix, item) in results.iter().enumerate() {
@@ -647,53 +1085,17 @@ impl Render for CommandPalette {
6471085 .into_any_element(),
6481086 );
6491087 }
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));
6951090 }
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();
6971099 let hint = |keys: Vec<AnyElement>, label: &'static str| {
6981100 h_flex().gap(px(4.)).children(keys).child(div().text_size(px(12.)).text_color(p.ink3).child(label))
6991101 };
@@ -703,6 +1105,12 @@ impl Render for CommandPalette {
7031105 .on_action(cx.listener(|this, _: &SelectNext, _, cx| this.move_highlight(1, cx)))
7041106 .on_action(cx.listener(|_, _: &Dismiss, _, cx| cx.emit(PaletteEvent::Dismiss)))
7051107 .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+ })
7061114 .on_key_down(cx.listener(Self::on_key_down))
7071115 .w(px(600.))
7081116 .max_w_full()
@@ -719,18 +1127,29 @@ impl Render for CommandPalette {
7191127 div()
7201128 .id("palette-back")
7211129 .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)))
7281131 .child(Icon::new(IconName::ArrowLeft).size(px(16.)).text_color(p.ink2))
7291132 .into_any_element()
7301133 } else {
7311134 Icon::new(IconName::Search).size(px(16.)).text_color(p.field_ink).into_any_element()
7321135 })
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+ }),
7341153 )
7351154 .child(metal::rule(p))
7361155 .child(
@@ -740,14 +1159,9 @@ impl Render for CommandPalette {
7401159 .max_h(px(448.))
7411160 .overflow_y_scroll()
7421161 .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))
7511165 })
7521166 .children(rows),
7531167 )
@@ -764,7 +1178,10 @@ impl Render for CommandPalette {
7641178 ],
7651179 "Navigate",
7661180 ))
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+ })
7681185 .when(in_submenu, |this| this.child(hint(vec![metal::keycap("Backspace", p).into_any_element()], "Back")))
7691186 .child(hint(vec![metal::keycap("Esc", p).into_any_element()], "Close")),
7701187 )
+51−0src/ui/workspace/remote.rsView File
@@ -25,7 +25,9 @@ use crate::remote::{
2525 use crate::session::{
2626 NewSession, PermissionDecision, Reasoning, RemoteLink, Session, from_millis, store_uploads,
2727 };
28+ use crate::projects;
2829 use crate::settings::{self, RemoteHost, Settings, SettingsPatch, WorkspaceMode};
30+ use crate::ui::folder_browser;
2931 use crate::ui::metal::MetalButton;
3032
3133 /// How often changed threads are sent to subscribed clients.
@@ -490,6 +492,38 @@ impl Workspace {
490492 // Tells the open conversations, and every client, this one too.
491493 self.settings_changed(window, cx);
492494 }
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+ }
493527 }
494528 }
495529
@@ -655,10 +689,27 @@ impl Workspace {
655689 }
656690 cx.notify();
657691 }
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+ }
658702 ClientEvent::Message(ServerMessage::Welcome { .. } | ServerMessage::Denied { .. }) => {}
659703 }
660704 }
661705
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+
662713 fn environment_sessions(&self, env: &str) -> Vec<Entity<Session>> {
663714 self.projects
664715 .iter()