Detailed changes
@@ -2539,6 +2539,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
+ "session",
"settings",
"sha2 0.10.7",
"sqlx",
@@ -9711,6 +9712,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "session"
+version = "0.1.0"
+dependencies = [
+ "db",
+ "util",
+ "uuid",
+]
+
[[package]]
name = "settings"
version = "0.1.0"
@@ -13470,6 +13480,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
+ "session",
"settings",
"smallvec",
"sqlez",
@@ -13819,6 +13830,7 @@ dependencies = [
"search",
"serde",
"serde_json",
+ "session",
"settings",
"settings_ui",
"simplelog",
@@ -90,6 +90,7 @@ members = [
"crates/search",
"crates/semantic_index",
"crates/semantic_version",
+ "crates/session",
"crates/settings",
"crates/settings_ui",
"crates/snippet",
@@ -248,6 +249,7 @@ rpc = { path = "crates/rpc" }
search = { path = "crates/search" }
semantic_index = { path = "crates/semantic_index" }
semantic_version = { path = "crates/semantic_version" }
+session = { path = "crates/session" }
settings = { path = "crates/settings" }
settings_ui = { path = "crates/settings_ui" }
snippet = { path = "crates/snippet" }
@@ -82,7 +82,7 @@
// Whether to confirm before quitting Zed.
"confirm_quit": false,
// Whether to restore last closed project when fresh Zed instance is opened.
- "restore_on_startup": "last_workspace",
+ "restore_on_startup": "last_session",
// Size of the drop target in the editor.
"drop_target_size": 0.2,
// Whether the window should be closed when using 'close active item' on a window with no tabs.
@@ -106,6 +106,7 @@ dev_server_projects.workspace = true
rpc = { workspace = true, features = ["test-support"] }
sea-orm = { version = "0.12.x", features = ["sqlx-sqlite"] }
serde_json.workspace = true
+session = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
sqlx = { version = "0.7", features = ["sqlite"] }
theme.workspace = true
@@ -32,6 +32,7 @@ use rpc::{
};
use semantic_version::SemanticVersion;
use serde_json::json;
+use session::Session;
use settings::SettingsStore;
use std::{
cell::{Ref, RefCell, RefMut},
@@ -276,6 +277,7 @@ impl TestServer {
fs: fs.clone(),
build_window_options: |_, _| Default::default(),
node_runtime: FakeNodeRuntime::new(),
+ session: Session::test(),
});
let os_keymap = "keymaps/default-macos.json";
@@ -403,6 +405,7 @@ impl TestServer {
fs: fs.clone(),
build_window_options: |_, _| Default::default(),
node_runtime: FakeNodeRuntime::new(),
+ session: Session::test(),
});
cx.update(|cx| {
@@ -0,0 +1,23 @@
+[package]
+name = "session"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/session.rs"
+doctest = false
+
+[features]
+test-support = [
+ "db/test-support",
+]
+
+[dependencies]
+db.workspace = true
+uuid.workspace = true
+util.workspace = true
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -0,0 +1,44 @@
+use db::kvp::KEY_VALUE_STORE;
+use util::ResultExt;
+use uuid::Uuid;
+
+#[derive(Clone, Debug)]
+pub struct Session {
+ session_id: String,
+ old_session_id: Option<String>,
+}
+
+impl Session {
+ pub async fn new() -> Self {
+ let key_name = "session_id".to_string();
+
+ let old_session_id = KEY_VALUE_STORE.read_kvp(&key_name).ok().flatten();
+
+ let session_id = Uuid::new_v4().to_string();
+
+ KEY_VALUE_STORE
+ .write_kvp(key_name, session_id.clone())
+ .await
+ .log_err();
+
+ Self {
+ session_id,
+ old_session_id,
+ }
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn test() -> Self {
+ Self {
+ session_id: Uuid::new_v4().to_string(),
+ old_session_id: None,
+ }
+ }
+
+ pub fn id(&self) -> &str {
+ &self.session_id
+ }
+ pub fn last_session_id(&self) -> Option<&str> {
+ self.old_session_id.as_deref()
+ }
+}
@@ -20,6 +20,7 @@ test-support = [
"http/test-support",
"db/test-support",
"project/test-support",
+ "session/test-support",
"settings/test-support",
"gpui/test-support",
"fs/test-support",
@@ -53,6 +54,7 @@ task.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
+session.workspace = true
settings.workspace = true
smallvec.workspace = true
sqlez.workspace = true
@@ -69,5 +71,6 @@ env_logger.workspace = true
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
+session = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
http = { workspace = true, features = ["test-support"] }
@@ -170,6 +170,7 @@ define_connection! {
// display: Option<Uuid>, // Display id
// fullscreen: Option<bool>, // Is the window fullscreen?
// centered_layout: Option<bool>, // Is the Centered Layout mode activated?
+ // session_id: Option<String>, // Session id
// )
//
// pane_groups(
@@ -344,6 +345,9 @@ define_connection! {
sql!(
ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB;
),
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL;
+ ),
];
}
@@ -443,6 +447,7 @@ impl WorkspaceDb {
centered_layout: centered_layout.unwrap_or(false),
display,
docks,
+ session_id: None,
})
}
@@ -536,6 +541,7 @@ impl WorkspaceDb {
centered_layout: centered_layout.unwrap_or(false),
display,
docks,
+ session_id: None,
})
}
@@ -572,9 +578,10 @@ impl WorkspaceDb {
bottom_dock_visible,
bottom_dock_active_panel,
bottom_dock_zoom,
+ session_id,
timestamp
)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, CURRENT_TIMESTAMP)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, CURRENT_TIMESTAMP)
ON CONFLICT DO
UPDATE SET
local_paths = ?2,
@@ -588,8 +595,9 @@ impl WorkspaceDb {
bottom_dock_visible = ?10,
bottom_dock_active_panel = ?11,
bottom_dock_zoom = ?12,
+ session_id = ?13,
timestamp = CURRENT_TIMESTAMP
- ))?((workspace.id, &local_paths, &local_paths_order, workspace.docks))
+ ))?((workspace.id, &local_paths, &local_paths_order, workspace.docks, workspace.session_id))
.context("Updating workspace")?;
}
SerializedWorkspaceLocation::DevServer(dev_server_project) => {
@@ -675,6 +683,15 @@ impl WorkspaceDb {
}
}
+ query! {
+ fn session_workspace_locations(session_id: String) -> Result<Vec<LocalPaths>> {
+ SELECT local_paths
+ FROM workspaces
+ WHERE session_id = ?1 AND dev_server_project_id IS NULL
+ ORDER BY timestamp DESC
+ }
+ }
+
query! {
fn dev_server_projects() -> Result<Vec<SerializedDevServerProject>> {
SELECT id, path, dev_server_name
@@ -770,6 +787,23 @@ impl WorkspaceDb {
.next())
}
+ pub fn last_session_workspace_locations(
+ &self,
+ last_session_id: &str,
+ ) -> Result<Vec<LocalPaths>> {
+ let mut result = Vec::new();
+
+ for location in self.session_workspace_locations(last_session_id.to_owned())? {
+ if location.paths().iter().all(|path| path.exists())
+ && location.paths().iter().any(|path| path.is_dir())
+ {
+ result.push(location);
+ }
+ }
+
+ Ok(result)
+ }
+
fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
Ok(self
.get_pane_group(workspace_id, None)?
@@ -983,6 +1017,7 @@ impl WorkspaceDb {
#[cfg(test)]
mod tests {
+
use super::*;
use db::open_test_db;
use gpui;
@@ -1065,6 +1100,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
+ session_id: None,
};
let workspace_2 = SerializedWorkspace {
@@ -1075,6 +1111,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
+ session_id: None,
};
db.save_workspace(workspace_1.clone()).await;
@@ -1177,6 +1214,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
+ session_id: None,
};
db.save_workspace(workspace.clone()).await;
@@ -1209,6 +1247,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
+ session_id: None,
};
let mut workspace_2 = SerializedWorkspace {
@@ -1219,6 +1258,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
+ session_id: None,
};
db.save_workspace(workspace_1.clone()).await;
@@ -1259,6 +1299,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
+ session_id: None,
};
db.save_workspace(workspace_3.clone()).await;
@@ -1279,6 +1320,75 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_session_workspace_locations() {
+ env_logger::try_init().ok();
+
+ let db = WorkspaceDb(open_test_db("test_serializing_workspaces_session_id").await);
+
+ let workspace_1 = SerializedWorkspace {
+ id: WorkspaceId(1),
+ location: SerializedWorkspaceLocation::from_local_paths(["/tmp1"]),
+ center_group: Default::default(),
+ window_bounds: Default::default(),
+ display: Default::default(),
+ docks: Default::default(),
+ centered_layout: false,
+ session_id: Some("session-id-1".to_owned()),
+ };
+
+ let workspace_2 = SerializedWorkspace {
+ id: WorkspaceId(2),
+ location: SerializedWorkspaceLocation::from_local_paths(["/tmp2"]),
+ center_group: Default::default(),
+ window_bounds: Default::default(),
+ display: Default::default(),
+ docks: Default::default(),
+ centered_layout: false,
+ session_id: Some("session-id-1".to_owned()),
+ };
+
+ let workspace_3 = SerializedWorkspace {
+ id: WorkspaceId(3),
+ location: SerializedWorkspaceLocation::from_local_paths(["/tmp3"]),
+ 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()),
+ };
+
+ let workspace_4 = SerializedWorkspace {
+ id: WorkspaceId(4),
+ location: SerializedWorkspaceLocation::from_local_paths(["/tmp4"]),
+ center_group: Default::default(),
+ window_bounds: Default::default(),
+ display: Default::default(),
+ docks: Default::default(),
+ centered_layout: false,
+ session_id: None,
+ };
+
+ db.save_workspace(workspace_1.clone()).await;
+ db.save_workspace(workspace_2.clone()).await;
+ db.save_workspace(workspace_3.clone()).await;
+ db.save_workspace(workspace_4.clone()).await;
+
+ let locations = db
+ .session_workspace_locations("session-id-1".to_owned())
+ .unwrap();
+ assert_eq!(locations.len(), 2);
+ assert_eq!(locations[0], LocalPaths::new(["/tmp1"]));
+ assert_eq!(locations[1], LocalPaths::new(["/tmp2"]));
+
+ let locations = db
+ .session_workspace_locations("session-id-2".to_owned())
+ .unwrap();
+ assert_eq!(locations.len(), 1);
+ assert_eq!(locations[0], LocalPaths::new(["/tmp3"]));
+ }
+
use crate::persistence::model::SerializedWorkspace;
use crate::persistence::model::{SerializedItem, SerializedPane, SerializedPaneGroup};
@@ -1294,6 +1404,7 @@ mod tests {
display: Default::default(),
docks: Default::default(),
centered_layout: false,
+ session_id: None,
}
}
@@ -58,6 +58,7 @@ 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")?
@@ -214,6 +215,7 @@ pub(crate) struct SerializedWorkspace {
pub(crate) centered_layout: bool,
pub(crate) display: Option<Uuid>,
pub(crate) docks: DockStructure,
+ pub(crate) session_id: Option<String>,
}
#[derive(Debug, PartialEq, Clone, Default)]
@@ -58,6 +58,7 @@ pub use persistence::{
use postage::stream::Stream;
use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
use serde::Deserialize;
+use session::Session;
use settings::Settings;
use shared_screen::SharedScreen;
use sqlez::{
@@ -536,6 +537,7 @@ pub struct AppState {
pub fs: Arc<dyn fs::Fs>,
pub build_window_options: fn(Option<Uuid>, &mut AppContext) -> WindowOptions,
pub node_runtime: Arc<dyn NodeRuntime>,
+ pub session: Session,
}
struct GlobalAppState(Weak<AppState>);
@@ -569,6 +571,7 @@ impl AppState {
#[cfg(any(test, feature = "test-support"))]
pub fn test(cx: &mut AppContext) -> Arc<Self> {
use node_runtime::FakeNodeRuntime;
+ use session::Session;
use settings::SettingsStore;
use ui::Context as _;
@@ -582,6 +585,7 @@ impl AppState {
let clock = Arc::new(clock::FakeSystemClock::default());
let http_client = http::FakeHttpClient::with_404_response();
let client = Client::new(clock, http_client.clone(), cx);
+ let session = Session::test();
let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
let workspace_store = cx.new_model(|cx| WorkspaceStore::new(client.clone(), cx));
@@ -597,6 +601,7 @@ impl AppState {
workspace_store,
node_runtime: FakeNodeRuntime::new(),
build_window_options: |_, _| Default::default(),
+ session,
})
}
}
@@ -664,6 +669,7 @@ pub enum Event {
ZoomChanged,
}
+#[derive(Debug)]
pub enum OpenVisible {
All,
None,
@@ -730,6 +736,7 @@ pub struct Workspace {
Option<Box<dyn Fn(&mut Self, &mut ViewContext<Self>) -> AnyElement>>,
serializable_items_tx: UnboundedSender<Box<dyn SerializableItemHandle>>,
_items_serializer: Task<Result<()>>,
+ session_id: Option<String>,
}
impl EventEmitter<Event> for Workspace {}
@@ -908,6 +915,8 @@ impl Workspace {
let modal_layer = cx.new_view(|_| ModalLayer::new());
+ let session_id = app_state.session.id().to_owned();
+
let mut active_call = None;
if let Some(call) = ActiveCall::try_global(cx) {
let call = call.clone();
@@ -1023,6 +1032,7 @@ impl Workspace {
render_disconnected_overlay: None,
serializable_items_tx,
_items_serializer,
+ session_id: Some(session_id),
}
}
@@ -1654,10 +1664,20 @@ impl Workspace {
}
}
- this.update(&mut cx, |this, cx| {
- this.save_all_internal(SaveIntent::Close, cx)
- })?
- .await
+ let save_result = this
+ .update(&mut cx, |this, cx| {
+ this.save_all_internal(SaveIntent::Close, cx)
+ })?
+ .await;
+
+ // If we're not quitting, but closing, we remove the workspace from
+ // the current session.
+ if !quitting && save_result.as_ref().map_or(false, |&res| res) {
+ this.update(&mut cx, |this, cx| this.remove_from_session(cx))?
+ .await;
+ }
+
+ save_result
})
}
@@ -3838,6 +3858,11 @@ impl Workspace {
}
}
+ fn remove_from_session(&mut self, cx: &mut WindowContext) -> Task<()> {
+ self.session_id.take();
+ self.serialize_workspace_internal(cx)
+ }
+
fn force_remove_pane(&mut self, pane: &View<Pane>, cx: &mut ViewContext<Workspace>) {
self.panes.retain(|p| p != pane);
self.panes
@@ -3992,7 +4017,6 @@ impl Workspace {
None
};
- // don't save workspace state for the empty workspace.
if let Some(location) = location {
let center_group = build_serialized_pane_group(&self.center.root, cx);
let docks = build_serialized_docks(self, cx);
@@ -4005,6 +4029,7 @@ impl Workspace {
display: Default::default(),
docks,
centered_layout: self.centered_layout,
+ session_id: self.session_id.clone(),
};
return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace));
}
@@ -4258,6 +4283,7 @@ impl Workspace {
#[cfg(any(test, feature = "test-support"))]
pub fn test_new(project: Model<Project>, cx: &mut ViewContext<Self>) -> Self {
use node_runtime::FakeNodeRuntime;
+ use session::Session;
let client = project.read(cx).client();
let user_store = project.read(cx).user_store();
@@ -4272,6 +4298,7 @@ impl Workspace {
fs: project.read(cx).fs().clone(),
build_window_options: |_, _| Default::default(),
node_runtime: FakeNodeRuntime::new(),
+ session: Session::test(),
});
let workspace = Self::new(Default::default(), project, app_state, cx);
workspace.active_pane.update(cx, |pane, cx| pane.focus(cx));
@@ -4873,6 +4900,11 @@ pub async fn last_opened_workspace_paths() -> Option<LocalPaths> {
DB.last_workspace().await.log_err().flatten()
}
+pub fn last_session_workspace_locations(last_session_id: &str) -> Option<Vec<LocalPaths>> {
+ DB.last_session_workspace_locations(last_session_id)
+ .log_err()
+}
+
actions!(collab, [OpenChannelNotes]);
actions!(zed, [OpenLog]);
@@ -47,8 +47,10 @@ pub enum RestoreOnStartupBehaviour {
/// Always start with an empty editor
None,
/// Restore the workspace that was closed last.
- #[default]
LastWorkspace,
+ /// Restore all workspaces that were open when quitting Zed.
+ #[default]
+ LastSession,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
@@ -74,8 +76,8 @@ pub struct WorkspaceSettingsContent {
/// Default: off
pub autosave: Option<AutosaveSetting>,
/// Controls previous session restoration in freshly launched Zed instance.
- /// Values: none, last_workspace
- /// Default: last_workspace
+ /// Values: none, last_workspace, last_session
+ /// Default: last_session
pub restore_on_startup: Option<RestoreOnStartupBehaviour>,
/// The size of the workspace split drop targets on the outer edges.
/// Given as a fraction that will be multiplied by the smaller dimension of the workspace.
@@ -85,6 +85,7 @@ rope.workspace = true
search.workspace = true
serde.workspace = true
serde_json.workspace = true
+session.workspace = true
settings.workspace = true
settings_ui.workspace = true
simplelog.workspace = true
@@ -28,6 +28,7 @@ use assets::Assets;
use node_runtime::RealNodeRuntime;
use parking_lot::Mutex;
use release_channel::{AppCommitSha, AppVersion};
+use session::Session;
use settings::{handle_settings_file_changes, watch_config_file, Settings, SettingsStore};
use simplelog::ConfigBuilder;
use smol::process::Command;
@@ -307,10 +308,15 @@ fn main() {
.block(installation_id())
.ok()
.unzip();
- let session_id = Uuid::new_v4().to_string();
+
+ let session = app.background_executor().block(Session::new());
let app_version = AppVersion::init(env!("CARGO_PKG_VERSION"));
- reliability::init_panic_hook(installation_id.clone(), app_version, session_id.clone());
+ reliability::init_panic_hook(
+ installation_id.clone(),
+ app_version,
+ session.id().to_owned(),
+ );
let (open_listener, mut open_rx) = OpenListener::new();
@@ -422,7 +428,7 @@ fn main() {
client::init(&client, cx);
language::init(cx);
let telemetry = client.telemetry();
- telemetry.start(installation_id.clone(), session_id, cx);
+ telemetry.start(installation_id.clone(), session.id().to_owned(), cx);
telemetry.report_app_event(
match existing_installation_id_found {
Some(false) => "first open",
@@ -438,6 +444,7 @@ fn main() {
build_window_options,
workspace_store,
node_runtime: node_runtime.clone(),
+ session,
});
AppState::set_global(Arc::downgrade(&app_state), cx);
@@ -657,23 +664,18 @@ async fn restore_or_create_workspace(
app_state: Arc<AppState>,
cx: &mut AsyncAppContext,
) -> Result<()> {
- let restore_behaviour = cx.update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup)?;
- let location = match restore_behaviour {
- workspace::RestoreOnStartupBehaviour::LastWorkspace => {
- workspace::last_opened_workspace_paths().await
+ if let Some(locations) = restorable_workspace_locations(cx, &app_state).await {
+ for location in locations {
+ cx.update(|cx| {
+ workspace::open_paths(
+ location.paths().as_ref(),
+ app_state.clone(),
+ workspace::OpenOptions::default(),
+ cx,
+ )
+ })?
+ .await?;
}
- _ => None,
- };
- if let Some(location) = location {
- cx.update(|cx| {
- workspace::open_paths(
- location.paths().as_ref(),
- app_state,
- workspace::OpenOptions::default(),
- cx,
- )
- })?
- .await?;
} else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) {
cx.update(|cx| show_welcome_view(app_state, cx))?.await?;
} else {
@@ -688,6 +690,42 @@ async fn restore_or_create_workspace(
Ok(())
}
+pub(crate) async fn restorable_workspace_locations(
+ cx: &mut AsyncAppContext,
+ app_state: &Arc<AppState>,
+) -> Option<Vec<workspace::LocalPaths>> {
+ let mut restore_behaviour = cx
+ .update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup)
+ .ok()?;
+
+ let last_session_id = app_state.session.last_session_id();
+ if last_session_id.is_none()
+ && matches!(
+ restore_behaviour,
+ workspace::RestoreOnStartupBehaviour::LastSession
+ )
+ {
+ restore_behaviour = workspace::RestoreOnStartupBehaviour::LastWorkspace;
+ }
+
+ match restore_behaviour {
+ workspace::RestoreOnStartupBehaviour::LastWorkspace => {
+ workspace::last_opened_workspace_paths()
+ .await
+ .map(|location| vec![location])
+ }
+ workspace::RestoreOnStartupBehaviour::LastSession => {
+ if let Some(last_session_id) = last_session_id {
+ workspace::last_session_workspace_locations(last_session_id)
+ .filter(|locations| !locations.is_empty())
+ } else {
+ None
+ }
+ }
+ _ => None,
+ }
+}
+
fn init_paths() -> anyhow::Result<()> {
for path in [
paths::config_dir(),
@@ -5,6 +5,7 @@ pub(crate) mod linux_prompts;
#[cfg(not(target_os = "linux"))]
pub(crate) mod only_instance;
mod open_listener;
+pub(crate) mod session;
mod ssh_connection_modal;
pub use app_menus::*;
@@ -3404,7 +3405,7 @@ mod tests {
.unwrap();
}
- fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
+ pub(crate) fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
init_test_with_state(cx, cx.update(|cx| AppState::test(cx)))
}
@@ -1,3 +1,4 @@
+use crate::restorable_workspace_locations;
use crate::{
handle_open_request, init_headless, init_ui, zed::ssh_connection_modal::SshConnectionModal,
};
@@ -528,149 +529,359 @@ pub async fn handle_cli_connection(
return;
}
- let paths = if paths.is_empty() {
- if open_new_workspace == Some(true) {
- vec![]
- } else {
- workspace::last_opened_workspace_paths()
- .await
- .map(|location| {
- location
- .paths()
- .iter()
- .map(|path| PathLikeWithPosition {
- path_like: path.clone(),
- row: None,
- column: None,
- })
- .collect::<Vec<_>>()
- })
- .unwrap_or_default()
- }
- } else {
- paths
+ let open_workspace_result = open_workspaces(
+ paths,
+ open_new_workspace,
+ &responses,
+ wait,
+ app_state.clone(),
+ &mut cx,
+ )
+ .await;
+
+ let status = if open_workspace_result.is_err() { 1 } else { 0 };
+ responses.send(CliResponse::Exit { status }).log_err();
+ }
+ }
+ }
+}
+
+async fn open_workspaces(
+ paths: Vec<String>,
+ open_new_workspace: Option<bool>,
+ responses: &IpcSender<CliResponse>,
+ wait: bool,
+ app_state: Arc<AppState>,
+ mut cx: &mut AsyncAppContext,
+) -> Result<()> {
+ let grouped_paths = if paths.is_empty() {
+ // If no paths are provided, restore from previous workspaces unless a new workspace is requested with -n
+ if open_new_workspace == Some(true) {
+ Vec::new()
+ } else {
+ let locations = restorable_workspace_locations(&mut cx, &app_state).await;
+ locations
+ .into_iter()
+ .flat_map(|locations| {
+ locations
.into_iter()
- .map(|path_with_position_string| {
- PathLikeWithPosition::parse_str(
- &path_with_position_string,
- |_, path_str| {
- Ok::<_, std::convert::Infallible>(
- Path::new(path_str).to_path_buf(),
- )
- },
+ .map(|location| {
+ location
+ .paths()
+ .iter()
+ .map(|path| PathLikeWithPosition {
+ path_like: path.clone(),
+ row: None,
+ column: None,
+ })
+ .collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>()
+ })
+ .collect()
+ }
+ } else {
+ // If paths are provided, parse them (they include positions)
+ let paths_with_position = paths
+ .into_iter()
+ .map(|path_with_position_string| {
+ PathLikeWithPosition::parse_str(&path_with_position_string, |_, path_str| {
+ Ok::<_, std::convert::Infallible>(Path::new(path_str).to_path_buf())
+ })
+ .expect("Infallible")
+ })
+ .collect();
+ vec![paths_with_position]
+ };
+
+ if grouped_paths.is_empty() {
+ // 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)) {
+ cx.update(|cx| show_welcome_view(app_state, cx).detach())
+ .log_err();
+ }
+ // If not the first launch, show an empty window with empty editor
+ else {
+ cx.update(|cx| {
+ workspace::open_new(app_state, cx, |workspace, cx| {
+ Editor::new_file(workspace, &Default::default(), cx)
+ })
+ .detach();
+ })
+ .log_err();
+ }
+ } else {
+ // If there are paths to open, open a workspace for each grouping of paths
+ let mut errored = false;
+
+ for workspace_paths in grouped_paths {
+ let workspace_failed_to_open = open_workspace(
+ workspace_paths,
+ open_new_workspace,
+ wait,
+ responses,
+ &app_state,
+ &mut cx,
+ )
+ .await;
+
+ if workspace_failed_to_open {
+ errored = true
+ }
+ }
+
+ if errored {
+ return Err(anyhow!("failed to open a workspace"));
+ }
+ }
+
+ Ok(())
+}
+
+async fn open_workspace(
+ workspace_paths: Vec<PathLikeWithPosition<PathBuf>>,
+ open_new_workspace: Option<bool>,
+ wait: bool,
+ responses: &IpcSender<CliResponse>,
+ app_state: &Arc<AppState>,
+ cx: &mut AsyncAppContext,
+) -> bool {
+ let mut errored = false;
+
+ match open_paths_with_positions(
+ &workspace_paths,
+ app_state.clone(),
+ workspace::OpenOptions {
+ open_new_workspace,
+ ..Default::default()
+ },
+ cx,
+ )
+ .await
+ {
+ Ok((workspace, items)) => {
+ let mut item_release_futures = Vec::new();
+
+ for (item, path) in items.into_iter().zip(&workspace_paths) {
+ match item {
+ Some(Ok(item)) => {
+ cx.update(|cx| {
+ let released = oneshot::channel();
+ item.on_release(
+ cx,
+ Box::new(move |_| {
+ let _ = released.0.send(());
+ }),
)
- .expect("Infallible")
+ .detach();
+ item_release_futures.push(released.1);
})
- .collect()
- };
-
- let mut errored = false;
-
- if !paths.is_empty() {
- match open_paths_with_positions(
- &paths,
- app_state,
- workspace::OpenOptions {
- open_new_workspace,
- ..Default::default()
- },
- &mut cx,
- )
- .await
- {
- Ok((workspace, items)) => {
- let mut item_release_futures = Vec::new();
-
- for (item, path) in items.into_iter().zip(&paths) {
- match item {
- Some(Ok(item)) => {
- cx.update(|cx| {
- let released = oneshot::channel();
- item.on_release(
- cx,
- Box::new(move |_| {
- let _ = released.0.send(());
- }),
- )
- .detach();
- item_release_futures.push(released.1);
- })
- .log_err();
- }
- Some(Err(err)) => {
- responses
- .send(CliResponse::Stderr {
- message: format!("error opening {path:?}: {err}"),
- })
- .log_err();
- errored = true;
- }
- None => {}
- }
- }
+ .log_err();
+ }
+ Some(Err(err)) => {
+ responses
+ .send(CliResponse::Stderr {
+ message: format!("error opening {path:?}: {err}"),
+ })
+ .log_err();
+ errored = true;
+ }
+ None => {}
+ }
+ }
- if wait {
- let background = cx.background_executor().clone();
- let wait = async move {
- if paths.is_empty() {
- let (done_tx, done_rx) = oneshot::channel();
- let _subscription = workspace.update(&mut cx, |_, cx| {
- cx.on_release(move |_, _, _| {
- let _ = done_tx.send(());
- })
- });
- let _ = done_rx.await;
- } else {
- let _ = futures::future::try_join_all(item_release_futures)
- .await;
- };
- }
- .fuse();
- futures::pin_mut!(wait);
-
- loop {
- // Repeatedly check if CLI is still open to avoid wasting resources
- // waiting for files or workspaces to close.
- let mut timer = background.timer(Duration::from_secs(1)).fuse();
- futures::select_biased! {
- _ = wait => break,
- _ = timer => {
- if responses.send(CliResponse::Ping).is_err() {
- break;
- }
- }
- }
- }
+ if wait {
+ let background = cx.background_executor().clone();
+ let wait = async move {
+ if workspace_paths.is_empty() {
+ let (done_tx, done_rx) = oneshot::channel();
+ let _subscription = workspace.update(cx, |_, cx| {
+ cx.on_release(move |_, _, _| {
+ let _ = done_tx.send(());
+ })
+ });
+ let _ = done_rx.await;
+ } else {
+ let _ = futures::future::try_join_all(item_release_futures).await;
+ };
+ }
+ .fuse();
+
+ futures::pin_mut!(wait);
+
+ loop {
+ // Repeatedly check if CLI is still open to avoid wasting resources
+ // waiting for files or workspaces to close.
+ let mut timer = background.timer(Duration::from_secs(1)).fuse();
+ futures::select_biased! {
+ _ = wait => break,
+ _ = timer => {
+ if responses.send(CliResponse::Ping).is_err() {
+ break;
}
}
- Err(error) => {
- errored = true;
- responses
- .send(CliResponse::Stderr {
- message: format!("error opening {paths:?}: {error}"),
- })
- .log_err();
- }
}
- } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) {
- cx.update(|cx| show_welcome_view(app_state, cx).detach())
- .log_err();
- } else {
- cx.update(|cx| {
- workspace::open_new(app_state, cx, |workspace, cx| {
- Editor::new_file(workspace, &Default::default(), cx)
- })
- .detach();
- })
- .log_err();
}
-
- responses
- .send(CliResponse::Exit {
- status: i32::from(errored),
- })
- .log_err();
}
}
+ Err(error) => {
+ errored = true;
+ responses
+ .send(CliResponse::Stderr {
+ message: format!("error opening {workspace_paths:?}: {error}"),
+ })
+ .log_err();
+ }
+ }
+ errored
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{path::PathBuf, sync::Arc};
+
+ use cli::{
+ ipc::{self},
+ CliResponse,
+ };
+ use editor::Editor;
+ use gpui::TestAppContext;
+ use serde_json::json;
+ use util::paths::PathLikeWithPosition;
+ use workspace::{AppState, Workspace};
+
+ use crate::zed::{open_listener::open_workspace, tests::init_test};
+
+ #[gpui::test]
+ async fn test_open_workspace_with_directory(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/root",
+ json!({
+ "dir1": {
+ "file1.txt": "content1",
+ "file2.txt": "content2",
+ },
+ }),
+ )
+ .await;
+
+ assert_eq!(cx.windows().len(), 0);
+
+ // First open the workspace directory
+ open_workspace_file("/root/dir1", None, app_state.clone(), cx).await;
+
+ assert_eq!(cx.windows().len(), 1);
+ let workspace = cx.windows()[0].downcast::<Workspace>().unwrap();
+ workspace
+ .update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_none())
+ })
+ .unwrap();
+
+ // Now open a file inside that workspace
+ open_workspace_file("/root/dir1/file1.txt", None, app_state.clone(), cx).await;
+
+ assert_eq!(cx.windows().len(), 1);
+ workspace
+ .update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_some());
+ })
+ .unwrap();
+
+ // Now open a file inside that workspace, but tell Zed to open a new window
+ open_workspace_file("/root/dir1/file1.txt", Some(true), app_state.clone(), cx).await;
+
+ assert_eq!(cx.windows().len(), 2);
+
+ let workspace_2 = cx.windows()[1].downcast::<Workspace>().unwrap();
+ workspace_2
+ .update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_some());
+ let items = workspace.items(cx).collect::<Vec<_>>();
+ assert_eq!(items.len(), 1, "Workspace should have two items");
+ })
+ .unwrap();
+ }
+
+ #[gpui::test]
+ async fn test_open_workspace_with_nonexistent_files(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+
+ app_state.fs.as_fake().insert_tree("/root", json!({})).await;
+
+ assert_eq!(cx.windows().len(), 0);
+
+ // Test case 1: Open a single file that does not exist yet
+ open_workspace_file("/root/file5.txt", None, app_state.clone(), cx).await;
+
+ assert_eq!(cx.windows().len(), 1);
+ let workspace_1 = cx.windows()[0].downcast::<Workspace>().unwrap();
+ workspace_1
+ .update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_some())
+ })
+ .unwrap();
+
+ // Test case 2: Open a single file that does not exist yet,
+ // but tell Zed to add it to the current workspace
+ open_workspace_file("/root/file6.txt", Some(false), app_state.clone(), cx).await;
+
+ assert_eq!(cx.windows().len(), 1);
+ workspace_1
+ .update(cx, |workspace, cx| {
+ let items = workspace.items(cx).collect::<Vec<_>>();
+ assert_eq!(items.len(), 2, "Workspace should have two items");
+ })
+ .unwrap();
+
+ // Test case 3: Open a single file that does not exist yet,
+ // but tell Zed to NOT add it to the current workspace
+ open_workspace_file("/root/file7.txt", Some(true), app_state.clone(), cx).await;
+
+ assert_eq!(cx.windows().len(), 2);
+ let workspace_2 = cx.windows()[1].downcast::<Workspace>().unwrap();
+ workspace_2
+ .update(cx, |workspace, cx| {
+ let items = workspace.items(cx).collect::<Vec<_>>();
+ assert_eq!(items.len(), 1, "Workspace should have two items");
+ })
+ .unwrap();
+ }
+
+ async fn open_workspace_file(
+ path: &str,
+ open_new_workspace: Option<bool>,
+ app_state: Arc<AppState>,
+ cx: &mut TestAppContext,
+ ) {
+ let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
+
+ let path_like = PathBuf::from(path);
+ let workspace_paths = vec![PathLikeWithPosition {
+ path_like,
+ row: None,
+ column: None,
+ }];
+
+ let errored = cx
+ .spawn(|mut cx| async move {
+ open_workspace(
+ workspace_paths,
+ open_new_workspace,
+ false,
+ &response_tx,
+ &app_state,
+ &mut cx,
+ )
+ .await
+ })
+ .await;
+
+ assert!(!errored);
}
}
@@ -0,0 +1 @@
+