ssh remoting: Restore SSH projects when reopening Zed (#19188)

Release Notes:

- N/A

---------

Co-authored-by: Bennet <bennet@zed.dev>
This commit is contained in:
Thorsten Ball 2024-10-14 14:56:25 +02:00 committed by GitHub
parent 71a878aa39
commit 6ec00cdb06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 248 additions and 70 deletions

View file

@ -732,9 +732,11 @@ impl WorkspaceDb {
bottom_dock_visible, bottom_dock_visible,
bottom_dock_active_panel, bottom_dock_active_panel,
bottom_dock_zoom, bottom_dock_zoom,
session_id,
window_id,
timestamp timestamp
) )
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, CURRENT_TIMESTAMP)
ON CONFLICT DO ON CONFLICT DO
UPDATE SET UPDATE SET
ssh_project_id = ?2, ssh_project_id = ?2,
@ -747,11 +749,15 @@ impl WorkspaceDb {
bottom_dock_visible = ?9, bottom_dock_visible = ?9,
bottom_dock_active_panel = ?10, bottom_dock_active_panel = ?10,
bottom_dock_zoom = ?11, bottom_dock_zoom = ?11,
session_id = ?12,
window_id = ?13,
timestamp = CURRENT_TIMESTAMP timestamp = CURRENT_TIMESTAMP
))?(( ))?((
workspace.id, workspace.id,
ssh_project.id.0, ssh_project.id.0,
workspace.docks, workspace.docks,
workspace.session_id,
workspace.window_id
)) ))
.context("Updating workspace")?; .context("Updating workspace")?;
} }
@ -827,8 +833,8 @@ impl WorkspaceDb {
} }
query! { query! {
fn session_workspaces(session_id: String) -> Result<Vec<(LocalPaths, Option<u64>)>> { fn session_workspaces(session_id: String) -> Result<Vec<(LocalPaths, Option<u64>, Option<u64>)>> {
SELECT local_paths, window_id SELECT local_paths, window_id, ssh_project_id
FROM workspaces FROM workspaces
WHERE session_id = ?1 AND dev_server_project_id IS NULL WHERE session_id = ?1 AND dev_server_project_id IS NULL
ORDER BY timestamp DESC ORDER BY timestamp DESC
@ -849,6 +855,14 @@ impl WorkspaceDb {
} }
} }
query! {
fn ssh_project(id: u64) -> Result<SerializedSshProject> {
SELECT id, host, port, paths, user
FROM ssh_projects
WHERE id = ?
}
}
pub(crate) fn last_window( pub(crate) fn last_window(
&self, &self,
) -> anyhow::Result<(Option<Uuid>, Option<SerializedWindowBounds>)> { ) -> anyhow::Result<(Option<Uuid>, Option<SerializedWindowBounds>)> {
@ -937,18 +951,13 @@ impl WorkspaceDb {
Ok(result) Ok(result)
} }
pub async fn last_workspace(&self) -> Result<Option<LocalPaths>> { pub async fn last_workspace(&self) -> Result<Option<SerializedWorkspaceLocation>> {
Ok(self Ok(self
.recent_workspaces_on_disk() .recent_workspaces_on_disk()
.await? .await?
.into_iter() .into_iter()
.filter_map(|(_, location)| match location { .next()
SerializedWorkspaceLocation::Local(local_paths, _) => Some(local_paths), .map(|(_, location)| location))
// Do not automatically reopen Dev Server and SSH workspaces
SerializedWorkspaceLocation::DevServer(_) => None,
SerializedWorkspaceLocation::Ssh(_) => None,
})
.next())
} }
// Returns the locations of the workspaces that were still opened when the last // Returns the locations of the workspaces that were still opened when the last
@ -959,13 +968,20 @@ impl WorkspaceDb {
&self, &self,
last_session_id: &str, last_session_id: &str,
last_session_window_stack: Option<Vec<WindowId>>, last_session_window_stack: Option<Vec<WindowId>>,
) -> Result<Vec<LocalPaths>> { ) -> Result<Vec<SerializedWorkspaceLocation>> {
let mut workspaces = Vec::new(); let mut workspaces = Vec::new();
for (location, window_id) in self.session_workspaces(last_session_id.to_owned())? { for (location, window_id, ssh_project_id) in
if location.paths().iter().all(|path| path.exists()) self.session_workspaces(last_session_id.to_owned())?
{
if let Some(ssh_project_id) = ssh_project_id {
let location = SerializedWorkspaceLocation::Ssh(self.ssh_project(ssh_project_id)?);
workspaces.push((location, window_id.map(WindowId::from)));
} else if location.paths().iter().all(|path| path.exists())
&& location.paths().iter().any(|path| path.is_dir()) && location.paths().iter().any(|path| path.is_dir())
{ {
let location =
SerializedWorkspaceLocation::from_local_paths(location.paths().iter());
workspaces.push((location, window_id.map(WindowId::from))); workspaces.push((location, window_id.map(WindowId::from)));
} }
} }
@ -1570,10 +1586,28 @@ mod tests {
window_id: None, window_id: None,
}; };
let ssh_project = db
.get_or_create_ssh_project("my-host".to_string(), Some(1234), vec![], None)
.await
.unwrap();
let workspace_5 = SerializedWorkspace {
id: WorkspaceId(5),
location: SerializedWorkspaceLocation::Ssh(ssh_project.clone()),
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
session_id: Some("session-id-2".to_owned()),
window_id: Some(50),
};
db.save_workspace(workspace_1.clone()).await; db.save_workspace(workspace_1.clone()).await;
db.save_workspace(workspace_2.clone()).await; db.save_workspace(workspace_2.clone()).await;
db.save_workspace(workspace_3.clone()).await; db.save_workspace(workspace_3.clone()).await;
db.save_workspace(workspace_4.clone()).await; db.save_workspace(workspace_4.clone()).await;
db.save_workspace(workspace_5.clone()).await;
let locations = db.session_workspaces("session-id-1".to_owned()).unwrap(); let locations = db.session_workspaces("session-id-1".to_owned()).unwrap();
assert_eq!(locations.len(), 2); assert_eq!(locations.len(), 2);
@ -1583,9 +1617,13 @@ mod tests {
assert_eq!(locations[1].1, Some(20)); assert_eq!(locations[1].1, Some(20));
let locations = db.session_workspaces("session-id-2".to_owned()).unwrap(); let locations = db.session_workspaces("session-id-2".to_owned()).unwrap();
assert_eq!(locations.len(), 1); assert_eq!(locations.len(), 2);
assert_eq!(locations[0].0, LocalPaths::new(["/tmp3"])); assert_eq!(locations[0].0, LocalPaths::new(["/tmp3"]));
assert_eq!(locations[0].1, Some(30)); assert_eq!(locations[0].1, Some(30));
let empty_paths: Vec<&str> = Vec::new();
assert_eq!(locations[1].0, LocalPaths::new(empty_paths.iter()));
assert_eq!(locations[1].1, Some(50));
assert_eq!(locations[1].2, Some(ssh_project.id.0));
} }
fn default_workspace<P: AsRef<Path>>( fn default_workspace<P: AsRef<Path>>(
@ -1650,10 +1688,97 @@ mod tests {
.last_session_workspace_locations("one-session", stack) .last_session_workspace_locations("one-session", stack)
.unwrap(); .unwrap();
assert_eq!(have.len(), 4); assert_eq!(have.len(), 4);
assert_eq!(have[0], LocalPaths::new([dir4.path().to_str().unwrap()])); assert_eq!(
assert_eq!(have[1], LocalPaths::new([dir3.path().to_str().unwrap()])); have[0],
assert_eq!(have[2], LocalPaths::new([dir2.path().to_str().unwrap()])); SerializedWorkspaceLocation::from_local_paths(&[dir4.path().to_str().unwrap()])
assert_eq!(have[3], LocalPaths::new([dir1.path().to_str().unwrap()])); );
assert_eq!(
have[1],
SerializedWorkspaceLocation::from_local_paths([dir3.path().to_str().unwrap()])
);
assert_eq!(
have[2],
SerializedWorkspaceLocation::from_local_paths([dir2.path().to_str().unwrap()])
);
assert_eq!(
have[3],
SerializedWorkspaceLocation::from_local_paths([dir1.path().to_str().unwrap()])
);
}
#[gpui::test]
async fn test_last_session_workspace_locations_ssh_projects() {
let db = WorkspaceDb(
open_test_db("test_serializing_workspaces_last_session_workspaces_ssh_projects").await,
);
let ssh_projects = [
("host-1", "my-user-1"),
("host-2", "my-user-2"),
("host-3", "my-user-3"),
("host-4", "my-user-4"),
]
.into_iter()
.map(|(host, user)| async {
db.get_or_create_ssh_project(host.to_string(), None, vec![], Some(user.to_string()))
.await
.unwrap()
})
.collect::<Vec<_>>();
let ssh_projects = futures::future::join_all(ssh_projects).await;
let workspaces = [
(1, ssh_projects[0].clone(), 9),
(2, ssh_projects[1].clone(), 5),
(3, ssh_projects[2].clone(), 8),
(4, ssh_projects[3].clone(), 2),
]
.into_iter()
.map(|(id, ssh_project, window_id)| SerializedWorkspace {
id: WorkspaceId(id),
location: SerializedWorkspaceLocation::Ssh(ssh_project),
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
session_id: Some("one-session".to_owned()),
window_id: Some(window_id),
})
.collect::<Vec<_>>();
for workspace in workspaces.iter() {
db.save_workspace(workspace.clone()).await;
}
let stack = Some(Vec::from([
WindowId::from(2), // Top
WindowId::from(8),
WindowId::from(5),
WindowId::from(9), // Bottom
]));
let have = db
.last_session_workspace_locations("one-session", stack)
.unwrap();
assert_eq!(have.len(), 4);
assert_eq!(
have[0],
SerializedWorkspaceLocation::Ssh(ssh_projects[3].clone())
);
assert_eq!(
have[1],
SerializedWorkspaceLocation::Ssh(ssh_projects[2].clone())
);
assert_eq!(
have[2],
SerializedWorkspaceLocation::Ssh(ssh_projects[1].clone())
);
assert_eq!(
have[3],
SerializedWorkspaceLocation::Ssh(ssh_projects[0].clone())
);
} }
#[gpui::test] #[gpui::test]

View file

@ -11,7 +11,7 @@ use db::sqlez::{
}; };
use gpui::{AsyncWindowContext, Model, View, WeakView}; use gpui::{AsyncWindowContext, Model, View, WeakView};
use project::Project; use project::Project;
use remote::ssh_session::SshProjectId; use remote::{ssh_session::SshProjectId, SshConnectionOptions};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
@ -50,6 +50,15 @@ impl SerializedSshProject {
}) })
.collect() .collect()
} }
pub fn connection_options(&self) -> SshConnectionOptions {
SshConnectionOptions {
host: self.host.clone(),
username: self.user.clone(),
port: self.port,
password: None,
}
}
} }
impl StaticColumnCount for SerializedSshProject { impl StaticColumnCount for SerializedSshProject {

View file

@ -5046,14 +5046,14 @@ pub fn activate_workspace_for_project(
None None
} }
pub async fn last_opened_workspace_paths() -> Option<LocalPaths> { pub async fn last_opened_workspace_location() -> Option<SerializedWorkspaceLocation> {
DB.last_workspace().await.log_err().flatten() DB.last_workspace().await.log_err().flatten()
} }
pub fn last_session_workspace_locations( pub fn last_session_workspace_locations(
last_session_id: &str, last_session_id: &str,
last_session_window_stack: Option<Vec<WindowId>>, last_session_window_stack: Option<Vec<WindowId>>,
) -> Option<Vec<LocalPaths>> { ) -> Option<Vec<SerializedWorkspaceLocation>> {
DB.last_session_workspace_locations(last_session_id, last_session_window_stack) DB.last_session_workspace_locations(last_session_id, last_session_window_stack)
.log_err() .log_err()
} }

View file

@ -26,6 +26,7 @@ use gpui::{
use http_client::{read_proxy_from_env, Uri}; use http_client::{read_proxy_from_env, Uri};
use language::LanguageRegistry; use language::LanguageRegistry;
use log::LevelFilter; use log::LevelFilter;
use remote::SshConnectionOptions;
use reqwest_client::ReqwestClient; use reqwest_client::ReqwestClient;
use assets::Assets; use assets::Assets;
@ -55,7 +56,7 @@ use uuid::Uuid;
use welcome::{show_welcome_view, BaseKeymap, FIRST_OPEN}; use welcome::{show_welcome_view, BaseKeymap, FIRST_OPEN};
use workspace::{ use workspace::{
notifications::{simple_message_notification::MessageNotification, NotificationId}, notifications::{simple_message_notification::MessageNotification, NotificationId},
AppState, WorkspaceSettings, WorkspaceStore, AppState, SerializedWorkspaceLocation, WorkspaceSettings, WorkspaceStore,
}; };
use zed::{ use zed::{
app_menus, build_window_options, derive_paths_with_position, handle_cli_connection, app_menus, build_window_options, derive_paths_with_position, handle_cli_connection,
@ -868,15 +869,41 @@ async fn restore_or_create_workspace(
) -> Result<()> { ) -> Result<()> {
if let Some(locations) = restorable_workspace_locations(cx, &app_state).await { if let Some(locations) = restorable_workspace_locations(cx, &app_state).await {
for location in locations { for location in locations {
cx.update(|cx| { match location {
workspace::open_paths( SerializedWorkspaceLocation::Local(location, _) => {
location.paths().as_ref(), let task = cx.update(|cx| {
app_state.clone(), workspace::open_paths(
workspace::OpenOptions::default(), location.paths().as_ref(),
cx, app_state.clone(),
) workspace::OpenOptions::default(),
})? cx,
.await?; )
})?;
task.await?;
}
SerializedWorkspaceLocation::Ssh(ssh_project) => {
let connection_options = SshConnectionOptions {
host: ssh_project.host.clone(),
username: ssh_project.user.clone(),
port: ssh_project.port,
password: None,
};
let app_state = app_state.clone();
cx.spawn(move |mut cx| async move {
recent_projects::open_ssh_project(
connection_options,
ssh_project.paths.into_iter().map(PathBuf::from).collect(),
app_state,
workspace::OpenOptions::default(),
&mut cx,
)
.await
.log_err();
})
.detach();
}
SerializedWorkspaceLocation::DevServer(_) => {}
}
} }
} else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) {
cx.update(|cx| show_welcome_view(app_state, cx))?.await?; cx.update(|cx| show_welcome_view(app_state, cx))?.await?;
@ -895,7 +922,7 @@ async fn restore_or_create_workspace(
pub(crate) async fn restorable_workspace_locations( pub(crate) async fn restorable_workspace_locations(
cx: &mut AsyncAppContext, cx: &mut AsyncAppContext,
app_state: &Arc<AppState>, app_state: &Arc<AppState>,
) -> Option<Vec<workspace::LocalPaths>> { ) -> Option<Vec<SerializedWorkspaceLocation>> {
let mut restore_behavior = cx let mut restore_behavior = cx
.update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup) .update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup)
.ok()?; .ok()?;
@ -923,7 +950,7 @@ pub(crate) async fn restorable_workspace_locations(
match restore_behavior { match restore_behavior {
workspace::RestoreOnStartupBehavior::LastWorkspace => { workspace::RestoreOnStartupBehavior::LastWorkspace => {
workspace::last_opened_workspace_paths() workspace::last_opened_workspace_location()
.await .await
.map(|location| vec![location]) .map(|location| vec![location])
} }

View file

@ -16,8 +16,9 @@ use futures::future::join_all;
use futures::{FutureExt, SinkExt, StreamExt}; use futures::{FutureExt, SinkExt, StreamExt};
use gpui::{AppContext, AsyncAppContext, Global, WindowHandle}; use gpui::{AppContext, AsyncAppContext, Global, WindowHandle};
use language::{Bias, Point}; use language::{Bias, Point};
use recent_projects::open_ssh_project;
use remote::SshConnectionOptions; use remote::SshConnectionOptions;
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use std::{process, thread}; use std::{process, thread};
@ -25,7 +26,7 @@ use util::paths::PathWithPosition;
use util::ResultExt; use util::ResultExt;
use welcome::{show_welcome_view, FIRST_OPEN}; use welcome::{show_welcome_view, FIRST_OPEN};
use workspace::item::ItemHandle; use workspace::item::ItemHandle;
use workspace::{AppState, OpenOptions, Workspace}; use workspace::{AppState, OpenOptions, SerializedWorkspaceLocation, Workspace};
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct OpenRequest { pub struct OpenRequest {
@ -356,33 +357,21 @@ async fn open_workspaces(
env: Option<collections::HashMap<String, String>>, env: Option<collections::HashMap<String, String>>,
cx: &mut AsyncAppContext, cx: &mut AsyncAppContext,
) -> Result<()> { ) -> Result<()> {
let grouped_paths = if paths.is_empty() { let grouped_locations = if paths.is_empty() {
// If no paths are provided, restore from previous workspaces unless a new workspace is requested with -n // If no paths are provided, restore from previous workspaces unless a new workspace is requested with -n
if open_new_workspace == Some(true) { if open_new_workspace == Some(true) {
Vec::new() Vec::new()
} else { } else {
let locations = restorable_workspace_locations(cx, &app_state).await; let locations = restorable_workspace_locations(cx, &app_state).await;
locations locations.unwrap_or_default()
.into_iter()
.flat_map(|locations| {
locations
.into_iter()
.map(|location| {
location
.paths()
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect()
})
.collect::<Vec<_>>()
})
.collect()
} }
} else { } else {
vec![paths] vec![SerializedWorkspaceLocation::from_local_paths(
paths.into_iter().map(PathBuf::from),
)]
}; };
if grouped_paths.is_empty() { if grouped_locations.is_empty() {
// If we have no paths to open, show the welcome screen if this is the first launch // If we have no paths to open, show the welcome screen if this is the first launch
if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) {
cx.update(|cx| show_welcome_view(app_state, cx).detach()) cx.update(|cx| show_welcome_view(app_state, cx).detach())
@ -406,20 +395,48 @@ async fn open_workspaces(
// If there are paths to open, open a workspace for each grouping of paths // If there are paths to open, open a workspace for each grouping of paths
let mut errored = false; let mut errored = false;
for workspace_paths in grouped_paths { for location in grouped_locations {
let workspace_failed_to_open = open_workspace( match location {
workspace_paths, SerializedWorkspaceLocation::Local(workspace_paths, _) => {
open_new_workspace, let workspace_paths = workspace_paths
wait, .paths()
responses, .iter()
env.as_ref(), .map(|path| path.to_string_lossy().to_string())
&app_state, .collect();
cx,
)
.await;
if workspace_failed_to_open { let workspace_failed_to_open = open_local_workspace(
errored = true workspace_paths,
open_new_workspace,
wait,
responses,
env.as_ref(),
&app_state,
cx,
)
.await;
if workspace_failed_to_open {
errored = true
}
}
SerializedWorkspaceLocation::Ssh(ssh_project) => {
let app_state = app_state.clone();
cx.spawn(|mut cx| async move {
open_ssh_project(
ssh_project.connection_options(),
ssh_project.paths.into_iter().map(PathBuf::from).collect(),
app_state,
OpenOptions::default(),
&mut cx,
)
.await
.log_err();
})
.detach();
// We don't set `errored` here, because for ssh projects, the
// error is displayed in the window.
}
SerializedWorkspaceLocation::DevServer(_) => {}
} }
} }
@ -431,7 +448,7 @@ async fn open_workspaces(
Ok(()) Ok(())
} }
async fn open_workspace( async fn open_local_workspace(
workspace_paths: Vec<String>, workspace_paths: Vec<String>,
open_new_workspace: Option<bool>, open_new_workspace: Option<bool>,
wait: bool, wait: bool,
@ -563,7 +580,7 @@ mod tests {
use serde_json::json; use serde_json::json;
use workspace::{AppState, Workspace}; use workspace::{AppState, Workspace};
use crate::zed::{open_listener::open_workspace, tests::init_test}; use crate::zed::{open_listener::open_local_workspace, tests::init_test};
#[gpui::test] #[gpui::test]
async fn test_open_workspace_with_directory(cx: &mut TestAppContext) { async fn test_open_workspace_with_directory(cx: &mut TestAppContext) {
@ -678,7 +695,7 @@ mod tests {
let errored = cx let errored = cx
.spawn(|mut cx| async move { .spawn(|mut cx| async move {
open_workspace( open_local_workspace(
workspace_paths, workspace_paths,
open_new_workspace, open_new_workspace,
false, false,