ssh project: Handle multiple paths and worktrees correctly (#18277)

This makes SSH projects work with `ssh_connections` that have multiple
paths:

```json
{
  "ssh_connections": [
    {
      "host": "127.0.0.1",
      "projects": [
        {
          "paths": [
            "/Users/thorstenball/work/projs/go-proj",
            "/Users/thorstenball/work/projs/rust-proj"
          ]
        }
      ]
    }
  ]
}
```

@ConradIrwin @mikayla-maki since this wasn't really released yet, we
didn't create a full-on migration, so old ssh projects that were already
serialized need to either be manually deleted from the database, or the
whole local DB wiped.

Release Notes:

- N/A

---------

Co-authored-by: Bennet <bennet@zed.dev>
This commit is contained in:
Thorsten Ball 2024-09-24 16:46:11 +02:00 committed by GitHub
parent 3a2f0653d1
commit 437bcc0ce6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 66 additions and 56 deletions

View file

@ -268,7 +268,7 @@ impl PickerDelegate for RecentProjectsDelegate {
.as_ref() .as_ref()
.map(|port| port.to_string()) .map(|port| port.to_string())
.unwrap_or_default(), .unwrap_or_default(),
ssh_project.path, ssh_project.paths.join(","),
ssh_project ssh_project
.user .user
.as_ref() .as_ref()
@ -403,7 +403,7 @@ impl PickerDelegate for RecentProjectsDelegate {
password: None, password: None,
}; };
let paths = vec![PathBuf::from(ssh_project.path.clone())]; let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
cx.spawn(|_, mut cx| async move { cx.spawn(|_, mut cx| async move {
open_ssh_project(connection_options, paths, app_state, open_options, &mut cx).await open_ssh_project(connection_options, paths, app_state, open_options, &mut cx).await
@ -460,9 +460,7 @@ impl PickerDelegate for RecentProjectsDelegate {
.filter_map(|i| paths.paths().get(*i).cloned()) .filter_map(|i| paths.paths().get(*i).cloned())
.collect(), .collect(),
), ),
SerializedWorkspaceLocation::Ssh(ssh_project) => { SerializedWorkspaceLocation::Ssh(ssh_project) => Arc::new(ssh_project.ssh_urls()),
Arc::new(vec![PathBuf::from(ssh_project.ssh_url())])
}
SerializedWorkspaceLocation::DevServer(dev_server_project) => { SerializedWorkspaceLocation::DevServer(dev_server_project) => {
Arc::new(vec![PathBuf::from(format!( Arc::new(vec![PathBuf::from(format!(
"{}:{}", "{}:{}",

View file

@ -366,6 +366,9 @@ define_connection! {
); );
ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE; ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE;
), ),
sql!(
ALTER TABLE ssh_projects RENAME COLUMN path TO paths;
),
]; ];
} }
@ -769,39 +772,40 @@ impl WorkspaceDb {
&self, &self,
host: String, host: String,
port: Option<u16>, port: Option<u16>,
path: String, paths: Vec<String>,
user: Option<String>, user: Option<String>,
) -> Result<SerializedSshProject> { ) -> Result<SerializedSshProject> {
let paths = serde_json::to_string(&paths)?;
if let Some(project) = self if let Some(project) = self
.get_ssh_project(host.clone(), port, path.clone(), user.clone()) .get_ssh_project(host.clone(), port, paths.clone(), user.clone())
.await? .await?
{ {
Ok(project) Ok(project)
} else { } else {
self.insert_ssh_project(host, port, path, user) self.insert_ssh_project(host, port, paths, user)
.await? .await?
.ok_or_else(|| anyhow!("failed to insert ssh project")) .ok_or_else(|| anyhow!("failed to insert ssh project"))
} }
} }
query! { query! {
async fn get_ssh_project(host: String, port: Option<u16>, path: String, user: Option<String>) -> Result<Option<SerializedSshProject>> { async fn get_ssh_project(host: String, port: Option<u16>, paths: String, user: Option<String>) -> Result<Option<SerializedSshProject>> {
SELECT id, host, port, path, user SELECT id, host, port, paths, user
FROM ssh_projects FROM ssh_projects
WHERE host IS ? AND port IS ? AND path IS ? AND user IS ? WHERE host IS ? AND port IS ? AND paths IS ? AND user IS ?
LIMIT 1 LIMIT 1
} }
} }
query! { query! {
async fn insert_ssh_project(host: String, port: Option<u16>, path: String, user: Option<String>) -> Result<Option<SerializedSshProject>> { async fn insert_ssh_project(host: String, port: Option<u16>, paths: String, user: Option<String>) -> Result<Option<SerializedSshProject>> {
INSERT INTO ssh_projects( INSERT INTO ssh_projects(
host, host,
port, port,
path, paths,
user user
) VALUES (?1, ?2, ?3, ?4) ) VALUES (?1, ?2, ?3, ?4)
RETURNING id, host, port, path, user RETURNING id, host, port, paths, user
} }
} }
@ -840,7 +844,7 @@ impl WorkspaceDb {
query! { query! {
fn ssh_projects() -> Result<Vec<SerializedSshProject>> { fn ssh_projects() -> Result<Vec<SerializedSshProject>> {
SELECT id, host, port, path, user SELECT id, host, port, paths, user
FROM ssh_projects FROM ssh_projects
} }
} }
@ -1656,45 +1660,45 @@ mod tests {
async fn test_get_or_create_ssh_project() { async fn test_get_or_create_ssh_project() {
let db = WorkspaceDb(open_test_db("test_get_or_create_ssh_project").await); let db = WorkspaceDb(open_test_db("test_get_or_create_ssh_project").await);
let (host, port, path, user) = ( let (host, port, paths, user) = (
"example.com".to_string(), "example.com".to_string(),
Some(22_u16), Some(22_u16),
"/home/user".to_string(), vec!["/home/user".to_string(), "/etc/nginx".to_string()],
Some("user".to_string()), Some("user".to_string()),
); );
let project = db let project = db
.get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone()) .get_or_create_ssh_project(host.clone(), port, paths.clone(), user.clone())
.await .await
.unwrap(); .unwrap();
assert_eq!(project.host, host); assert_eq!(project.host, host);
assert_eq!(project.path, path); assert_eq!(project.paths, paths);
assert_eq!(project.user, user); assert_eq!(project.user, user);
// Test that calling the function again with the same parameters returns the same project // Test that calling the function again with the same parameters returns the same project
let same_project = db let same_project = db
.get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone()) .get_or_create_ssh_project(host.clone(), port, paths.clone(), user.clone())
.await .await
.unwrap(); .unwrap();
assert_eq!(project.id, same_project.id); assert_eq!(project.id, same_project.id);
// Test with different parameters // Test with different parameters
let (host2, path2, user2) = ( let (host2, paths2, user2) = (
"otherexample.com".to_string(), "otherexample.com".to_string(),
"/home/otheruser".to_string(), vec!["/home/otheruser".to_string()],
Some("otheruser".to_string()), Some("otheruser".to_string()),
); );
let different_project = db let different_project = db
.get_or_create_ssh_project(host2.clone(), None, path2.clone(), user2.clone()) .get_or_create_ssh_project(host2.clone(), None, paths2.clone(), user2.clone())
.await .await
.unwrap(); .unwrap();
assert_ne!(project.id, different_project.id); assert_ne!(project.id, different_project.id);
assert_eq!(different_project.host, host2); assert_eq!(different_project.host, host2);
assert_eq!(different_project.path, path2); assert_eq!(different_project.paths, paths2);
assert_eq!(different_project.user, user2); assert_eq!(different_project.user, user2);
} }
@ -1702,25 +1706,25 @@ mod tests {
async fn test_get_or_create_ssh_project_with_null_user() { async fn test_get_or_create_ssh_project_with_null_user() {
let db = WorkspaceDb(open_test_db("test_get_or_create_ssh_project_with_null_user").await); let db = WorkspaceDb(open_test_db("test_get_or_create_ssh_project_with_null_user").await);
let (host, port, path, user) = ( let (host, port, paths, user) = (
"example.com".to_string(), "example.com".to_string(),
None, None,
"/home/user".to_string(), vec!["/home/user".to_string()],
None, None,
); );
let project = db let project = db
.get_or_create_ssh_project(host.clone(), port, path.clone(), None) .get_or_create_ssh_project(host.clone(), port, paths.clone(), None)
.await .await
.unwrap(); .unwrap();
assert_eq!(project.host, host); assert_eq!(project.host, host);
assert_eq!(project.path, path); assert_eq!(project.paths, paths);
assert_eq!(project.user, None); assert_eq!(project.user, None);
// Test that calling the function again with the same parameters returns the same project // Test that calling the function again with the same parameters returns the same project
let same_project = db let same_project = db
.get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone()) .get_or_create_ssh_project(host.clone(), port, paths.clone(), user.clone())
.await .await
.unwrap(); .unwrap();
@ -1735,32 +1739,32 @@ mod tests {
( (
"example.com".to_string(), "example.com".to_string(),
None, None,
"/home/user".to_string(), vec!["/home/user".to_string()],
None, None,
), ),
( (
"anotherexample.com".to_string(), "anotherexample.com".to_string(),
Some(123_u16), Some(123_u16),
"/home/user2".to_string(), vec!["/home/user2".to_string()],
Some("user2".to_string()), Some("user2".to_string()),
), ),
( (
"yetanother.com".to_string(), "yetanother.com".to_string(),
Some(345_u16), Some(345_u16),
"/home/user3".to_string(), vec!["/home/user3".to_string(), "/proc/1234/exe".to_string()],
None, None,
), ),
]; ];
for (host, port, path, user) in projects.iter() { for (host, port, paths, user) in projects.iter() {
let project = db let project = db
.get_or_create_ssh_project(host.clone(), *port, path.clone(), user.clone()) .get_or_create_ssh_project(host.clone(), *port, paths.clone(), user.clone())
.await .await
.unwrap(); .unwrap();
assert_eq!(&project.host, host); assert_eq!(&project.host, host);
assert_eq!(&project.port, port); assert_eq!(&project.port, port);
assert_eq!(&project.path, path); assert_eq!(&project.paths, paths);
assert_eq!(&project.user, user); assert_eq!(&project.user, user);
} }

View file

@ -26,24 +26,29 @@ pub struct SerializedSshProject {
pub id: SshProjectId, pub id: SshProjectId,
pub host: String, pub host: String,
pub port: Option<u16>, pub port: Option<u16>,
pub path: String, pub paths: Vec<String>,
pub user: Option<String>, pub user: Option<String>,
} }
impl SerializedSshProject { impl SerializedSshProject {
pub fn ssh_url(&self) -> String { pub fn ssh_urls(&self) -> Vec<PathBuf> {
let mut result = String::from("ssh://"); self.paths
if let Some(user) = &self.user { .iter()
result.push_str(user); .map(|path| {
result.push('@'); let mut result = String::new();
} if let Some(user) = &self.user {
result.push_str(&self.host); result.push_str(user);
if let Some(port) = &self.port { result.push('@');
result.push(':'); }
result.push_str(&port.to_string()); result.push_str(&self.host);
} if let Some(port) = &self.port {
result.push_str(&self.path); result.push(':');
result result.push_str(&port.to_string());
}
result.push_str(path);
PathBuf::from(result)
})
.collect()
} }
} }
@ -58,7 +63,8 @@ impl Bind for &SerializedSshProject {
let next_index = statement.bind(&self.id.0, start_index)?; let next_index = statement.bind(&self.id.0, start_index)?;
let next_index = statement.bind(&self.host, next_index)?; let next_index = statement.bind(&self.host, next_index)?;
let next_index = statement.bind(&self.port, next_index)?; let next_index = statement.bind(&self.port, next_index)?;
let next_index = statement.bind(&self.path, next_index)?; let raw_paths = serde_json::to_string(&self.paths)?;
let next_index = statement.bind(&raw_paths, next_index)?;
statement.bind(&self.user, next_index) statement.bind(&self.user, next_index)
} }
} }
@ -68,7 +74,9 @@ impl Column for SerializedSshProject {
let id = statement.column_int64(start_index)?; let id = statement.column_int64(start_index)?;
let host = statement.column_text(start_index + 1)?.to_string(); let host = statement.column_text(start_index + 1)?.to_string();
let (port, _) = Option::<u16>::column(statement, start_index + 2)?; let (port, _) = Option::<u16>::column(statement, start_index + 2)?;
let path = statement.column_text(start_index + 3)?.to_string(); let raw_paths = statement.column_text(start_index + 3)?.to_string();
let paths: Vec<String> = serde_json::from_str(&raw_paths)?;
let (user, _) = Option::<String>::column(statement, start_index + 4)?; let (user, _) = Option::<String>::column(statement, start_index + 4)?;
Ok(( Ok((
@ -76,7 +84,7 @@ impl Column for SerializedSshProject {
id: SshProjectId(id as u64), id: SshProjectId(id as u64),
host, host,
port, port,
path, paths,
user, user,
}, },
start_index + 5, start_index + 5,

View file

@ -5516,14 +5516,14 @@ pub fn open_ssh_project(
cx: &mut AppContext, cx: &mut AppContext,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
cx.spawn(|mut cx| async move { cx.spawn(|mut cx| async move {
// TODO: Handle multiple paths
let path = paths.iter().next().cloned().unwrap_or_default();
let serialized_ssh_project = persistence::DB let serialized_ssh_project = persistence::DB
.get_or_create_ssh_project( .get_or_create_ssh_project(
connection_options.host.clone(), connection_options.host.clone(),
connection_options.port, connection_options.port,
path.to_string_lossy().to_string(), paths
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>(),
connection_options.username.clone(), connection_options.username.clone(),
) )
.await?; .await?;