diff --git a/Cargo.lock b/Cargo.lock index ca5d68881f..16ee627d2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14096,6 +14096,7 @@ dependencies = [ "parking_lot", "postage", "project", + "remote", "schemars", "serde", "serde_json", diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index 491f378f30..af5f51f14f 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -39,7 +39,6 @@ use ui::{ RadioWithLabel, Tooltip, }; use ui_input::{FieldLabelLayout, TextField}; -use util::paths::PathWithPosition; use util::ResultExt; use workspace::notifications::NotifyResultExt; use workspace::OpenOptions; @@ -987,11 +986,7 @@ impl DevServerProjects { cx.spawn(|_, mut cx| async move { let result = open_ssh_project( server.into(), - project - .paths - .into_iter() - .map(|path| PathWithPosition::from_path(PathBuf::from(path))) - .collect(), + project.paths.into_iter().map(PathBuf::from).collect(), app_state, OpenOptions::default(), &mut cx, diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 182cec4614..cb3d3ab659 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -2,6 +2,7 @@ mod dev_servers; pub mod disconnected_overlay; mod ssh_connections; mod ssh_remotes; +use remote::SshConnectionOptions; pub use ssh_connections::open_ssh_project; use client::{DevServerProjectId, ProjectId}; @@ -32,8 +33,8 @@ use ui::{ }; use util::{paths::PathExt, ResultExt}; use workspace::{ - AppState, CloseIntent, ModalView, SerializedWorkspaceLocation, Workspace, WorkspaceId, - WORKSPACE_DB, + AppState, CloseIntent, ModalView, OpenOptions, SerializedWorkspaceLocation, Workspace, + WorkspaceId, WORKSPACE_DB, }; #[derive(PartialEq, Clone, Deserialize, Default)] @@ -172,7 +173,7 @@ pub struct RecentProjectsDelegate { create_new_window: bool, // Flag to reset index when there is a new query vs not reset index when user delete an item reset_selected_match_index: bool, - has_any_dev_server_projects: bool, + has_any_non_local_projects: bool, } impl RecentProjectsDelegate { @@ -185,16 +186,16 @@ impl RecentProjectsDelegate { create_new_window, render_paths, reset_selected_match_index: true, - has_any_dev_server_projects: false, + has_any_non_local_projects: false, } } pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) { self.workspaces = workspaces; - self.has_any_dev_server_projects = self + self.has_any_non_local_projects = !self .workspaces .iter() - .any(|(_, location)| matches!(location, SerializedWorkspaceLocation::DevServer(_))); + .all(|(_, location)| matches!(location, SerializedWorkspaceLocation::Local(_, _))); } } impl EventEmitter for RecentProjectsDelegate {} @@ -258,6 +259,23 @@ impl PickerDelegate for RecentProjectsDelegate { dev_server_project.paths.join("") ) } + SerializedWorkspaceLocation::Ssh(ssh_project) => { + format!( + "{}{}{}{}", + ssh_project.host, + ssh_project + .port + .as_ref() + .map(|port| port.to_string()) + .unwrap_or_default(), + ssh_project.path, + ssh_project + .user + .as_ref() + .map(|user| user.to_string()) + .unwrap_or_default() + ) + } }; StringMatchCandidate::new(id, combined_string) @@ -364,6 +382,33 @@ impl PickerDelegate for RecentProjectsDelegate { }; open_dev_server_project(replace_current_window, dev_server_project.id, project_id, cx) } + SerializedWorkspaceLocation::Ssh(ssh_project) => { + let app_state = workspace.app_state().clone(); + + let replace_window = if replace_current_window { + cx.window_handle().downcast::() + } else { + None + }; + + let open_options = OpenOptions { + replace_window, + ..Default::default() + }; + + let connection_options = SshConnectionOptions { + host: ssh_project.host.clone(), + username: ssh_project.user.clone(), + port: ssh_project.port, + password: None, + }; + + let paths = vec![PathBuf::from(ssh_project.path.clone())]; + + cx.spawn(|_, mut cx| async move { + open_ssh_project(connection_options, paths, app_state, open_options, &mut cx).await + }) + } } } }) @@ -392,7 +437,6 @@ impl PickerDelegate for RecentProjectsDelegate { let (_, location) = self.workspaces.get(hit.candidate_id)?; - let is_remote = matches!(location, SerializedWorkspaceLocation::DevServer(_)); let dev_server_status = if let SerializedWorkspaceLocation::DevServer(dev_server_project) = location { let store = dev_server_projects::Store::global(cx).read(cx); @@ -416,6 +460,9 @@ impl PickerDelegate for RecentProjectsDelegate { .filter_map(|i| paths.paths().get(*i).cloned()) .collect(), ), + SerializedWorkspaceLocation::Ssh(ssh_project) => { + Arc::new(vec![PathBuf::from(ssh_project.ssh_url())]) + } SerializedWorkspaceLocation::DevServer(dev_server_project) => { Arc::new(vec![PathBuf::from(format!( "{}:{}", @@ -457,29 +504,34 @@ impl PickerDelegate for RecentProjectsDelegate { h_flex() .flex_grow() .gap_3() - .when(self.has_any_dev_server_projects, |this| { - this.child(if is_remote { - // if disabled, Color::Disabled - let indicator_color = match dev_server_status { - Some(DevServerStatus::Online) => Color::Created, - Some(DevServerStatus::Offline) => Color::Hidden, - _ => unreachable!(), - }; - IconWithIndicator::new( - Icon::new(IconName::Server).color(Color::Muted), - Some(Indicator::dot()), - ) - .indicator_color(indicator_color) - .indicator_border_color(if selected { - Some(cx.theme().colors().element_selected) - } else { - None - }) - .into_any_element() - } else { - Icon::new(IconName::Screen) + .when(self.has_any_non_local_projects, |this| { + this.child(match location { + SerializedWorkspaceLocation::Local(_, _) => { + Icon::new(IconName::Screen) + .color(Color::Muted) + .into_any_element() + } + SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Screen) .color(Color::Muted) + .into_any_element(), + SerializedWorkspaceLocation::DevServer(_) => { + let indicator_color = match dev_server_status { + Some(DevServerStatus::Online) => Color::Created, + Some(DevServerStatus::Offline) => Color::Hidden, + _ => unreachable!(), + }; + IconWithIndicator::new( + Icon::new(IconName::Server).color(Color::Muted), + Some(Indicator::dot()), + ) + .indicator_color(indicator_color) + .indicator_border_color(if selected { + Some(cx.theme().colors().element_selected) + } else { + None + }) .into_any_element() + } }) }) .child({ diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index 8da4284b7f..ad23a5c896 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -19,7 +19,6 @@ use ui::{ h_flex, v_flex, FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled, StyledExt as _, ViewContext, VisualContext, WindowContext, }; -use util::paths::PathWithPosition; use workspace::{AppState, ModalView, Workspace}; #[derive(Deserialize)] @@ -358,24 +357,29 @@ pub fn connect_over_ssh( pub async fn open_ssh_project( connection_options: SshConnectionOptions, - paths: Vec, + paths: Vec, app_state: Arc, - _open_options: workspace::OpenOptions, + open_options: workspace::OpenOptions, cx: &mut AsyncAppContext, ) -> Result<()> { let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?; - let window = cx.open_window(options, |cx| { - let project = project::Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ); - cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx)) - })?; + + let window = if let Some(window) = open_options.replace_window { + window + } else { + cx.open_window(options, |cx| { + let project = project::Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + cx, + ); + cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx)) + })? + }; let result = window .update(cx, |workspace, cx| { @@ -387,40 +391,17 @@ pub async fn open_ssh_project( .read(cx) .prompt .clone(); - connect_over_ssh(connection_options, ui, cx) + connect_over_ssh(connection_options.clone(), ui, cx) })? .await; if result.is_err() { window.update(cx, |_, cx| cx.remove_window()).ok(); } - let session = result?; - let project = cx.update(|cx| { - project::Project::ssh( - session, - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - cx, - ) - })?; - - for path in paths { - project - .update(cx, |project, cx| { - project.find_or_create_worktree(&path.path, true, cx) - })? - .await?; - } - - window.update(cx, |_, cx| { - cx.replace_root_view(|cx| Workspace::new(None, project, app_state, cx)) - })?; - window.update(cx, |_, cx| cx.activate_window())?; - - Ok(()) + cx.update(|cx| { + workspace::open_ssh_project(window, connection_options, session, app_state, paths, cx) + })? + .await } diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs index 7556b38f3e..4aab731e64 100644 --- a/crates/remote/src/ssh_session.rs +++ b/crates/remote/src/ssh_session.rs @@ -33,6 +33,11 @@ use std::{ }; use tempfile::TempDir; +#[derive( + Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize, +)] +pub struct SshProjectId(pub u64); + #[derive(Clone)] pub struct SshSocket { connection_options: SshConnectionOptions, diff --git a/crates/sqlez/src/bindable.rs b/crates/sqlez/src/bindable.rs index e8b9679936..8cf4329f92 100644 --- a/crates/sqlez/src/bindable.rs +++ b/crates/sqlez/src/bindable.rs @@ -196,6 +196,22 @@ impl Column for u32 { } } +impl StaticColumnCount for u16 {} +impl Bind for u16 { + fn bind(&self, statement: &Statement, start_index: i32) -> Result { + (*self as i64) + .bind(statement, start_index) + .with_context(|| format!("Failed to bind usize at index {start_index}")) + } +} + +impl Column for u16 { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let result = statement.column_int64(start_index)?; + Ok((result as u16, start_index + 1)) + } +} + impl StaticColumnCount for usize {} impl Bind for usize { fn bind(&self, statement: &Statement, start_index: i32) -> Result { diff --git a/crates/sqlez/src/typed_statements.rs b/crates/sqlez/src/typed_statements.rs index d7f25cde51..95f4f829ec 100644 --- a/crates/sqlez/src/typed_statements.rs +++ b/crates/sqlez/src/typed_statements.rs @@ -74,7 +74,7 @@ impl Connection { } /// Prepare a statement which takes a binding and selects a single row - /// from the database. WIll return none if no rows are returned and will + /// from the database. Will return none if no rows are returned and will /// error if more than 1 row is returned. /// /// Note: If there are multiple statements that depend upon each other diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 7f5c1ccce8..1b998eeabe 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -51,6 +51,7 @@ postage.workspace = true project.workspace = true dev_server_projects.workspace = true task.workspace = true +remote.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 88ede4228d..034328a30b 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -7,6 +7,7 @@ use client::DevServerProjectId; use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql}; use gpui::{point, size, Axis, Bounds, WindowBounds, WindowId}; +use remote::ssh_session::SshProjectId; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, @@ -20,7 +21,7 @@ use crate::WorkspaceId; use model::{ GroupId, LocalPaths, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup, - SerializedWorkspace, + SerializedSshProject, SerializedWorkspace, }; use self::model::{ @@ -354,7 +355,17 @@ define_connection! { ), sql!( ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0; - ) + ), + sql!( + CREATE TABLE ssh_projects ( + id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port INTEGER, + path TEXT NOT NULL, + user TEXT + ); + ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE; + ), ]; } @@ -374,7 +385,6 @@ impl WorkspaceDb { workspace_id, local_paths, local_paths_order, - dev_server_project_id, window_bounds, display, centered_layout, @@ -384,7 +394,6 @@ impl WorkspaceDb { WorkspaceId, Option, Option, - Option, Option, Option, Option, @@ -396,7 +405,6 @@ impl WorkspaceDb { workspace_id, local_paths, local_paths_order, - dev_server_project_id, window_state, window_x, window_y, @@ -422,28 +430,13 @@ impl WorkspaceDb { .warn_on_err() .flatten()?; - let location = if let Some(dev_server_project_id) = dev_server_project_id { - let dev_server_project: SerializedDevServerProject = self - .select_row_bound(sql! { - SELECT id, path, dev_server_name - FROM dev_server_projects - WHERE id = ? - }) - .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id)) - .context("No remote project found") - .warn_on_err() - .flatten()?; - SerializedWorkspaceLocation::DevServer(dev_server_project) - } else if let Some(local_paths) = local_paths { - match local_paths_order { - Some(order) => SerializedWorkspaceLocation::Local(local_paths, order), - None => { - let order = LocalPathsOrder::default_for_paths(&local_paths); - SerializedWorkspaceLocation::Local(local_paths, order) - } + let local_paths = local_paths?; + let location = match local_paths_order { + Some(order) => SerializedWorkspaceLocation::Local(local_paths, order), + None => { + let order = LocalPathsOrder::default_for_paths(&local_paths); + SerializedWorkspaceLocation::Local(local_paths, order) } - } else { - return None; }; Some(SerializedWorkspace { @@ -470,8 +463,6 @@ impl WorkspaceDb { // and we've grabbed the most recent workspace let ( workspace_id, - local_paths, - local_paths_order, dev_server_project_id, window_bounds, display, @@ -480,8 +471,6 @@ impl WorkspaceDb { window_id, ): ( WorkspaceId, - Option, - Option, Option, Option, Option, @@ -492,8 +481,6 @@ impl WorkspaceDb { .select_row_bound(sql! { SELECT workspace_id, - local_paths, - local_paths_order, dev_server_project_id, window_state, window_x, @@ -520,29 +507,20 @@ impl WorkspaceDb { .warn_on_err() .flatten()?; - let location = if let Some(dev_server_project_id) = dev_server_project_id { - let dev_server_project: SerializedDevServerProject = self - .select_row_bound(sql! { - SELECT id, path, dev_server_name - FROM dev_server_projects - WHERE id = ? - }) - .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id)) - .context("No remote project found") - .warn_on_err() - .flatten()?; - SerializedWorkspaceLocation::DevServer(dev_server_project) - } else if let Some(local_paths) = local_paths { - match local_paths_order { - Some(order) => SerializedWorkspaceLocation::Local(local_paths, order), - None => { - let order = LocalPathsOrder::default_for_paths(&local_paths); - SerializedWorkspaceLocation::Local(local_paths, order) - } - } - } else { - return None; - }; + let dev_server_project_id = dev_server_project_id?; + + let dev_server_project: SerializedDevServerProject = self + .select_row_bound(sql! { + SELECT id, path, dev_server_name + FROM dev_server_projects + WHERE id = ? + }) + .and_then(|mut prepared_statement| (prepared_statement)(dev_server_project_id)) + .context("No remote project found") + .warn_on_err() + .flatten()?; + + let location = SerializedWorkspaceLocation::DevServer(dev_server_project); Some(SerializedWorkspace { id: workspace_id, @@ -560,6 +538,62 @@ impl WorkspaceDb { }) } + pub(crate) fn workspace_for_ssh_project( + &self, + ssh_project: &SerializedSshProject, + ) -> Option { + let (workspace_id, window_bounds, display, centered_layout, docks, window_id): ( + WorkspaceId, + Option, + Option, + Option, + DockStructure, + Option, + ) = self + .select_row_bound(sql! { + SELECT + workspace_id, + window_state, + window_x, + window_y, + window_width, + window_height, + display, + centered_layout, + left_dock_visible, + left_dock_active_panel, + left_dock_zoom, + right_dock_visible, + right_dock_active_panel, + right_dock_zoom, + bottom_dock_visible, + bottom_dock_active_panel, + bottom_dock_zoom, + window_id + FROM workspaces + WHERE ssh_project_id = ? + }) + .and_then(|mut prepared_statement| (prepared_statement)(ssh_project.id.0)) + .context("No workspaces found") + .warn_on_err() + .flatten()?; + + Some(SerializedWorkspace { + id: workspace_id, + location: SerializedWorkspaceLocation::Ssh(ssh_project.clone()), + center_group: self + .get_center_pane_group(workspace_id) + .context("Getting center group") + .log_err()?, + window_bounds, + centered_layout: centered_layout.unwrap_or(false), + display, + docks, + session_id: None, + window_id, + }) + } + /// Saves a workspace using the worktree roots. Will garbage collect any workspaces /// that used this workspace previously pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) { @@ -674,6 +708,49 @@ impl WorkspaceDb { workspace.docks, )) .context("Updating workspace")?; + }, + SerializedWorkspaceLocation::Ssh(ssh_project) => { + conn.exec_bound(sql!( + DELETE FROM workspaces WHERE ssh_project_id = ? AND workspace_id != ? + ))?((ssh_project.id.0, workspace.id)) + .context("clearing out old locations")?; + + // Upsert + conn.exec_bound(sql!( + INSERT INTO workspaces( + workspace_id, + ssh_project_id, + left_dock_visible, + left_dock_active_panel, + left_dock_zoom, + right_dock_visible, + right_dock_active_panel, + right_dock_zoom, + bottom_dock_visible, + bottom_dock_active_panel, + bottom_dock_zoom, + timestamp + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP) + ON CONFLICT DO + UPDATE SET + ssh_project_id = ?2, + left_dock_visible = ?3, + left_dock_active_panel = ?4, + left_dock_zoom = ?5, + right_dock_visible = ?6, + right_dock_active_panel = ?7, + right_dock_zoom = ?8, + bottom_dock_visible = ?9, + bottom_dock_active_panel = ?10, + bottom_dock_zoom = ?11, + timestamp = CURRENT_TIMESTAMP + ))?(( + workspace.id, + ssh_project.id.0, + workspace.docks, + )) + .context("Updating workspace")?; } } @@ -688,6 +765,46 @@ impl WorkspaceDb { .await; } + pub(crate) async fn get_or_create_ssh_project( + &self, + host: String, + port: Option, + path: String, + user: Option, + ) -> Result { + if let Some(project) = self + .get_ssh_project(host.clone(), port, path.clone(), user.clone()) + .await? + { + Ok(project) + } else { + self.insert_ssh_project(host, port, path, user) + .await? + .ok_or_else(|| anyhow!("failed to insert ssh project")) + } + } + + query! { + async fn get_ssh_project(host: String, port: Option, path: String, user: Option) -> Result> { + SELECT id, host, port, path, user + FROM ssh_projects + WHERE host IS ? AND port IS ? AND path IS ? AND user IS ? + LIMIT 1 + } + } + + query! { + async fn insert_ssh_project(host: String, port: Option, path: String, user: Option) -> Result> { + INSERT INTO ssh_projects( + host, + port, + path, + user + ) VALUES (?1, ?2, ?3, ?4) + RETURNING id, host, port, path, user + } + } + query! { pub async fn next_id() -> Result { INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id @@ -695,10 +812,12 @@ impl WorkspaceDb { } query! { - fn recent_workspaces() -> Result)>> { - SELECT workspace_id, local_paths, local_paths_order, dev_server_project_id + fn recent_workspaces() -> Result, Option)>> { + SELECT workspace_id, local_paths, local_paths_order, dev_server_project_id, ssh_project_id FROM workspaces - WHERE local_paths IS NOT NULL OR dev_server_project_id IS NOT NULL + WHERE local_paths IS NOT NULL + OR dev_server_project_id IS NOT NULL + OR ssh_project_id IS NOT NULL ORDER BY timestamp DESC } } @@ -719,6 +838,13 @@ impl WorkspaceDb { } } + query! { + fn ssh_projects() -> Result> { + SELECT id, host, port, path, user + FROM ssh_projects + } + } + pub(crate) fn last_window( &self, ) -> anyhow::Result<(Option, Option)> { @@ -768,8 +894,11 @@ impl WorkspaceDb { let mut result = Vec::new(); let mut delete_tasks = Vec::new(); let dev_server_projects = self.dev_server_projects()?; + let ssh_projects = self.ssh_projects()?; - for (id, location, order, dev_server_project_id) in self.recent_workspaces()? { + for (id, location, order, dev_server_project_id, ssh_project_id) in + self.recent_workspaces()? + { if let Some(dev_server_project_id) = dev_server_project_id.map(DevServerProjectId) { if let Some(dev_server_project) = dev_server_projects .iter() @@ -782,6 +911,15 @@ impl WorkspaceDb { continue; } + if let Some(ssh_project_id) = ssh_project_id.map(SshProjectId) { + if let Some(ssh_project) = ssh_projects.iter().find(|rp| rp.id == ssh_project_id) { + result.push((id, SerializedWorkspaceLocation::Ssh(ssh_project.clone()))); + } else { + delete_tasks.push(self.delete_workspace_by_id(id)); + } + continue; + } + if location.paths().iter().all(|path| path.exists()) && location.paths().iter().any(|path| path.is_dir()) { @@ -802,7 +940,9 @@ impl WorkspaceDb { .into_iter() .filter_map(|(_, location)| match location { SerializedWorkspaceLocation::Local(local_paths, _) => Some(local_paths), + // Do not automatically reopen Dev Server and SSH workspaces SerializedWorkspaceLocation::DevServer(_) => None, + SerializedWorkspaceLocation::Ssh(_) => None, }) .next()) } @@ -1512,6 +1652,122 @@ mod tests { assert_eq!(have[3], LocalPaths::new([dir1.path().to_str().unwrap()])); } + #[gpui::test] + async fn test_get_or_create_ssh_project() { + let db = WorkspaceDb(open_test_db("test_get_or_create_ssh_project").await); + + let (host, port, path, user) = ( + "example.com".to_string(), + Some(22_u16), + "/home/user".to_string(), + Some("user".to_string()), + ); + + let project = db + .get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone()) + .await + .unwrap(); + + assert_eq!(project.host, host); + assert_eq!(project.path, path); + assert_eq!(project.user, user); + + // Test that calling the function again with the same parameters returns the same project + let same_project = db + .get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone()) + .await + .unwrap(); + + assert_eq!(project.id, same_project.id); + + // Test with different parameters + let (host2, path2, user2) = ( + "otherexample.com".to_string(), + "/home/otheruser".to_string(), + Some("otheruser".to_string()), + ); + + let different_project = db + .get_or_create_ssh_project(host2.clone(), None, path2.clone(), user2.clone()) + .await + .unwrap(); + + assert_ne!(project.id, different_project.id); + assert_eq!(different_project.host, host2); + assert_eq!(different_project.path, path2); + assert_eq!(different_project.user, user2); + } + + #[gpui::test] + 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 (host, port, path, user) = ( + "example.com".to_string(), + None, + "/home/user".to_string(), + None, + ); + + let project = db + .get_or_create_ssh_project(host.clone(), port, path.clone(), None) + .await + .unwrap(); + + assert_eq!(project.host, host); + assert_eq!(project.path, path); + assert_eq!(project.user, None); + + // Test that calling the function again with the same parameters returns the same project + let same_project = db + .get_or_create_ssh_project(host.clone(), port, path.clone(), user.clone()) + .await + .unwrap(); + + assert_eq!(project.id, same_project.id); + } + + #[gpui::test] + async fn test_get_ssh_projects() { + let db = WorkspaceDb(open_test_db("test_get_ssh_projects").await); + + let projects = vec![ + ( + "example.com".to_string(), + None, + "/home/user".to_string(), + None, + ), + ( + "anotherexample.com".to_string(), + Some(123_u16), + "/home/user2".to_string(), + Some("user2".to_string()), + ), + ( + "yetanother.com".to_string(), + Some(345_u16), + "/home/user3".to_string(), + None, + ), + ]; + + for (host, port, path, user) in projects.iter() { + let project = db + .get_or_create_ssh_project(host.clone(), *port, path.clone(), user.clone()) + .await + .unwrap(); + + assert_eq!(&project.host, host); + assert_eq!(&project.port, port); + assert_eq!(&project.path, path); + assert_eq!(&project.user, user); + } + + let stored_projects = db.ssh_projects().unwrap(); + assert_eq!(stored_projects.len(), projects.len()); + } + #[gpui::test] async fn test_simple_split() { env_logger::try_init().ok(); diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index d6f8001f25..0ad3fa5e60 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -11,6 +11,7 @@ use db::sqlez::{ }; use gpui::{AsyncWindowContext, Model, View, WeakView}; use project::Project; +use remote::ssh_session::SshProjectId; use serde::{Deserialize, Serialize}; use std::{ path::{Path, PathBuf}, @@ -20,6 +21,69 @@ use ui::SharedString; use util::ResultExt; use uuid::Uuid; +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct SerializedSshProject { + pub id: SshProjectId, + pub host: String, + pub port: Option, + pub path: String, + pub user: Option, +} + +impl SerializedSshProject { + pub fn ssh_url(&self) -> String { + let mut result = String::from("ssh://"); + if let Some(user) = &self.user { + result.push_str(user); + result.push('@'); + } + result.push_str(&self.host); + if let Some(port) = &self.port { + result.push(':'); + result.push_str(&port.to_string()); + } + result.push_str(&self.path); + result + } +} + +impl StaticColumnCount for SerializedSshProject { + fn column_count() -> usize { + 5 + } +} + +impl Bind for &SerializedSshProject { + fn bind(&self, statement: &Statement, start_index: i32) -> Result { + 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.port, next_index)?; + let next_index = statement.bind(&self.path, next_index)?; + statement.bind(&self.user, next_index) + } +} + +impl Column for SerializedSshProject { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let id = statement.column_int64(start_index)?; + let host = statement.column_text(start_index + 1)?.to_string(); + let (port, _) = Option::::column(statement, start_index + 2)?; + let path = statement.column_text(start_index + 3)?.to_string(); + let (user, _) = Option::::column(statement, start_index + 4)?; + + Ok(( + Self { + id: SshProjectId(id as u64), + host, + port, + path, + user, + }, + start_index + 5, + )) + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct SerializedDevServerProject { pub id: DevServerProjectId, @@ -58,7 +122,6 @@ impl Column for LocalPaths { fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { let path_blob = statement.column_blob(start_index)?; let paths: Arc> = if path_blob.is_empty() { - println!("path blog is empty"); Default::default() } else { bincode::deserialize(path_blob).context("Bincode deserialization of paths failed")? @@ -146,6 +209,7 @@ impl Column for SerializedDevServerProject { #[derive(Debug, PartialEq, Clone)] pub enum SerializedWorkspaceLocation { Local(LocalPaths, LocalPathsOrder), + Ssh(SerializedSshProject), DevServer(SerializedDevServerProject), } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 98ac49992d..5855dcce1e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -49,15 +49,19 @@ use node_runtime::NodeRuntime; use notifications::{simple_message_notification::MessageNotification, NotificationHandle}; pub use pane::*; pub use pane_group::*; -use persistence::{model::SerializedWorkspace, SerializedWindowBounds, DB}; pub use persistence::{ model::{ItemId, LocalPaths, SerializedDevServerProject, SerializedWorkspaceLocation}, WorkspaceDb, DB as WORKSPACE_DB, }; +use persistence::{ + model::{SerializedSshProject, SerializedWorkspace}, + SerializedWindowBounds, DB, +}; use postage::stream::Stream; use project::{ DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, }; +use remote::{SshConnectionOptions, SshSession}; use serde::Deserialize; use session::AppSession; use settings::Settings; @@ -756,6 +760,7 @@ pub struct Workspace { render_disconnected_overlay: Option) -> AnyElement>>, serializable_items_tx: UnboundedSender>, + serialized_ssh_project: Option, _items_serializer: Task>, session_id: Option, } @@ -1054,6 +1059,7 @@ impl Workspace { serializable_items_tx, _items_serializer, session_id: Some(session_id), + serialized_ssh_project: None, } } @@ -1440,6 +1446,10 @@ impl Workspace { self.on_prompt_for_open_path = Some(prompt) } + pub fn set_serialized_ssh_project(&mut self, serialized_ssh_project: SerializedSshProject) { + self.serialized_ssh_project = Some(serialized_ssh_project); + } + pub fn set_render_disconnected_overlay( &mut self, render: impl Fn(&mut Self, &mut ViewContext) -> AnyElement + 'static, @@ -4097,7 +4107,9 @@ impl Workspace { } } - let location = if let Some(local_paths) = self.local_paths(cx) { + let location = if let Some(ssh_project) = &self.serialized_ssh_project { + Some(SerializedWorkspaceLocation::Ssh(ssh_project.clone())) + } else if let Some(local_paths) = self.local_paths(cx) { if !local_paths.is_empty() { Some(SerializedWorkspaceLocation::from_local_paths(local_paths)) } else { @@ -5476,6 +5488,70 @@ pub fn join_hosted_project( }) } +pub fn open_ssh_project( + window: WindowHandle, + connection_options: SshConnectionOptions, + session: Arc, + app_state: Arc, + paths: Vec, + cx: &mut AppContext, +) -> Task> { + 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 + .get_or_create_ssh_project( + connection_options.host.clone(), + connection_options.port, + path.to_string_lossy().to_string(), + connection_options.username.clone(), + ) + .await?; + + let project = cx.update(|cx| { + project::Project::ssh( + session, + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + cx, + ) + })?; + + for path in paths { + project + .update(&mut cx, |project, cx| { + project.find_or_create_worktree(&path, true, cx) + })? + .await?; + } + + let serialized_workspace = + persistence::DB.workspace_for_ssh_project(&serialized_ssh_project); + + let workspace_id = + if let Some(workspace_id) = serialized_workspace.map(|workspace| workspace.id) { + workspace_id + } else { + persistence::DB.next_id().await? + }; + + cx.update_window(window.into(), |_, cx| { + cx.replace_root_view(|cx| { + let mut workspace = + Workspace::new(Some(workspace_id), project, app_state.clone(), cx); + workspace.set_serialized_ssh_project(serialized_ssh_project); + workspace + }); + })?; + + window.update(&mut cx, |_, cx| cx.activate_window()) + }) +} + pub fn join_dev_server_project( dev_server_project_id: DevServerProjectId, project_id: ProjectId, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index c127a975a9..3104001f99 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -667,7 +667,11 @@ fn handle_open_request( cx.spawn(|mut cx| async move { open_ssh_project( connection_info, - request.open_paths, + request + .open_paths + .into_iter() + .map(|path| path.path) + .collect::>(), app_state, workspace::OpenOptions::default(), &mut cx,