Cargo.lock 🔗
@@ -14096,6 +14096,7 @@ dependencies = [
"parking_lot",
"postage",
"project",
+ "remote",
"schemars",
"serde",
"serde_json",
Thorsten Ball and Bennet Bo Fenner created
TODOs:
- [x] Add tests to `workspace/src/persistence.rs`
- [x] Add a icon for ssh projects
- [x] Fix all `TODO` comments
- [x] Use `port` if it's passed in the ssh connection options
In next PRs:
- Make sure unsaved buffers are persisted/restored, along with other
items/layout
- Handle multiple paths/worktrees correctly
Release Notes:
- N/A
---------
Co-authored-by: Bennet Bo Fenner <bennet@zed.dev>
Cargo.lock | 1
crates/recent_projects/src/dev_servers.rs | 7
crates/recent_projects/src/recent_projects.rs | 108 ++++-
crates/recent_projects/src/ssh_connections.rs | 67 +--
crates/remote/src/ssh_session.rs | 5
crates/sqlez/src/bindable.rs | 16
crates/sqlez/src/typed_statements.rs | 2
crates/workspace/Cargo.toml | 1
crates/workspace/src/persistence.rs | 374 +++++++++++++++++---
crates/workspace/src/persistence/model.rs | 66 +++
crates/workspace/src/workspace.rs | 80 ++++
crates/zed/src/main.rs | 6
12 files changed, 592 insertions(+), 141 deletions(-)
@@ -14096,6 +14096,7 @@ dependencies = [
"parking_lot",
"postage",
"project",
+ "remote",
"schemars",
"serde",
"serde_json",
@@ -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,
@@ -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<DismissEvent> 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::<Workspace>()
+ } 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({
@@ -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<PathWithPosition>,
+ paths: Vec<PathBuf>,
app_state: Arc<AppState>,
- _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
}
@@ -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,
@@ -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<i32> {
+ (*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<i32> {
@@ -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
@@ -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
@@ -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<LocalPaths>,
Option<LocalPathsOrder>,
- Option<u64>,
Option<SerializedWindowBounds>,
Option<Uuid>,
Option<bool>,
@@ -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<LocalPaths>,
- Option<LocalPathsOrder>,
Option<u64>,
Option<SerializedWindowBounds>,
Option<Uuid>,
@@ -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<SerializedWorkspace> {
+ let (workspace_id, window_bounds, display, centered_layout, docks, window_id): (
+ WorkspaceId,
+ Option<SerializedWindowBounds>,
+ Option<Uuid>,
+ Option<bool>,
+ DockStructure,
+ Option<u64>,
+ ) = 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<u16>,
+ path: String,
+ user: Option<String>,
+ ) -> Result<SerializedSshProject> {
+ 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<u16>, path: String, user: Option<String>) -> Result<Option<SerializedSshProject>> {
+ 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<u16>, path: String, user: Option<String>) -> Result<Option<SerializedSshProject>> {
+ 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<WorkspaceId> {
INSERT INTO workspaces DEFAULT VALUES RETURNING workspace_id
@@ -695,10 +812,12 @@ impl WorkspaceDb {
}
query! {
- fn recent_workspaces() -> Result<Vec<(WorkspaceId, LocalPaths, LocalPathsOrder, Option<u64>)>> {
- SELECT workspace_id, local_paths, local_paths_order, dev_server_project_id
+ fn recent_workspaces() -> Result<Vec<(WorkspaceId, LocalPaths, LocalPathsOrder, Option<u64>, Option<u64>)>> {
+ 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<Vec<SerializedSshProject>> {
+ SELECT id, host, port, path, user
+ FROM ssh_projects
+ }
+ }
+
pub(crate) fn last_window(
&self,
) -> anyhow::Result<(Option<Uuid>, Option<SerializedWindowBounds>)> {
@@ -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();
@@ -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<u16>,
+ pub path: String,
+ pub user: Option<String>,
+}
+
+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<i32> {
+ 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::<u16>::column(statement, start_index + 2)?;
+ let path = statement.column_text(start_index + 3)?.to_string();
+ let (user, _) = Option::<String>::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<Vec<PathBuf>> = 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),
}
@@ -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<Box<dyn Fn(&mut Self, &mut ViewContext<Self>) -> AnyElement>>,
serializable_items_tx: UnboundedSender<Box<dyn SerializableItemHandle>>,
+ serialized_ssh_project: Option<SerializedSshProject>,
_items_serializer: Task<Result<()>>,
session_id: Option<String>,
}
@@ -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<Self>) -> 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<Workspace>,
+ connection_options: SshConnectionOptions,
+ session: Arc<SshSession>,
+ app_state: Arc<AppState>,
+ paths: Vec<PathBuf>,
+ cx: &mut AppContext,
+) -> Task<Result<()>> {
+ 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,
@@ -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::<Vec<_>>(),
app_state,
workspace::OpenOptions::default(),
&mut cx,