diff --git a/Cargo.lock b/Cargo.lock index 2ab8ee9169732eafc2bbc1a52a86be4964612e7d..1bedec094f7d5a45cda5c25462a24f4b94cde9c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1380,6 +1380,7 @@ dependencies = [ "schemars", "serde", "serde_derive", + "serde_json", "settings", "smol", "sum_tree", @@ -1545,6 +1546,7 @@ dependencies = [ "schemars", "serde", "serde_derive", + "serde_json", "settings", "smallvec", "theme", @@ -2490,6 +2492,7 @@ dependencies = [ "regex", "serde", "serde_derive", + "serde_json", "settings", "smallvec", "smol", @@ -3784,6 +3787,7 @@ dependencies = [ "lsp", "project", "serde", + "serde_json", "settings", "theme", "tree-sitter", @@ -5511,6 +5515,7 @@ dependencies = [ "picker", "postage", "project", + "serde_json", "settings", "smol", "text", @@ -7764,6 +7769,7 @@ dependencies = [ "search", "serde", "serde_derive", + "serde_json", "settings", "shellexpand", "smallvec", @@ -7844,6 +7850,7 @@ dependencies = [ "pathfinder_color", "rust-embed", "serde", + "serde_json", "simplelog", "strum", "theme", diff --git a/assets/icons/arrow_circle.svg b/assets/icons/arrow_circle.svg index 750e349e2b8c73ef0c78b9974ea100f70ae37abe..90e352bdea7a208356139bed8af5bb3c1301b5ce 100644 --- a/assets/icons/arrow_circle.svg +++ b/assets/icons/arrow_circle.svg @@ -1 +1,6 @@ - + + + + + + diff --git a/crates/ai/src/providers/open_ai/embedding.rs b/crates/ai/src/providers/open_ai/embedding.rs index d5fe4e8c5842709c587b9898862e0a2461461ed2..0a9b6ba969c7c519d337ae27db45af12252efa0b 100644 --- a/crates/ai/src/providers/open_ai/embedding.rs +++ b/crates/ai/src/providers/open_ai/embedding.rs @@ -1,8 +1,8 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use futures::AsyncReadExt; +use gpui::AppContext; use gpui::BackgroundExecutor; -use gpui::{serde_json, AppContext}; use isahc::http::StatusCode; use isahc::prelude::Configurable; use isahc::{AsyncBody, Response}; @@ -11,6 +11,7 @@ use parking_lot::{Mutex, RwLock}; use parse_duration::parse; use postage::watch; use serde::{Deserialize, Serialize}; +use serde_json; use std::env; use std::ops::Add; use std::sync::Arc; diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 452538b9103e99f1f8dd281ffcfb26f35e2f9540..f53343531af09083999e04f1d3dce92eafe34850 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -2298,11 +2298,10 @@ impl ConversationEditor { move |_cx| { let message_id = message.id; let sender = ButtonLike::new("role") + .style(ButtonStyle::Filled) .child(match message.role { Role::User => Label::new("You").color(Color::Default), - Role::Assistant => { - Label::new("Assistant").color(Color::Modified) - } + Role::Assistant => Label::new("Assistant").color(Color::Info), Role::System => Label::new("System").color(Color::Warning), }) .tooltip(|cx| { @@ -2325,11 +2324,12 @@ impl ConversationEditor { } }); - h_stack() + div() + .h_flex() .id(("message_header", message_id.0)) .h_11() + .relative() .gap_1() - .p_1() .child(sender) // TODO: Only show this if the message if the message has been sent .child( @@ -2538,7 +2538,7 @@ impl Render for ConversationEditor { .child( div() .size_full() - .pl_2() + .pl_4() .bg(cx.theme().colors().editor_background) .child(self.editor.clone()), ) @@ -3538,5 +3538,5 @@ fn report_assistant_event( .default_open_ai_model .clone(); - telemetry.report_assistant_event(conversation_id, assistant_kind, model.full_name(), cx) + telemetry.report_assistant_event(conversation_id, assistant_kind, model.full_name()) } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index c419043a722b35fb34f33a224502057e53f3a16b..3561cc33852a84d78ed21371432743d9dc540862 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -310,14 +310,14 @@ impl ActiveCall { }) } - pub fn decline_incoming(&mut self, cx: &mut ModelContext) -> Result<()> { + pub fn decline_incoming(&mut self, _: &mut ModelContext) -> Result<()> { let call = self .incoming_call .0 .borrow_mut() .take() .ok_or_else(|| anyhow!("no incoming call"))?; - report_call_event_for_room("decline incoming", call.room_id, None, &self.client, cx); + report_call_event_for_room("decline incoming", call.room_id, None, &self.client); self.client.send(proto::DeclineCall { room_id: call.room_id, })?; @@ -467,7 +467,7 @@ impl ActiveCall { pub fn report_call_event(&self, operation: &'static str, cx: &mut AppContext) { if let Some(room) = self.room() { let room = room.read(cx); - report_call_event_for_room(operation, room.id(), room.channel_id(), &self.client, cx); + report_call_event_for_room(operation, room.id(), room.channel_id(), &self.client); } } } @@ -477,11 +477,10 @@ pub fn report_call_event_for_room( room_id: u64, channel_id: Option, client: &Arc, - cx: &mut AppContext, ) { let telemetry = client.telemetry(); - telemetry.report_call_event(operation, Some(room_id), channel_id, cx) + telemetry.report_call_event(operation, Some(room_id), channel_id) } pub fn report_call_event_for_channel( @@ -494,12 +493,7 @@ pub fn report_call_event_for_channel( let telemetry = client.telemetry(); - telemetry.report_call_event( - operation, - room.map(|r| r.read(cx).id()), - Some(channel_id), - cx, - ) + telemetry.report_call_event(operation, room.map(|r| r.read(cx).id()), Some(channel_id)) } #[cfg(test)] diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index 20413d7a76ff96a1052a828c1b08f1b884d56ed7..0b07918acfba7b9fe3ad87ef001f5fb7c5eafb30 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -343,12 +343,13 @@ async fn test_channel_messages(cx: &mut TestAppContext) { } fn init_test(cx: &mut AppContext) -> Model { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + let http = FakeHttpClient::with_404_response(); let client = Client::new(http.clone(), cx); let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx)); - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); client::init(&client, cx); crate::init(&client, user_store, cx); diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index c24cbca35be25aeca198cd9178467bfb93db2969..03d6c06fe399842cad6a6de6057503d5f43a5bbb 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -36,6 +36,7 @@ rand.workspace = true schemars.workspace = true serde.workspace = true serde_derive.workspace = true +serde_json.workspace = true smol.workspace = true sysinfo.workspace = true tempfile = "3" diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 2f1e234b7303c1787fe2e6ec806ad94218e9e196..3eae9d92bb9f8d75cbf7ddb63eab26fdd70ecdc4 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -15,8 +15,8 @@ use futures::{ TryFutureExt as _, TryStreamExt, }; use gpui::{ - actions, serde_json, AnyModel, AnyWeakModel, AppContext, AsyncAppContext, Model, - SemanticVersion, Task, WeakModel, + actions, AnyModel, AnyWeakModel, AppContext, AsyncAppContext, Model, SemanticVersion, Task, + WeakModel, }; use lazy_static::lazy_static; use parking_lot::RwLock; @@ -25,6 +25,7 @@ use rand::prelude::*; use rpc::proto::{AnyTypedEnvelope, EntityMessage, EnvelopedMessage, PeerId, RequestMessage}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use serde_json; use settings::Settings; use std::{ any::TypeId, @@ -45,7 +46,7 @@ use util::http::HttpClient; use util::{ResultExt, TryFutureExt}; pub use rpc::*; -pub use telemetry::ClickhouseEvent; +pub use telemetry::Event; pub use user::*; lazy_static! { @@ -501,8 +502,7 @@ impl Client { })); } Status::SignedOut | Status::UpgradeRequired => { - cx.update(|cx| self.telemetry.set_authenticated_user_info(None, false, cx)) - .log_err(); + self.telemetry.set_authenticated_user_info(None, false); state._reconnect_task.take(); } _ => {} @@ -1405,11 +1405,13 @@ mod tests { use gpui::{BackgroundExecutor, Context, TestAppContext}; use parking_lot::Mutex; + use settings::SettingsStore; use std::future; use util::http::FakeHttpClient; #[gpui::test(iterations = 10)] async fn test_reconnection(cx: &mut TestAppContext) { + init_test(cx); let user_id = 5; let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let server = FakeServer::for_client(user_id, &client, cx).await; @@ -1444,6 +1446,7 @@ mod tests { #[gpui::test(iterations = 10)] async fn test_connection_timeout(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); let user_id = 5; let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let mut status = client.status(); @@ -1515,6 +1518,7 @@ mod tests { cx: &mut TestAppContext, executor: BackgroundExecutor, ) { + init_test(cx); let auth_count = Arc::new(Mutex::new(0)); let dropped_auth_count = Arc::new(Mutex::new(0)); let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); @@ -1563,6 +1567,7 @@ mod tests { #[gpui::test] async fn test_subscribing_to_entity(cx: &mut TestAppContext) { + init_test(cx); let user_id = 5; let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let server = FakeServer::for_client(user_id, &client, cx).await; @@ -1616,6 +1621,7 @@ mod tests { #[gpui::test] async fn test_subscribing_after_dropping_subscription(cx: &mut TestAppContext) { + init_test(cx); let user_id = 5; let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let server = FakeServer::for_client(user_id, &client, cx).await; @@ -1644,6 +1650,7 @@ mod tests { #[gpui::test] async fn test_dropping_subscription_in_handler(cx: &mut TestAppContext) { + init_test(cx); let user_id = 5; let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let server = FakeServer::for_client(user_id, &client, cx).await; @@ -1672,4 +1679,11 @@ mod tests { id: usize, subscription: Option, } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } } diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 2391c5f3b55a0c96133ed82ae964ecd969cc68e2..26b5748187ff735ebedf2cdb38753bdb6d9fcedc 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -1,11 +1,12 @@ use crate::{TelemetrySettings, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL}; use chrono::{DateTime, Utc}; use futures::Future; -use gpui::{serde_json, AppContext, AppMetadata, BackgroundExecutor, Task}; +use gpui::{AppContext, AppMetadata, BackgroundExecutor, Task}; use lazy_static::lazy_static; use parking_lot::Mutex; use serde::Serialize; -use settings::Settings; +use serde_json; +use settings::{Settings, SettingsStore}; use std::{env, io::Write, mem, path::PathBuf, sync::Arc, time::Duration}; use sysinfo::{ CpuRefreshKind, Pid, PidExt, ProcessExt, ProcessRefreshKind, RefreshKind, System, SystemExt, @@ -17,32 +18,32 @@ use util::{channel::ReleaseChannel, TryFutureExt}; pub struct Telemetry { http_client: Arc, executor: BackgroundExecutor, - state: Mutex, + state: Arc>, } struct TelemetryState { + settings: TelemetrySettings, metrics_id: Option>, // Per logged-in user installation_id: Option>, // Per app installation (different for dev, nightly, preview, and stable) session_id: Option>, // Per app launch release_channel: Option<&'static str>, app_metadata: AppMetadata, architecture: &'static str, - clickhouse_events_queue: Vec, - flush_clickhouse_events_task: Option>, + events_queue: Vec, + flush_events_task: Option>, log_file: Option, is_staff: Option, first_event_datetime: Option>, } -const CLICKHOUSE_EVENTS_URL_PATH: &'static str = "/api/events"; +const EVENTS_URL_PATH: &'static str = "/api/events"; lazy_static! { - static ref CLICKHOUSE_EVENTS_URL: String = - format!("{}{}", *ZED_SERVER_URL, CLICKHOUSE_EVENTS_URL_PATH); + static ref EVENTS_URL: String = format!("{}{}", *ZED_SERVER_URL, EVENTS_URL_PATH); } #[derive(Serialize, Debug)] -struct ClickhouseEventRequestBody { +struct EventRequestBody { token: &'static str, installation_id: Option>, session_id: Option>, @@ -52,14 +53,14 @@ struct ClickhouseEventRequestBody { os_version: Option, architecture: &'static str, release_channel: Option<&'static str>, - events: Vec, + events: Vec, } #[derive(Serialize, Debug)] -struct ClickhouseEventWrapper { +struct EventWrapper { signed_in: bool, #[serde(flatten)] - event: ClickhouseEvent, + event: Event, } #[derive(Serialize, Debug)] @@ -71,7 +72,7 @@ pub enum AssistantKind { #[derive(Serialize, Debug)] #[serde(tag = "type")] -pub enum ClickhouseEvent { +pub enum Event { Editor { operation: &'static str, file_extension: Option, @@ -139,45 +140,61 @@ impl Telemetry { None }; + TelemetrySettings::register(cx); + + let state = Arc::new(Mutex::new(TelemetryState { + settings: TelemetrySettings::get_global(cx).clone(), + app_metadata: cx.app_metadata(), + architecture: env::consts::ARCH, + release_channel, + installation_id: None, + metrics_id: None, + session_id: None, + events_queue: Default::default(), + flush_events_task: Default::default(), + log_file: None, + is_staff: None, + first_event_datetime: None, + })); + + cx.observe_global::({ + let state = state.clone(); + + move |cx| { + let mut state = state.lock(); + state.settings = TelemetrySettings::get_global(cx).clone(); + } + }) + .detach(); + // TODO: Replace all hardware stuff with nested SystemSpecs json let this = Arc::new(Self { http_client: client, executor: cx.background_executor().clone(), - state: Mutex::new(TelemetryState { - app_metadata: cx.app_metadata(), - architecture: env::consts::ARCH, - release_channel, - installation_id: None, - metrics_id: None, - session_id: None, - clickhouse_events_queue: Default::default(), - flush_clickhouse_events_task: Default::default(), - log_file: None, - is_staff: None, - first_event_datetime: None, - }), + state, }); // We should only ever have one instance of Telemetry, leak the subscription to keep it alive // rather than store in TelemetryState, complicating spawn as subscriptions are not Send std::mem::forget(cx.on_app_quit({ let this = this.clone(); - move |cx| this.shutdown_telemetry(cx) + move |_| this.shutdown_telemetry() })); this } #[cfg(any(test, feature = "test-support"))] - fn shutdown_telemetry(self: &Arc, _: &mut AppContext) -> impl Future { + fn shutdown_telemetry(self: &Arc) -> impl Future { Task::ready(()) } // Skip calling this function in tests. // TestAppContext ends up calling this function on shutdown and it panics when trying to find the TelemetrySettings #[cfg(not(any(test, feature = "test-support")))] - fn shutdown_telemetry(self: &Arc, cx: &mut AppContext) -> impl Future { - self.report_app_event("close", true, cx); + fn shutdown_telemetry(self: &Arc) -> impl Future { + self.report_app_event("close"); + self.flush_events(); Task::ready(()) } @@ -197,7 +214,7 @@ impl Telemetry { drop(state); let this = self.clone(); - cx.spawn(|cx| async move { + cx.spawn(|_| async move { // Avoiding calling `System::new_all()`, as there have been crashes related to it let refresh_kind = RefreshKind::new() .with_memory() // For memory usage @@ -226,11 +243,8 @@ impl Telemetry { return; }; - cx.update(|cx| { - this.report_memory_event(process.memory(), process.virtual_memory(), cx); - this.report_cpu_event(process.cpu_usage(), system.cpus().len() as u32, cx); - }) - .ok(); + this.report_memory_event(process.memory(), process.virtual_memory()); + this.report_cpu_event(process.cpu_usage(), system.cpus().len() as u32); } }) .detach(); @@ -240,13 +254,13 @@ impl Telemetry { self: &Arc, metrics_id: Option, is_staff: bool, - cx: &AppContext, ) { - if !TelemetrySettings::get_global(cx).metrics { + let mut state = self.state.lock(); + + if !state.settings.metrics { return; } - let mut state = self.state.lock(); let metrics_id: Option> = metrics_id.map(|id| id.into()); state.metrics_id = metrics_id.clone(); state.is_staff = Some(is_staff); @@ -260,9 +274,8 @@ impl Telemetry { operation: &'static str, copilot_enabled: bool, copilot_enabled_for_language: bool, - cx: &AppContext, ) { - let event = ClickhouseEvent::Editor { + let event = Event::Editor { file_extension, vim_mode, operation, @@ -271,7 +284,7 @@ impl Telemetry { milliseconds_since_first_event: self.milliseconds_since_first_event(), }; - self.report_clickhouse_event(event, false, cx) + self.report_event(event) } pub fn report_copilot_event( @@ -279,16 +292,15 @@ impl Telemetry { suggestion_id: Option, suggestion_accepted: bool, file_extension: Option, - cx: &AppContext, ) { - let event = ClickhouseEvent::Copilot { + let event = Event::Copilot { suggestion_id, suggestion_accepted, file_extension, milliseconds_since_first_event: self.milliseconds_since_first_event(), }; - self.report_clickhouse_event(event, false, cx) + self.report_event(event) } pub fn report_assistant_event( @@ -296,16 +308,15 @@ impl Telemetry { conversation_id: Option, kind: AssistantKind, model: &'static str, - cx: &AppContext, ) { - let event = ClickhouseEvent::Assistant { + let event = Event::Assistant { conversation_id, kind, model, milliseconds_since_first_event: self.milliseconds_since_first_event(), }; - self.report_clickhouse_event(event, false, cx) + self.report_event(event) } pub fn report_call_event( @@ -313,75 +324,58 @@ impl Telemetry { operation: &'static str, room_id: Option, channel_id: Option, - cx: &AppContext, ) { - let event = ClickhouseEvent::Call { + let event = Event::Call { operation, room_id, channel_id, milliseconds_since_first_event: self.milliseconds_since_first_event(), }; - self.report_clickhouse_event(event, false, cx) + self.report_event(event) } - pub fn report_cpu_event( - self: &Arc, - usage_as_percentage: f32, - core_count: u32, - cx: &AppContext, - ) { - let event = ClickhouseEvent::Cpu { + pub fn report_cpu_event(self: &Arc, usage_as_percentage: f32, core_count: u32) { + let event = Event::Cpu { usage_as_percentage, core_count, milliseconds_since_first_event: self.milliseconds_since_first_event(), }; - self.report_clickhouse_event(event, false, cx) + self.report_event(event) } pub fn report_memory_event( self: &Arc, memory_in_bytes: u64, virtual_memory_in_bytes: u64, - cx: &AppContext, ) { - let event = ClickhouseEvent::Memory { + let event = Event::Memory { memory_in_bytes, virtual_memory_in_bytes, milliseconds_since_first_event: self.milliseconds_since_first_event(), }; - self.report_clickhouse_event(event, false, cx) + self.report_event(event) } - pub fn report_app_event( - self: &Arc, - operation: &'static str, - immediate_flush: bool, - cx: &AppContext, - ) { - let event = ClickhouseEvent::App { + pub fn report_app_event(self: &Arc, operation: &'static str) { + let event = Event::App { operation, milliseconds_since_first_event: self.milliseconds_since_first_event(), }; - self.report_clickhouse_event(event, immediate_flush, cx) + self.report_event(event) } - pub fn report_setting_event( - self: &Arc, - setting: &'static str, - value: String, - cx: &AppContext, - ) { - let event = ClickhouseEvent::Setting { + pub fn report_setting_event(self: &Arc, setting: &'static str, value: String) { + let event = Event::Setting { setting, value, milliseconds_since_first_event: self.milliseconds_since_first_event(), }; - self.report_clickhouse_event(event, false, cx) + self.report_event(event) } fn milliseconds_since_first_event(&self) -> i64 { @@ -398,32 +392,26 @@ impl Telemetry { } } - fn report_clickhouse_event( - self: &Arc, - event: ClickhouseEvent, - immediate_flush: bool, - cx: &AppContext, - ) { - if !TelemetrySettings::get_global(cx).metrics { + fn report_event(self: &Arc, event: Event) { + let mut state = self.state.lock(); + + if !state.settings.metrics { return; } - let mut state = self.state.lock(); let signed_in = state.metrics_id.is_some(); - state - .clickhouse_events_queue - .push(ClickhouseEventWrapper { signed_in, event }); + state.events_queue.push(EventWrapper { signed_in, event }); if state.installation_id.is_some() { - if immediate_flush || state.clickhouse_events_queue.len() >= MAX_QUEUE_LEN { + if state.events_queue.len() >= MAX_QUEUE_LEN { drop(state); - self.flush_clickhouse_events(); + self.flush_events(); } else { let this = self.clone(); let executor = self.executor.clone(); - state.flush_clickhouse_events_task = Some(self.executor.spawn(async move { + state.flush_events_task = Some(self.executor.spawn(async move { executor.timer(DEBOUNCE_INTERVAL).await; - this.flush_clickhouse_events(); + this.flush_events(); })); } } @@ -441,11 +429,11 @@ impl Telemetry { self.state.lock().is_staff } - fn flush_clickhouse_events(self: &Arc) { + pub fn flush_events(self: &Arc) { let mut state = self.state.lock(); state.first_event_datetime = None; - let mut events = mem::take(&mut state.clickhouse_events_queue); - state.flush_clickhouse_events_task.take(); + let mut events = mem::take(&mut state.events_queue); + state.flush_events_task.take(); drop(state); let this = self.clone(); @@ -466,7 +454,7 @@ impl Telemetry { { let state = this.state.lock(); - let request_body = ClickhouseEventRequestBody { + let request_body = EventRequestBody { token: ZED_SECRET_CLIENT_TOKEN, installation_id: state.installation_id.clone(), session_id: state.session_id.clone(), @@ -490,7 +478,7 @@ impl Telemetry { } this.http_client - .post_json(CLICKHOUSE_EVENTS_URL.as_str(), json_bytes.into()) + .post_json(EVENTS_URL.as_str(), json_bytes.into()) .await?; anyhow::Ok(()) } diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index b08d423cae0fb48a5ed2f06c9461662a46522ee1..1c288c875db39e3d3d7aed81ed836c1a69b41f5e 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -164,7 +164,6 @@ impl UserStore { client.telemetry.set_authenticated_user_info( Some(info.metrics_id.clone()), info.staff, - cx, ) } })?; diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 878f0d67cfaf6ec03cf51880c2dff67566454ec0..9a87f91b8182b69b32539810e9fc46f7afdadb14 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -888,6 +888,14 @@ impl Database { .exec(&*tx) .await?; + follower::Entity::delete_many() + .filter( + Condition::all() + .add(follower::Column::FollowerConnectionId.eq(connection.id as i32)), + ) + .exec(&*tx) + .await?; + // Unshare projects. project::Entity::delete_many() .filter( diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index 5178df408f95b8809495836576c3f1c74159cf85..0486e294619fd4fd34604c6cc4113fb03f1057ad 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -1,1890 +1,1777 @@ -//todo!(workspace) - -// use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; -// use call::ActiveCall; -// use collab_ui::notifications::project_shared_notification::ProjectSharedNotification; -// use editor::{Editor, ExcerptRange, MultiBuffer}; -// use gpui::{point, BackgroundExecutor, TestAppContext, View, VisualTestContext, WindowContext}; -// use live_kit_client::MacOSDisplay; -// use project::project_settings::ProjectSettings; -// use rpc::proto::PeerId; -// use serde_json::json; -// use settings::SettingsStore; -// use std::borrow::Cow; -// use workspace::{ -// dock::{test::TestPanel, DockPosition}, -// item::{test::TestItem, ItemHandle as _}, -// shared_screen::SharedScreen, -// SplitDirection, Workspace, -// }; - -// #[gpui::test(iterations = 10)] -// async fn test_basic_following( -// executor: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// cx_c: &mut TestAppContext, -// cx_d: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(executor.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; -// let client_c = server.create_client(cx_c, "user_c").await; -// let client_d = server.create_client(cx_d, "user_d").await; -// server -// .create_room(&mut [ -// (&client_a, cx_a), -// (&client_b, cx_b), -// (&client_c, cx_c), -// (&client_d, cx_d), -// ]) -// .await; -// let active_call_a = cx_a.read(ActiveCall::global); -// let active_call_b = cx_b.read(ActiveCall::global); - -// cx_a.update(editor::init); -// cx_b.update(editor::init); - -// client_a -// .fs() -// .insert_tree( -// "/a", -// json!({ -// "1.txt": "one\none\none", -// "2.txt": "two\ntwo\ntwo", -// "3.txt": "three\nthree\nthree", -// }), -// ) -// .await; -// let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; -// active_call_a -// .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) -// .await -// .unwrap(); - -// let project_id = active_call_a -// .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) -// .await -// .unwrap(); -// let project_b = client_b.build_remote_project(project_id, cx_b).await; -// active_call_b -// .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) -// .await -// .unwrap(); - -// let window_a = client_a.build_workspace(&project_a, cx_a); -// let workspace_a = window_a.root(cx_a).unwrap(); -// let window_b = client_b.build_workspace(&project_b, cx_b); -// let workspace_b = window_b.root(cx_b).unwrap(); - -// todo!("could be wrong") -// let mut cx_a = VisualTestContext::from_window(*window_a, cx_a); -// let cx_a = &mut cx_a; -// let mut cx_b = VisualTestContext::from_window(*window_b, cx_b); -// let cx_b = &mut cx_b; -// let mut cx_c = VisualTestContext::from_window(*window_c, cx_c); -// let cx_c = &mut cx_c; -// let mut cx_d = VisualTestContext::from_window(*window_d, cx_d); -// let cx_d = &mut cx_d; - -// // Client A opens some editors. -// let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone()); -// let editor_a1 = workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id, "1.txt"), None, true, cx) -// }) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); -// let editor_a2 = workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id, "2.txt"), None, true, cx) -// }) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); - -// // Client B opens an editor. -// let editor_b1 = workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.open_path((worktree_id, "1.txt"), None, true, cx) -// }) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); - -// let peer_id_a = client_a.peer_id().unwrap(); -// let peer_id_b = client_b.peer_id().unwrap(); -// let peer_id_c = client_c.peer_id().unwrap(); -// let peer_id_d = client_d.peer_id().unwrap(); - -// // Client A updates their selections in those editors -// editor_a1.update(cx_a, |editor, cx| { -// editor.handle_input("a", cx); -// editor.handle_input("b", cx); -// editor.handle_input("c", cx); -// editor.select_left(&Default::default(), cx); -// assert_eq!(editor.selections.ranges(cx), vec![3..2]); -// }); -// editor_a2.update(cx_a, |editor, cx| { -// editor.handle_input("d", cx); -// editor.handle_input("e", cx); -// editor.select_left(&Default::default(), cx); -// assert_eq!(editor.selections.ranges(cx), vec![2..1]); -// }); - -// // When client B starts following client A, all visible view states are replicated to client B. -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.follow(peer_id_a, cx).unwrap() -// }) -// .await -// .unwrap(); - -// cx_c.executor().run_until_parked(); -// let editor_b2 = workspace_b.update(cx_b, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); -// assert_eq!( -// cx_b.read(|cx| editor_b2.project_path(cx)), -// Some((worktree_id, "2.txt").into()) -// ); -// assert_eq!( -// editor_b2.update(cx_b, |editor, cx| editor.selections.ranges(cx)), -// vec![2..1] -// ); -// assert_eq!( -// editor_b1.update(cx_b, |editor, cx| editor.selections.ranges(cx)), -// vec![3..2] -// ); - -// cx_c.executor().run_until_parked(); -// let active_call_c = cx_c.read(ActiveCall::global); -// let project_c = client_c.build_remote_project(project_id, cx_c).await; -// let window_c = client_c.build_workspace(&project_c, cx_c); -// let workspace_c = window_c.root(cx_c).unwrap(); -// active_call_c -// .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx)) -// .await -// .unwrap(); -// drop(project_c); - -// // Client C also follows client A. -// workspace_c -// .update(cx_c, |workspace, cx| { -// workspace.follow(peer_id_a, cx).unwrap() -// }) -// .await -// .unwrap(); - -// cx_d.executor().run_until_parked(); -// let active_call_d = cx_d.read(ActiveCall::global); -// let project_d = client_d.build_remote_project(project_id, cx_d).await; -// let workspace_d = client_d -// .build_workspace(&project_d, cx_d) -// .root(cx_d) -// .unwrap(); -// active_call_d -// .update(cx_d, |call, cx| call.set_location(Some(&project_d), cx)) -// .await -// .unwrap(); -// drop(project_d); - -// // All clients see that clients B and C are following client A. -// cx_c.executor().run_until_parked(); -// for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { -// assert_eq!( -// followers_by_leader(project_id, cx), -// &[(peer_id_a, vec![peer_id_b, peer_id_c])], -// "followers seen by {name}" -// ); -// } - -// // Client C unfollows client A. -// workspace_c.update(cx_c, |workspace, cx| { -// workspace.unfollow(&workspace.active_pane().clone(), cx); -// }); - -// // All clients see that clients B is following client A. -// cx_c.executor().run_until_parked(); -// for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { -// assert_eq!( -// followers_by_leader(project_id, cx), -// &[(peer_id_a, vec![peer_id_b])], -// "followers seen by {name}" -// ); -// } - -// // Client C re-follows client A. -// workspace_c -// .update(cx_c, |workspace, cx| { -// workspace.follow(peer_id_a, cx).unwrap() -// }) -// .await -// .unwrap(); - -// // All clients see that clients B and C are following client A. -// cx_c.executor().run_until_parked(); -// for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { -// assert_eq!( -// followers_by_leader(project_id, cx), -// &[(peer_id_a, vec![peer_id_b, peer_id_c])], -// "followers seen by {name}" -// ); -// } - -// // Client D follows client B, then switches to following client C. -// workspace_d -// .update(cx_d, |workspace, cx| { -// workspace.follow(peer_id_b, cx).unwrap() -// }) -// .await -// .unwrap(); -// workspace_d -// .update(cx_d, |workspace, cx| { -// workspace.follow(peer_id_c, cx).unwrap() -// }) -// .await -// .unwrap(); - -// // All clients see that D is following C -// cx_d.executor().run_until_parked(); -// for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { -// assert_eq!( -// followers_by_leader(project_id, cx), -// &[ -// (peer_id_a, vec![peer_id_b, peer_id_c]), -// (peer_id_c, vec![peer_id_d]) -// ], -// "followers seen by {name}" -// ); -// } - -// // Client C closes the project. -// window_c.remove(cx_c); -// cx_c.drop_last(workspace_c); - -// // Clients A and B see that client B is following A, and client C is not present in the followers. -// cx_c.executor().run_until_parked(); -// for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { -// assert_eq!( -// followers_by_leader(project_id, cx), -// &[(peer_id_a, vec![peer_id_b]),], -// "followers seen by {name}" -// ); -// } - -// // When client A activates a different editor, client B does so as well. -// workspace_a.update(cx_a, |workspace, cx| { -// workspace.activate_item(&editor_a1, cx) -// }); -// executor.run_until_parked(); -// workspace_b.update(cx_b, |workspace, cx| { -// assert_eq!( -// workspace.active_item(cx).unwrap().item_id(), -// editor_b1.item_id() -// ); -// }); - -// // When client A opens a multibuffer, client B does so as well. -// let multibuffer_a = cx_a.build_model(|cx| { -// let buffer_a1 = project_a.update(cx, |project, cx| { -// project -// .get_open_buffer(&(worktree_id, "1.txt").into(), cx) -// .unwrap() -// }); -// let buffer_a2 = project_a.update(cx, |project, cx| { -// project -// .get_open_buffer(&(worktree_id, "2.txt").into(), cx) -// .unwrap() -// }); -// let mut result = MultiBuffer::new(0); -// result.push_excerpts( -// buffer_a1, -// [ExcerptRange { -// context: 0..3, -// primary: None, -// }], -// cx, -// ); -// result.push_excerpts( -// buffer_a2, -// [ExcerptRange { -// context: 4..7, -// primary: None, -// }], -// cx, -// ); -// result -// }); -// let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| { -// let editor = -// cx.build_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx)); -// workspace.add_item(Box::new(editor.clone()), cx); -// editor -// }); -// executor.run_until_parked(); -// let multibuffer_editor_b = workspace_b.update(cx_b, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); -// assert_eq!( -// multibuffer_editor_a.update(cx_a, |editor, cx| editor.text(cx)), -// multibuffer_editor_b.update(cx_b, |editor, cx| editor.text(cx)), -// ); - -// // When client A navigates back and forth, client B does so as well. -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// executor.run_until_parked(); -// workspace_b.update(cx_b, |workspace, cx| { -// assert_eq!( -// workspace.active_item(cx).unwrap().item_id(), -// editor_b1.item_id() -// ); -// }); - -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// executor.run_until_parked(); -// workspace_b.update(cx_b, |workspace, cx| { -// assert_eq!( -// workspace.active_item(cx).unwrap().item_id(), -// editor_b2.item_id() -// ); -// }); - -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.go_forward(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// executor.run_until_parked(); -// workspace_b.update(cx_b, |workspace, cx| { -// assert_eq!( -// workspace.active_item(cx).unwrap().item_id(), -// editor_b1.item_id() -// ); -// }); - -// // Changes to client A's editor are reflected on client B. -// editor_a1.update(cx_a, |editor, cx| { -// editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2])); -// }); -// executor.run_until_parked(); -// editor_b1.update(cx_b, |editor, cx| { -// assert_eq!(editor.selections.ranges(cx), &[1..1, 2..2]); -// }); - -// editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx)); -// executor.run_until_parked(); -// editor_b1.update(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO")); - -// editor_a1.update(cx_a, |editor, cx| { -// editor.change_selections(None, cx, |s| s.select_ranges([3..3])); -// editor.set_scroll_position(point(0., 100.), cx); -// }); -// executor.run_until_parked(); -// editor_b1.update(cx_b, |editor, cx| { -// assert_eq!(editor.selections.ranges(cx), &[3..3]); -// }); - -// // After unfollowing, client B stops receiving updates from client A. -// workspace_b.update(cx_b, |workspace, cx| { -// workspace.unfollow(&workspace.active_pane().clone(), cx) -// }); -// workspace_a.update(cx_a, |workspace, cx| { -// workspace.activate_item(&editor_a2, cx) -// }); -// executor.run_until_parked(); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, cx| workspace -// .active_item(cx) -// .unwrap() -// .item_id()), -// editor_b1.item_id() -// ); - -// // Client A starts following client B. -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.follow(peer_id_b, cx).unwrap() -// }) -// .await -// .unwrap(); -// assert_eq!( -// workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), -// Some(peer_id_b) -// ); -// assert_eq!( -// workspace_a.update(cx_a, |workspace, cx| workspace -// .active_item(cx) -// .unwrap() -// .item_id()), -// editor_a1.item_id() -// ); - -// // Client B activates an external window, which causes a new screen-sharing item to be added to the pane. -// let display = MacOSDisplay::new(); -// active_call_b -// .update(cx_b, |call, cx| call.set_location(None, cx)) -// .await -// .unwrap(); -// active_call_b -// .update(cx_b, |call, cx| { -// call.room().unwrap().update(cx, |room, cx| { -// room.set_display_sources(vec![display.clone()]); -// room.share_screen(cx) -// }) -// }) -// .await -// .unwrap(); -// executor.run_until_parked(); -// let shared_screen = workspace_a.update(cx_a, |workspace, cx| { -// workspace -// .active_item(cx) -// .expect("no active item") -// .downcast::() -// .expect("active item isn't a shared screen") -// }); - -// // Client B activates Zed again, which causes the previous editor to become focused again. -// active_call_b -// .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) -// .await -// .unwrap(); -// executor.run_until_parked(); -// workspace_a.update(cx_a, |workspace, cx| { -// assert_eq!( -// workspace.active_item(cx).unwrap().item_id(), -// editor_a1.item_id() -// ) -// }); - -// // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer. -// workspace_b.update(cx_b, |workspace, cx| { -// workspace.activate_item(&multibuffer_editor_b, cx) -// }); -// executor.run_until_parked(); -// workspace_a.update(cx_a, |workspace, cx| { -// assert_eq!( -// workspace.active_item(cx).unwrap().item_id(), -// multibuffer_editor_a.item_id() -// ) -// }); - -// // Client B activates a panel, and the previously-opened screen-sharing item gets activated. -// let panel = window_b.build_view(cx_b, |_| TestPanel::new(DockPosition::Left)); -// workspace_b.update(cx_b, |workspace, cx| { -// workspace.add_panel(panel, cx); -// workspace.toggle_panel_focus::(cx); -// }); -// executor.run_until_parked(); -// assert_eq!( -// workspace_a.update(cx_a, |workspace, cx| workspace -// .active_item(cx) -// .unwrap() -// .item_id()), -// shared_screen.item_id() -// ); - -// // Toggling the focus back to the pane causes client A to return to the multibuffer. -// workspace_b.update(cx_b, |workspace, cx| { -// workspace.toggle_panel_focus::(cx); -// }); -// executor.run_until_parked(); -// workspace_a.update(cx_a, |workspace, cx| { -// assert_eq!( -// workspace.active_item(cx).unwrap().item_id(), -// multibuffer_editor_a.item_id() -// ) -// }); - -// // Client B activates an item that doesn't implement following, -// // so the previously-opened screen-sharing item gets activated. -// let unfollowable_item = window_b.build_view(cx_b, |_| TestItem::new()); -// workspace_b.update(cx_b, |workspace, cx| { -// workspace.active_pane().update(cx, |pane, cx| { -// pane.add_item(Box::new(unfollowable_item), true, true, None, cx) -// }) -// }); -// executor.run_until_parked(); -// assert_eq!( -// workspace_a.update(cx_a, |workspace, cx| workspace -// .active_item(cx) -// .unwrap() -// .item_id()), -// shared_screen.item_id() -// ); - -// // Following interrupts when client B disconnects. -// client_b.disconnect(&cx_b.to_async()); -// executor.advance_clock(RECONNECT_TIMEOUT); -// assert_eq!( -// workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), -// None -// ); -// } - -// #[gpui::test] -// async fn test_following_tab_order( -// executor: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(executor.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; -// server -// .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) -// .await; -// let active_call_a = cx_a.read(ActiveCall::global); -// let active_call_b = cx_b.read(ActiveCall::global); - -// cx_a.update(editor::init); -// cx_b.update(editor::init); - -// client_a -// .fs() -// .insert_tree( -// "/a", -// json!({ -// "1.txt": "one", -// "2.txt": "two", -// "3.txt": "three", -// }), -// ) -// .await; -// let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; -// active_call_a -// .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) -// .await -// .unwrap(); - -// let project_id = active_call_a -// .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) -// .await -// .unwrap(); -// let project_b = client_b.build_remote_project(project_id, cx_b).await; -// active_call_b -// .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) -// .await -// .unwrap(); - -// let workspace_a = client_a -// .build_workspace(&project_a, cx_a) -// .root(cx_a) -// .unwrap(); -// let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone()); - -// let workspace_b = client_b -// .build_workspace(&project_b, cx_b) -// .root(cx_b) -// .unwrap(); -// let pane_b = workspace_b.update(cx_b, |workspace, _| workspace.active_pane().clone()); - -// let client_b_id = project_a.update(cx_a, |project, _| { -// project.collaborators().values().next().unwrap().peer_id -// }); - -// //Open 1, 3 in that order on client A -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id, "1.txt"), None, true, cx) -// }) -// .await -// .unwrap(); -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id, "3.txt"), None, true, cx) -// }) -// .await -// .unwrap(); - -// let pane_paths = |pane: &View, cx: &mut TestAppContext| { -// pane.update(cx, |pane, cx| { -// pane.items() -// .map(|item| { -// item.project_path(cx) -// .unwrap() -// .path -// .to_str() -// .unwrap() -// .to_owned() -// }) -// .collect::>() -// }) -// }; - -// //Verify that the tabs opened in the order we expect -// assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt"]); - -// //Follow client B as client A -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.follow(client_b_id, cx).unwrap() -// }) -// .await -// .unwrap(); - -// //Open just 2 on client B -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.open_path((worktree_id, "2.txt"), None, true, cx) -// }) -// .await -// .unwrap(); -// executor.run_until_parked(); - -// // Verify that newly opened followed file is at the end -// assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]); - -// //Open just 1 on client B -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.open_path((worktree_id, "1.txt"), None, true, cx) -// }) -// .await -// .unwrap(); -// assert_eq!(&pane_paths(&pane_b, cx_b), &["2.txt", "1.txt"]); -// executor.run_until_parked(); - -// // Verify that following into 1 did not reorder -// assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]); -// } - -// #[gpui::test(iterations = 10)] -// async fn test_peers_following_each_other( -// executor: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(executor.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; -// server -// .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) -// .await; -// let active_call_a = cx_a.read(ActiveCall::global); -// let active_call_b = cx_b.read(ActiveCall::global); - -// cx_a.update(editor::init); -// cx_b.update(editor::init); - -// // Client A shares a project. -// client_a -// .fs() -// .insert_tree( -// "/a", -// json!({ -// "1.txt": "one", -// "2.txt": "two", -// "3.txt": "three", -// "4.txt": "four", -// }), -// ) -// .await; -// let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; -// active_call_a -// .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) -// .await -// .unwrap(); -// let project_id = active_call_a -// .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) -// .await -// .unwrap(); - -// // Client B joins the project. -// let project_b = client_b.build_remote_project(project_id, cx_b).await; -// active_call_b -// .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) -// .await -// .unwrap(); - -// // Client A opens a file. -// let workspace_a = client_a -// .build_workspace(&project_a, cx_a) -// .root(cx_a) -// .unwrap(); -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id, "1.txt"), None, true, cx) -// }) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); - -// // Client B opens a different file. -// let workspace_b = client_b -// .build_workspace(&project_b, cx_b) -// .root(cx_b) -// .unwrap(); -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.open_path((worktree_id, "2.txt"), None, true, cx) -// }) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); - -// // Clients A and B follow each other in split panes -// workspace_a.update(cx_a, |workspace, cx| { -// workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx); -// }); -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.follow(client_b.peer_id().unwrap(), cx).unwrap() -// }) -// .await -// .unwrap(); -// workspace_b.update(cx_b, |workspace, cx| { -// workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx); -// }); -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.follow(client_a.peer_id().unwrap(), cx).unwrap() -// }) -// .await -// .unwrap(); - -// // Clients A and B return focus to the original files they had open -// workspace_a.update(cx_a, |workspace, cx| workspace.activate_next_pane(cx)); -// workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); -// executor.run_until_parked(); - -// // Both clients see the other client's focused file in their right pane. -// assert_eq!( -// pane_summaries(&workspace_a, cx_a), -// &[ -// PaneSummary { -// active: true, -// leader: None, -// items: vec![(true, "1.txt".into())] -// }, -// PaneSummary { -// active: false, -// leader: client_b.peer_id(), -// items: vec![(false, "1.txt".into()), (true, "2.txt".into())] -// }, -// ] -// ); -// assert_eq!( -// pane_summaries(&workspace_b, cx_b), -// &[ -// PaneSummary { -// active: true, -// leader: None, -// items: vec![(true, "2.txt".into())] -// }, -// PaneSummary { -// active: false, -// leader: client_a.peer_id(), -// items: vec![(false, "2.txt".into()), (true, "1.txt".into())] -// }, -// ] -// ); - -// // Clients A and B each open a new file. -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id, "3.txt"), None, true, cx) -// }) -// .await -// .unwrap(); - -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.open_path((worktree_id, "4.txt"), None, true, cx) -// }) -// .await -// .unwrap(); -// executor.run_until_parked(); - -// // Both client's see the other client open the new file, but keep their -// // focus on their own active pane. -// assert_eq!( -// pane_summaries(&workspace_a, cx_a), -// &[ -// PaneSummary { -// active: true, -// leader: None, -// items: vec![(false, "1.txt".into()), (true, "3.txt".into())] -// }, -// PaneSummary { -// active: false, -// leader: client_b.peer_id(), -// items: vec![ -// (false, "1.txt".into()), -// (false, "2.txt".into()), -// (true, "4.txt".into()) -// ] -// }, -// ] -// ); -// assert_eq!( -// pane_summaries(&workspace_b, cx_b), -// &[ -// PaneSummary { -// active: true, -// leader: None, -// items: vec![(false, "2.txt".into()), (true, "4.txt".into())] -// }, -// PaneSummary { -// active: false, -// leader: client_a.peer_id(), -// items: vec![ -// (false, "2.txt".into()), -// (false, "1.txt".into()), -// (true, "3.txt".into()) -// ] -// }, -// ] -// ); - -// // Client A focuses their right pane, in which they're following client B. -// workspace_a.update(cx_a, |workspace, cx| workspace.activate_next_pane(cx)); -// executor.run_until_parked(); - -// // Client B sees that client A is now looking at the same file as them. -// assert_eq!( -// pane_summaries(&workspace_a, cx_a), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "1.txt".into()), (true, "3.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: client_b.peer_id(), -// items: vec![ -// (false, "1.txt".into()), -// (false, "2.txt".into()), -// (true, "4.txt".into()) -// ] -// }, -// ] -// ); -// assert_eq!( -// pane_summaries(&workspace_b, cx_b), -// &[ -// PaneSummary { -// active: true, -// leader: None, -// items: vec![(false, "2.txt".into()), (true, "4.txt".into())] -// }, -// PaneSummary { -// active: false, -// leader: client_a.peer_id(), -// items: vec![ -// (false, "2.txt".into()), -// (false, "1.txt".into()), -// (false, "3.txt".into()), -// (true, "4.txt".into()) -// ] -// }, -// ] -// ); - -// // Client B focuses their right pane, in which they're following client A, -// // who is following them. -// workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); -// executor.run_until_parked(); - -// // Client A sees that client B is now looking at the same file as them. -// assert_eq!( -// pane_summaries(&workspace_b, cx_b), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "2.txt".into()), (true, "4.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: client_a.peer_id(), -// items: vec![ -// (false, "2.txt".into()), -// (false, "1.txt".into()), -// (false, "3.txt".into()), -// (true, "4.txt".into()) -// ] -// }, -// ] -// ); -// assert_eq!( -// pane_summaries(&workspace_a, cx_a), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "1.txt".into()), (true, "3.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: client_b.peer_id(), -// items: vec![ -// (false, "1.txt".into()), -// (false, "2.txt".into()), -// (true, "4.txt".into()) -// ] -// }, -// ] -// ); - -// // Client B focuses a file that they previously followed A to, breaking -// // the follow. -// workspace_b.update(cx_b, |workspace, cx| { -// workspace.active_pane().update(cx, |pane, cx| { -// pane.activate_prev_item(true, cx); -// }); -// }); -// executor.run_until_parked(); - -// // Both clients see that client B is looking at that previous file. -// assert_eq!( -// pane_summaries(&workspace_b, cx_b), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "2.txt".into()), (true, "4.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: None, -// items: vec![ -// (false, "2.txt".into()), -// (false, "1.txt".into()), -// (true, "3.txt".into()), -// (false, "4.txt".into()) -// ] -// }, -// ] -// ); -// assert_eq!( -// pane_summaries(&workspace_a, cx_a), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "1.txt".into()), (true, "3.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: client_b.peer_id(), -// items: vec![ -// (false, "1.txt".into()), -// (false, "2.txt".into()), -// (false, "4.txt".into()), -// (true, "3.txt".into()), -// ] -// }, -// ] -// ); - -// // Client B closes tabs, some of which were originally opened by client A, -// // and some of which were originally opened by client B. -// workspace_b.update(cx_b, |workspace, cx| { -// workspace.active_pane().update(cx, |pane, cx| { -// pane.close_inactive_items(&Default::default(), cx) -// .unwrap() -// .detach(); -// }); -// }); - -// executor.run_until_parked(); - -// // Both clients see that Client B is looking at the previous tab. -// assert_eq!( -// pane_summaries(&workspace_b, cx_b), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "2.txt".into()), (true, "4.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: None, -// items: vec![(true, "3.txt".into()),] -// }, -// ] -// ); -// assert_eq!( -// pane_summaries(&workspace_a, cx_a), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "1.txt".into()), (true, "3.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: client_b.peer_id(), -// items: vec![ -// (false, "1.txt".into()), -// (false, "2.txt".into()), -// (false, "4.txt".into()), -// (true, "3.txt".into()), -// ] -// }, -// ] -// ); - -// // Client B follows client A again. -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.follow(client_a.peer_id().unwrap(), cx).unwrap() -// }) -// .await -// .unwrap(); - -// // Client A cycles through some tabs. -// workspace_a.update(cx_a, |workspace, cx| { -// workspace.active_pane().update(cx, |pane, cx| { -// pane.activate_prev_item(true, cx); -// }); -// }); -// executor.run_until_parked(); - -// // Client B follows client A into those tabs. -// assert_eq!( -// pane_summaries(&workspace_a, cx_a), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "1.txt".into()), (true, "3.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: None, -// items: vec![ -// (false, "1.txt".into()), -// (false, "2.txt".into()), -// (true, "4.txt".into()), -// (false, "3.txt".into()), -// ] -// }, -// ] -// ); -// assert_eq!( -// pane_summaries(&workspace_b, cx_b), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "2.txt".into()), (true, "4.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: client_a.peer_id(), -// items: vec![(false, "3.txt".into()), (true, "4.txt".into())] -// }, -// ] -// ); - -// workspace_a.update(cx_a, |workspace, cx| { -// workspace.active_pane().update(cx, |pane, cx| { -// pane.activate_prev_item(true, cx); -// }); -// }); -// executor.run_until_parked(); - -// assert_eq!( -// pane_summaries(&workspace_a, cx_a), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "1.txt".into()), (true, "3.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: None, -// items: vec![ -// (false, "1.txt".into()), -// (true, "2.txt".into()), -// (false, "4.txt".into()), -// (false, "3.txt".into()), -// ] -// }, -// ] -// ); -// assert_eq!( -// pane_summaries(&workspace_b, cx_b), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "2.txt".into()), (true, "4.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: client_a.peer_id(), -// items: vec![ -// (false, "3.txt".into()), -// (false, "4.txt".into()), -// (true, "2.txt".into()) -// ] -// }, -// ] -// ); - -// workspace_a.update(cx_a, |workspace, cx| { -// workspace.active_pane().update(cx, |pane, cx| { -// pane.activate_prev_item(true, cx); -// }); -// }); -// executor.run_until_parked(); - -// assert_eq!( -// pane_summaries(&workspace_a, cx_a), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "1.txt".into()), (true, "3.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: None, -// items: vec![ -// (true, "1.txt".into()), -// (false, "2.txt".into()), -// (false, "4.txt".into()), -// (false, "3.txt".into()), -// ] -// }, -// ] -// ); -// assert_eq!( -// pane_summaries(&workspace_b, cx_b), -// &[ -// PaneSummary { -// active: false, -// leader: None, -// items: vec![(false, "2.txt".into()), (true, "4.txt".into())] -// }, -// PaneSummary { -// active: true, -// leader: client_a.peer_id(), -// items: vec![ -// (false, "3.txt".into()), -// (false, "4.txt".into()), -// (false, "2.txt".into()), -// (true, "1.txt".into()), -// ] -// }, -// ] -// ); -// } - -// #[gpui::test(iterations = 10)] -// async fn test_auto_unfollowing( -// executor: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// // 2 clients connect to a server. -// let mut server = TestServer::start(executor.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; -// server -// .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) -// .await; -// let active_call_a = cx_a.read(ActiveCall::global); -// let active_call_b = cx_b.read(ActiveCall::global); - -// cx_a.update(editor::init); -// cx_b.update(editor::init); - -// // Client A shares a project. -// client_a -// .fs() -// .insert_tree( -// "/a", -// json!({ -// "1.txt": "one", -// "2.txt": "two", -// "3.txt": "three", -// }), -// ) -// .await; -// let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; -// active_call_a -// .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) -// .await -// .unwrap(); - -// let project_id = active_call_a -// .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) -// .await -// .unwrap(); -// let project_b = client_b.build_remote_project(project_id, cx_b).await; -// active_call_b -// .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) -// .await -// .unwrap(); - -// todo!("could be wrong") -// let mut cx_a = VisualTestContext::from_window(*window_a, cx_a); -// let cx_a = &mut cx_a; -// let mut cx_b = VisualTestContext::from_window(*window_b, cx_b); -// let cx_b = &mut cx_b; - -// // Client A opens some editors. -// let workspace_a = client_a -// .build_workspace(&project_a, cx_a) -// .root(cx_a) -// .unwrap(); -// let _editor_a1 = workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id, "1.txt"), None, true, cx) -// }) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); - -// // Client B starts following client A. -// let workspace_b = client_b -// .build_workspace(&project_b, cx_b) -// .root(cx_b) -// .unwrap(); -// let pane_b = workspace_b.update(cx_b, |workspace, _| workspace.active_pane().clone()); -// let leader_id = project_b.update(cx_b, |project, _| { -// project.collaborators().values().next().unwrap().peer_id -// }); -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.follow(leader_id, cx).unwrap() -// }) -// .await -// .unwrap(); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// Some(leader_id) -// ); -// let editor_b2 = workspace_b.update(cx_b, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); - -// // When client B moves, it automatically stops following client A. -// editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx)); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// None -// ); - -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.follow(leader_id, cx).unwrap() -// }) -// .await -// .unwrap(); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// Some(leader_id) -// ); - -// // When client B edits, it automatically stops following client A. -// editor_b2.update(cx_b, |editor, cx| editor.insert("X", cx)); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// None -// ); - -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.follow(leader_id, cx).unwrap() -// }) -// .await -// .unwrap(); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// Some(leader_id) -// ); - -// // When client B scrolls, it automatically stops following client A. -// editor_b2.update(cx_b, |editor, cx| { -// editor.set_scroll_position(point(0., 3.), cx) -// }); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// None -// ); - -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.follow(leader_id, cx).unwrap() -// }) -// .await -// .unwrap(); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// Some(leader_id) -// ); - -// // When client B activates a different pane, it continues following client A in the original pane. -// workspace_b.update(cx_b, |workspace, cx| { -// workspace.split_and_clone(pane_b.clone(), SplitDirection::Right, cx) -// }); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// Some(leader_id) -// ); - -// workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// Some(leader_id) -// ); - -// // When client B activates a different item in the original pane, it automatically stops following client A. -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.open_path((worktree_id, "2.txt"), None, true, cx) -// }) -// .await -// .unwrap(); -// assert_eq!( -// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), -// None -// ); -// } - -// #[gpui::test(iterations = 10)] -// async fn test_peers_simultaneously_following_each_other( -// executor: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(executor.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; -// server -// .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) -// .await; -// let active_call_a = cx_a.read(ActiveCall::global); - -// cx_a.update(editor::init); -// cx_b.update(editor::init); - -// client_a.fs().insert_tree("/a", json!({})).await; -// let (project_a, _) = client_a.build_local_project("/a", cx_a).await; -// let workspace_a = client_a -// .build_workspace(&project_a, cx_a) -// .root(cx_a) -// .unwrap(); -// let project_id = active_call_a -// .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) -// .await -// .unwrap(); - -// let project_b = client_b.build_remote_project(project_id, cx_b).await; -// let workspace_b = client_b -// .build_workspace(&project_b, cx_b) -// .root(cx_b) -// .unwrap(); - -// executor.run_until_parked(); -// let client_a_id = project_b.update(cx_b, |project, _| { -// project.collaborators().values().next().unwrap().peer_id -// }); -// let client_b_id = project_a.update(cx_a, |project, _| { -// project.collaborators().values().next().unwrap().peer_id -// }); - -// let a_follow_b = workspace_a.update(cx_a, |workspace, cx| { -// workspace.follow(client_b_id, cx).unwrap() -// }); -// let b_follow_a = workspace_b.update(cx_b, |workspace, cx| { -// workspace.follow(client_a_id, cx).unwrap() -// }); - -// futures::try_join!(a_follow_b, b_follow_a).unwrap(); -// workspace_a.update(cx_a, |workspace, _| { -// assert_eq!( -// workspace.leader_for_pane(workspace.active_pane()), -// Some(client_b_id) -// ); -// }); -// workspace_b.update(cx_b, |workspace, _| { -// assert_eq!( -// workspace.leader_for_pane(workspace.active_pane()), -// Some(client_a_id) -// ); -// }); -// } - -// #[gpui::test(iterations = 10)] -// async fn test_following_across_workspaces( -// executor: BackgroundExecutor, -// cx_a: &mut TestAppContext, -// cx_b: &mut TestAppContext, -// ) { -// // a and b join a channel/call -// // a shares project 1 -// // b shares project 2 -// // -// // b follows a: causes project 2 to be joined, and b to follow a. -// // b opens a different file in project 2, a follows b -// // b opens a different file in project 1, a cannot follow b -// // b shares the project, a joins the project and follows b -// let mut server = TestServer::start(executor.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; -// cx_a.update(editor::init); -// cx_b.update(editor::init); - -// client_a -// .fs() -// .insert_tree( -// "/a", -// json!({ -// "w.rs": "", -// "x.rs": "", -// }), -// ) -// .await; - -// client_b -// .fs() -// .insert_tree( -// "/b", -// json!({ -// "y.rs": "", -// "z.rs": "", -// }), -// ) -// .await; - -// server -// .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) -// .await; -// let active_call_a = cx_a.read(ActiveCall::global); -// let active_call_b = cx_b.read(ActiveCall::global); - -// let (project_a, worktree_id_a) = client_a.build_local_project("/a", cx_a).await; -// let (project_b, worktree_id_b) = client_b.build_local_project("/b", cx_b).await; - -// let workspace_a = client_a -// .build_workspace(&project_a, cx_a) -// .root(cx_a) -// .unwrap(); -// let workspace_b = client_b -// .build_workspace(&project_b, cx_b) -// .root(cx_b) -// .unwrap(); - -// cx_a.update(|cx| collab_ui::init(&client_a.app_state, cx)); -// cx_b.update(|cx| collab_ui::init(&client_b.app_state, cx)); - -// active_call_a -// .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) -// .await -// .unwrap(); - -// active_call_a -// .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) -// .await -// .unwrap(); -// active_call_b -// .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) -// .await -// .unwrap(); - -// todo!("could be wrong") -// let mut cx_a = VisualTestContext::from_window(*window_a, cx_a); -// let cx_a = &mut cx_a; -// let mut cx_b = VisualTestContext::from_window(*window_b, cx_b); -// let cx_b = &mut cx_b; - -// workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id_a, "w.rs"), None, true, cx) -// }) -// .await -// .unwrap(); - -// executor.run_until_parked(); -// assert_eq!(visible_push_notifications(cx_b).len(), 1); - -// workspace_b.update(cx_b, |workspace, cx| { -// workspace -// .follow(client_a.peer_id().unwrap(), cx) -// .unwrap() -// .detach() -// }); - -// executor.run_until_parked(); -// let workspace_b_project_a = cx_b -// .windows() -// .iter() -// .max_by_key(|window| window.item_id()) -// .unwrap() -// .downcast::() -// .unwrap() -// .root(cx_b) -// .unwrap(); - -// // assert that b is following a in project a in w.rs -// workspace_b_project_a.update(cx_b, |workspace, cx| { -// assert!(workspace.is_being_followed(client_a.peer_id().unwrap())); -// assert_eq!( -// client_a.peer_id(), -// workspace.leader_for_pane(workspace.active_pane()) -// ); -// let item = workspace.active_item(cx).unwrap(); -// assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("w.rs")); -// }); - -// // TODO: in app code, this would be done by the collab_ui. -// active_call_b -// .update(cx_b, |call, cx| { -// let project = workspace_b_project_a.read(cx).project().clone(); -// call.set_location(Some(&project), cx) -// }) -// .await -// .unwrap(); - -// // assert that there are no share notifications open -// assert_eq!(visible_push_notifications(cx_b).len(), 0); - -// // b moves to x.rs in a's project, and a follows -// workspace_b_project_a -// .update(cx_b, |workspace, cx| { -// workspace.open_path((worktree_id_a, "x.rs"), None, true, cx) -// }) -// .await -// .unwrap(); - -// executor.run_until_parked(); -// workspace_b_project_a.update(cx_b, |workspace, cx| { -// let item = workspace.active_item(cx).unwrap(); -// assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("x.rs")); -// }); - -// workspace_a.update(cx_a, |workspace, cx| { -// workspace -// .follow(client_b.peer_id().unwrap(), cx) -// .unwrap() -// .detach() -// }); - -// executor.run_until_parked(); -// workspace_a.update(cx_a, |workspace, cx| { -// assert!(workspace.is_being_followed(client_b.peer_id().unwrap())); -// assert_eq!( -// client_b.peer_id(), -// workspace.leader_for_pane(workspace.active_pane()) -// ); -// let item = workspace.active_pane().read(cx).active_item().unwrap(); -// assert_eq!(item.tab_description(0, cx).unwrap(), "x.rs".into()); -// }); - -// // b moves to y.rs in b's project, a is still following but can't yet see -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.open_path((worktree_id_b, "y.rs"), None, true, cx) -// }) -// .await -// .unwrap(); - -// // TODO: in app code, this would be done by the collab_ui. -// active_call_b -// .update(cx_b, |call, cx| { -// let project = workspace_b.read(cx).project().clone(); -// call.set_location(Some(&project), cx) -// }) -// .await -// .unwrap(); - -// let project_b_id = active_call_b -// .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx)) -// .await -// .unwrap(); - -// executor.run_until_parked(); -// assert_eq!(visible_push_notifications(cx_a).len(), 1); -// cx_a.update(|cx| { -// workspace::join_remote_project( -// project_b_id, -// client_b.user_id().unwrap(), -// client_a.app_state.clone(), -// cx, -// ) -// }) -// .await -// .unwrap(); - -// executor.run_until_parked(); - -// assert_eq!(visible_push_notifications(cx_a).len(), 0); -// let workspace_a_project_b = cx_a -// .windows() -// .iter() -// .max_by_key(|window| window.item_id()) -// .unwrap() -// .downcast::() -// .unwrap() -// .root(cx_a) -// .unwrap(); - -// workspace_a_project_b.update(cx_a, |workspace, cx| { -// assert_eq!(workspace.project().read(cx).remote_id(), Some(project_b_id)); -// assert!(workspace.is_being_followed(client_b.peer_id().unwrap())); -// assert_eq!( -// client_b.peer_id(), -// workspace.leader_for_pane(workspace.active_pane()) -// ); -// let item = workspace.active_item(cx).unwrap(); -// assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("y.rs")); -// }); -// } - -// #[gpui::test] -// async fn test_following_into_excluded_file( -// executor: BackgroundExecutor, -// mut cx_a: &mut TestAppContext, -// mut cx_b: &mut TestAppContext, -// ) { -// let mut server = TestServer::start(executor.clone()).await; -// let client_a = server.create_client(cx_a, "user_a").await; -// let client_b = server.create_client(cx_b, "user_b").await; -// for cx in [&mut cx_a, &mut cx_b] { -// cx.update(|cx| { -// cx.update_global::(|store, cx| { -// store.update_user_settings::(cx, |project_settings| { -// project_settings.file_scan_exclusions = Some(vec!["**/.git".to_string()]); -// }); -// }); -// }); -// } -// server -// .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) -// .await; -// let active_call_a = cx_a.read(ActiveCall::global); -// let active_call_b = cx_b.read(ActiveCall::global); - -// cx_a.update(editor::init); -// cx_b.update(editor::init); - -// client_a -// .fs() -// .insert_tree( -// "/a", -// json!({ -// ".git": { -// "COMMIT_EDITMSG": "write your commit message here", -// }, -// "1.txt": "one\none\none", -// "2.txt": "two\ntwo\ntwo", -// "3.txt": "three\nthree\nthree", -// }), -// ) -// .await; -// let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; -// active_call_a -// .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) -// .await -// .unwrap(); - -// let project_id = active_call_a -// .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) -// .await -// .unwrap(); -// let project_b = client_b.build_remote_project(project_id, cx_b).await; -// active_call_b -// .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) -// .await -// .unwrap(); - -// let window_a = client_a.build_workspace(&project_a, cx_a); -// let workspace_a = window_a.root(cx_a).unwrap(); -// let peer_id_a = client_a.peer_id().unwrap(); -// let window_b = client_b.build_workspace(&project_b, cx_b); -// let workspace_b = window_b.root(cx_b).unwrap(); - -// todo!("could be wrong") -// let mut cx_a = VisualTestContext::from_window(*window_a, cx_a); -// let cx_a = &mut cx_a; -// let mut cx_b = VisualTestContext::from_window(*window_b, cx_b); -// let cx_b = &mut cx_b; - -// // Client A opens editors for a regular file and an excluded file. -// let editor_for_regular = workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id, "1.txt"), None, true, cx) -// }) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); -// let editor_for_excluded_a = workspace_a -// .update(cx_a, |workspace, cx| { -// workspace.open_path((worktree_id, ".git/COMMIT_EDITMSG"), None, true, cx) -// }) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); - -// // Client A updates their selections in those editors -// editor_for_regular.update(cx_a, |editor, cx| { -// editor.handle_input("a", cx); -// editor.handle_input("b", cx); -// editor.handle_input("c", cx); -// editor.select_left(&Default::default(), cx); -// assert_eq!(editor.selections.ranges(cx), vec![3..2]); -// }); -// editor_for_excluded_a.update(cx_a, |editor, cx| { -// editor.select_all(&Default::default(), cx); -// editor.handle_input("new commit message", cx); -// editor.select_left(&Default::default(), cx); -// assert_eq!(editor.selections.ranges(cx), vec![18..17]); -// }); - -// // When client B starts following client A, currently visible file is replicated -// workspace_b -// .update(cx_b, |workspace, cx| { -// workspace.follow(peer_id_a, cx).unwrap() -// }) -// .await -// .unwrap(); - -// let editor_for_excluded_b = workspace_b.update(cx_b, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); -// assert_eq!( -// cx_b.read(|cx| editor_for_excluded_b.project_path(cx)), -// Some((worktree_id, ".git/COMMIT_EDITMSG").into()) -// ); -// assert_eq!( -// editor_for_excluded_b.update(cx_b, |editor, cx| editor.selections.ranges(cx)), -// vec![18..17] -// ); - -// // Changes from B to the excluded file are replicated in A's editor -// editor_for_excluded_b.update(cx_b, |editor, cx| { -// editor.handle_input("\nCo-Authored-By: B ", cx); -// }); -// executor.run_until_parked(); -// editor_for_excluded_a.update(cx_a, |editor, cx| { -// assert_eq!( -// editor.text(cx), -// "new commit messag\nCo-Authored-By: B " -// ); -// }); -// } - -// fn visible_push_notifications( -// cx: &mut TestAppContext, -// ) -> Vec> { -// let mut ret = Vec::new(); -// for window in cx.windows() { -// window.update(cx, |window| { -// if let Some(handle) = window -// .root_view() -// .clone() -// .downcast::() -// { -// ret.push(handle) -// } -// }); -// } -// ret -// } - -// #[derive(Debug, PartialEq, Eq)] -// struct PaneSummary { -// active: bool, -// leader: Option, -// items: Vec<(bool, String)>, -// } - -// fn followers_by_leader(project_id: u64, cx: &TestAppContext) -> Vec<(PeerId, Vec)> { -// cx.read(|cx| { -// let active_call = ActiveCall::global(cx).read(cx); -// let peer_id = active_call.client().peer_id(); -// let room = active_call.room().unwrap().read(cx); -// let mut result = room -// .remote_participants() -// .values() -// .map(|participant| participant.peer_id) -// .chain(peer_id) -// .filter_map(|peer_id| { -// let followers = room.followers_for(peer_id, project_id); -// if followers.is_empty() { -// None -// } else { -// Some((peer_id, followers.to_vec())) -// } -// }) -// .collect::>(); -// result.sort_by_key(|e| e.0); -// result -// }) -// } - -// fn pane_summaries(workspace: &View, cx: &mut WindowContext<'_>) -> Vec { -// workspace.update(cx, |workspace, cx| { -// let active_pane = workspace.active_pane(); -// workspace -// .panes() -// .iter() -// .map(|pane| { -// let leader = workspace.leader_for_pane(pane); -// let active = pane == active_pane; -// let pane = pane.read(cx); -// let active_ix = pane.active_item_index(); -// PaneSummary { -// active, -// leader, -// items: pane -// .items() -// .enumerate() -// .map(|(ix, item)| { -// ( -// ix == active_ix, -// item.tab_description(0, cx) -// .map_or(String::new(), |s| s.to_string()), -// ) -// }) -// .collect(), -// } -// }) -// .collect() -// }) -// } +use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; +use call::ActiveCall; +use collab_ui::notifications::project_shared_notification::ProjectSharedNotification; +use editor::{Editor, ExcerptRange, MultiBuffer}; +use gpui::{ + point, BackgroundExecutor, Context, SharedString, TestAppContext, View, VisualContext, + VisualTestContext, +}; +use language::Capability; +use live_kit_client::MacOSDisplay; +use project::project_settings::ProjectSettings; +use rpc::proto::PeerId; +use serde_json::json; +use settings::SettingsStore; +use workspace::{ + dock::{test::TestPanel, DockPosition}, + item::{test::TestItem, ItemHandle as _}, + shared_screen::SharedScreen, + SplitDirection, Workspace, +}; + +#[gpui::test(iterations = 10)] +async fn test_basic_following( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, + cx_d: &mut TestAppContext, +) { + let executor = cx_a.executor(); + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + let client_d = server.create_client(cx_d, "user_d").await; + server + .create_room(&mut [ + (&client_a, cx_a), + (&client_b, cx_b), + (&client_c, cx_c), + (&client_d, cx_d), + ]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + "/a", + json!({ + "1.txt": "one\none\none", + "2.txt": "two\ntwo\ntwo", + "3.txt": "three\nthree\nthree", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + // Client A opens some editors. + let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone()); + let editor_a1 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let editor_a2 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client B opens an editor. + let editor_b1 = workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let peer_id_a = client_a.peer_id().unwrap(); + let peer_id_b = client_b.peer_id().unwrap(); + let peer_id_c = client_c.peer_id().unwrap(); + let peer_id_d = client_d.peer_id().unwrap(); + + // Client A updates their selections in those editors + editor_a1.update(cx_a, |editor, cx| { + editor.handle_input("a", cx); + editor.handle_input("b", cx); + editor.handle_input("c", cx); + editor.select_left(&Default::default(), cx); + assert_eq!(editor.selections.ranges(cx), vec![3..2]); + }); + editor_a2.update(cx_a, |editor, cx| { + editor.handle_input("d", cx); + editor.handle_input("e", cx); + editor.select_left(&Default::default(), cx); + assert_eq!(editor.selections.ranges(cx), vec![2..1]); + }); + + // When client B starts following client A, all visible view states are replicated to client B. + workspace_b.update(cx_b, |workspace, cx| workspace.follow(peer_id_a, cx)); + + cx_c.executor().run_until_parked(); + let editor_b2 = workspace_b.update(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + assert_eq!( + cx_b.read(|cx| editor_b2.project_path(cx)), + Some((worktree_id, "2.txt").into()) + ); + assert_eq!( + editor_b2.update(cx_b, |editor, cx| editor.selections.ranges(cx)), + vec![2..1] + ); + assert_eq!( + editor_b1.update(cx_b, |editor, cx| editor.selections.ranges(cx)), + vec![3..2] + ); + + executor.run_until_parked(); + let active_call_c = cx_c.read(ActiveCall::global); + let project_c = client_c.build_remote_project(project_id, cx_c).await; + let (workspace_c, cx_c) = client_c.build_workspace(&project_c, cx_c); + active_call_c + .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx)) + .await + .unwrap(); + let weak_project_c = project_c.downgrade(); + drop(project_c); + + // Client C also follows client A. + workspace_c.update(cx_c, |workspace, cx| workspace.follow(peer_id_a, cx)); + + cx_d.executor().run_until_parked(); + let active_call_d = cx_d.read(ActiveCall::global); + let project_d = client_d.build_remote_project(project_id, cx_d).await; + let (workspace_d, cx_d) = client_d.build_workspace(&project_d, cx_d); + active_call_d + .update(cx_d, |call, cx| call.set_location(Some(&project_d), cx)) + .await + .unwrap(); + drop(project_d); + + // All clients see that clients B and C are following client A. + cx_c.executor().run_until_parked(); + for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { + assert_eq!( + followers_by_leader(project_id, cx), + &[(peer_id_a, vec![peer_id_b, peer_id_c])], + "followers seen by {name}" + ); + } + + // Client C unfollows client A. + workspace_c.update(cx_c, |workspace, cx| { + workspace.unfollow(&workspace.active_pane().clone(), cx); + }); + + // All clients see that clients B is following client A. + cx_c.executor().run_until_parked(); + for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { + assert_eq!( + followers_by_leader(project_id, cx), + &[(peer_id_a, vec![peer_id_b])], + "followers seen by {name}" + ); + } + + // Client C re-follows client A. + workspace_c.update(cx_c, |workspace, cx| workspace.follow(peer_id_a, cx)); + + // All clients see that clients B and C are following client A. + cx_c.executor().run_until_parked(); + for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { + assert_eq!( + followers_by_leader(project_id, cx), + &[(peer_id_a, vec![peer_id_b, peer_id_c])], + "followers seen by {name}" + ); + } + + // Client D follows client B, then switches to following client C. + workspace_d.update(cx_d, |workspace, cx| workspace.follow(peer_id_b, cx)); + cx_a.executor().run_until_parked(); + workspace_d.update(cx_d, |workspace, cx| workspace.follow(peer_id_c, cx)); + + // All clients see that D is following C + cx_a.executor().run_until_parked(); + for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { + assert_eq!( + followers_by_leader(project_id, cx), + &[ + (peer_id_a, vec![peer_id_b, peer_id_c]), + (peer_id_c, vec![peer_id_d]) + ], + "followers seen by {name}" + ); + } + + // Client C closes the project. + let weak_workspace_c = workspace_c.downgrade(); + workspace_c.update(cx_c, |workspace, cx| { + workspace.close_window(&Default::default(), cx); + }); + cx_c.update(|_| { + drop(workspace_c); + }); + cx_b.executor().run_until_parked(); + // are you sure you want to leave the call? + cx_c.simulate_prompt_answer(0); + cx_b.executor().run_until_parked(); + executor.run_until_parked(); + + weak_workspace_c.assert_dropped(); + weak_project_c.assert_dropped(); + + // Clients A and B see that client B is following A, and client C is not present in the followers. + executor.run_until_parked(); + for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("D", &cx_d)] { + assert_eq!( + followers_by_leader(project_id, cx), + &[(peer_id_a, vec![peer_id_b]),], + "followers seen by {name}" + ); + } + + // When client A activates a different editor, client B does so as well. + workspace_a.update(cx_a, |workspace, cx| { + workspace.activate_item(&editor_a1, cx) + }); + executor.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + editor_b1.item_id() + ); + }); + + // When client A opens a multibuffer, client B does so as well. + let multibuffer_a = cx_a.new_model(|cx| { + let buffer_a1 = project_a.update(cx, |project, cx| { + project + .get_open_buffer(&(worktree_id, "1.txt").into(), cx) + .unwrap() + }); + let buffer_a2 = project_a.update(cx, |project, cx| { + project + .get_open_buffer(&(worktree_id, "2.txt").into(), cx) + .unwrap() + }); + let mut result = MultiBuffer::new(0, Capability::ReadWrite); + result.push_excerpts( + buffer_a1, + [ExcerptRange { + context: 0..3, + primary: None, + }], + cx, + ); + result.push_excerpts( + buffer_a2, + [ExcerptRange { + context: 4..7, + primary: None, + }], + cx, + ); + result + }); + let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| { + let editor = + cx.new_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx)); + workspace.add_item(Box::new(editor.clone()), cx); + editor + }); + executor.run_until_parked(); + let multibuffer_editor_b = workspace_b.update(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + assert_eq!( + multibuffer_editor_a.update(cx_a, |editor, cx| editor.text(cx)), + multibuffer_editor_b.update(cx_b, |editor, cx| editor.text(cx)), + ); + + // When client A navigates back and forth, client B does so as well. + workspace_a + .update(cx_a, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + editor_b1.item_id() + ); + }); + + workspace_a + .update(cx_a, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + editor_b2.item_id() + ); + }); + + workspace_a + .update(cx_a, |workspace, cx| { + workspace.go_forward(workspace.active_pane().downgrade(), cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + editor_b1.item_id() + ); + }); + + // Changes to client A's editor are reflected on client B. + editor_a1.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2])); + }); + executor.run_until_parked(); + cx_b.background_executor.run_until_parked(); + editor_b1.update(cx_b, |editor, cx| { + assert_eq!(editor.selections.ranges(cx), &[1..1, 2..2]); + }); + + editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx)); + executor.run_until_parked(); + editor_b1.update(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO")); + + editor_a1.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([3..3])); + editor.set_scroll_position(point(0., 100.), cx); + }); + executor.run_until_parked(); + editor_b1.update(cx_b, |editor, cx| { + assert_eq!(editor.selections.ranges(cx), &[3..3]); + }); + + // After unfollowing, client B stops receiving updates from client A. + workspace_b.update(cx_b, |workspace, cx| { + workspace.unfollow(&workspace.active_pane().clone(), cx) + }); + workspace_a.update(cx_a, |workspace, cx| { + workspace.activate_item(&editor_a2, cx) + }); + executor.run_until_parked(); + assert_eq!( + workspace_b.update(cx_b, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .item_id()), + editor_b1.item_id() + ); + + // Client A starts following client B. + workspace_a.update(cx_a, |workspace, cx| workspace.follow(peer_id_b, cx)); + executor.run_until_parked(); + assert_eq!( + workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), + Some(peer_id_b) + ); + assert_eq!( + workspace_a.update(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .item_id()), + editor_a1.item_id() + ); + + // Client B activates an external window, which causes a new screen-sharing item to be added to the pane. + let display = MacOSDisplay::new(); + active_call_b + .update(cx_b, |call, cx| call.set_location(None, cx)) + .await + .unwrap(); + active_call_b + .update(cx_b, |call, cx| { + call.room().unwrap().update(cx, |room, cx| { + room.set_display_sources(vec![display.clone()]); + room.share_screen(cx) + }) + }) + .await + .unwrap(); + executor.run_until_parked(); + let shared_screen = workspace_a.update(cx_a, |workspace, cx| { + workspace + .active_item(cx) + .expect("no active item") + .downcast::() + .expect("active item isn't a shared screen") + }); + + // Client B activates Zed again, which causes the previous editor to become focused again. + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + executor.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + editor_a1.item_id() + ) + }); + + // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer. + workspace_b.update(cx_b, |workspace, cx| { + workspace.activate_item(&multibuffer_editor_b, cx) + }); + executor.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + multibuffer_editor_a.item_id() + ) + }); + + // Client B activates a panel, and the previously-opened screen-sharing item gets activated. + let panel = cx_b.new_view(|cx| TestPanel::new(DockPosition::Left, cx)); + workspace_b.update(cx_b, |workspace, cx| { + workspace.add_panel(panel, cx); + workspace.toggle_panel_focus::(cx); + }); + executor.run_until_parked(); + assert_eq!( + workspace_a.update(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .item_id()), + shared_screen.item_id() + ); + + // Toggling the focus back to the pane causes client A to return to the multibuffer. + workspace_b.update(cx_b, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); + executor.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().item_id(), + multibuffer_editor_a.item_id() + ) + }); + + // Client B activates an item that doesn't implement following, + // so the previously-opened screen-sharing item gets activated. + let unfollowable_item = cx_b.new_view(|cx| TestItem::new(cx)); + workspace_b.update(cx_b, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(unfollowable_item), true, true, None, cx) + }) + }); + executor.run_until_parked(); + assert_eq!( + workspace_a.update(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .item_id()), + shared_screen.item_id() + ); + + // Following interrupts when client B disconnects. + client_b.disconnect(&cx_b.to_async()); + executor.advance_clock(RECONNECT_TIMEOUT); + assert_eq!( + workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), + None + ); +} + +#[gpui::test] +async fn test_following_tab_order( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + "/a", + json!({ + "1.txt": "one", + "2.txt": "two", + "3.txt": "three", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone()); + + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let pane_b = workspace_b.update(cx_b, |workspace, _| workspace.active_pane().clone()); + + let client_b_id = project_a.update(cx_a, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + + //Open 1, 3 in that order on client A + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap(); + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "3.txt"), None, true, cx) + }) + .await + .unwrap(); + + let pane_paths = |pane: &View, cx: &mut VisualTestContext| { + pane.update(cx, |pane, cx| { + pane.items() + .map(|item| { + item.project_path(cx) + .unwrap() + .path + .to_str() + .unwrap() + .to_owned() + }) + .collect::>() + }) + }; + + //Verify that the tabs opened in the order we expect + assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt"]); + + //Follow client B as client A + workspace_a.update(cx_a, |workspace, cx| workspace.follow(client_b_id, cx)); + executor.run_until_parked(); + + //Open just 2 on client B + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), None, true, cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + + // Verify that newly opened followed file is at the end + assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]); + + //Open just 1 on client B + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap(); + assert_eq!(&pane_paths(&pane_b, cx_b), &["2.txt", "1.txt"]); + executor.run_until_parked(); + + // Verify that following into 1 did not reorder + assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]); +} + +#[gpui::test(iterations = 10)] +async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let executor = cx_a.executor(); + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + // Client A shares a project. + client_a + .fs() + .insert_tree( + "/a", + json!({ + "1.txt": "one", + "2.txt": "two", + "3.txt": "three", + "4.txt": "four", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + // Client B joins the project. + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + // Client A opens a file. + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client B opens a different file. + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Clients A and B follow each other in split panes + workspace_a.update(cx_a, |workspace, cx| { + workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx); + }); + workspace_a.update(cx_a, |workspace, cx| { + workspace.follow(client_b.peer_id().unwrap(), cx) + }); + executor.run_until_parked(); + workspace_b.update(cx_b, |workspace, cx| { + workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx); + }); + workspace_b.update(cx_b, |workspace, cx| { + workspace.follow(client_a.peer_id().unwrap(), cx) + }); + executor.run_until_parked(); + + // Clients A and B return focus to the original files they had open + workspace_a.update(cx_a, |workspace, cx| workspace.activate_next_pane(cx)); + workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); + executor.run_until_parked(); + + // Both clients see the other client's focused file in their right pane. + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(true, "1.txt".into())] + }, + PaneSummary { + active: false, + leader: client_b.peer_id(), + items: vec![(false, "1.txt".into()), (true, "2.txt".into())] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(true, "2.txt".into())] + }, + PaneSummary { + active: false, + leader: client_a.peer_id(), + items: vec![(false, "2.txt".into()), (true, "1.txt".into())] + }, + ] + ); + + // Clients A and B each open a new file. + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "3.txt"), None, true, cx) + }) + .await + .unwrap(); + + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "4.txt"), None, true, cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + + // Both client's see the other client open the new file, but keep their + // focus on their own active pane. + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: false, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: false, + leader: client_a.peer_id(), + items: vec![ + (false, "2.txt".into()), + (false, "1.txt".into()), + (true, "3.txt".into()) + ] + }, + ] + ); + + // Client A focuses their right pane, in which they're following client B. + workspace_a.update(cx_a, |workspace, cx| workspace.activate_next_pane(cx)); + executor.run_until_parked(); + + // Client B sees that client A is now looking at the same file as them. + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: false, + leader: client_a.peer_id(), + items: vec![ + (false, "2.txt".into()), + (false, "1.txt".into()), + (false, "3.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + + // Client B focuses their right pane, in which they're following client A, + // who is following them. + workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); + executor.run_until_parked(); + + // Client A sees that client B is now looking at the same file as them. + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: client_a.peer_id(), + items: vec![ + (false, "2.txt".into()), + (false, "1.txt".into()), + (false, "3.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + + // Client B focuses a file that they previously followed A to, breaking + // the follow. + workspace_b.update(cx_b, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.activate_prev_item(true, cx); + }); + }); + executor.run_until_parked(); + + // Both clients see that client B is looking at that previous file. + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![ + (false, "2.txt".into()), + (false, "1.txt".into()), + (true, "3.txt".into()), + (false, "4.txt".into()) + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (false, "4.txt".into()), + (true, "3.txt".into()), + ] + }, + ] + ); + + // Client B closes tabs, some of which were originally opened by client A, + // and some of which were originally opened by client B. + workspace_b.update(cx_b, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_inactive_items(&Default::default(), cx) + .unwrap() + .detach(); + }); + }); + + executor.run_until_parked(); + + // Both clients see that Client B is looking at the previous tab. + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![(true, "3.txt".into()),] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (false, "4.txt".into()), + (true, "3.txt".into()), + ] + }, + ] + ); + + // Client B follows client A again. + workspace_b.update(cx_b, |workspace, cx| { + workspace.follow(client_a.peer_id().unwrap(), cx) + }); + executor.run_until_parked(); + // Client A cycles through some tabs. + workspace_a.update(cx_a, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.activate_prev_item(true, cx); + }); + }); + executor.run_until_parked(); + + // Client B follows client A into those tabs. + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (true, "4.txt".into()), + (false, "3.txt".into()), + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: client_a.peer_id(), + items: vec![(false, "3.txt".into()), (true, "4.txt".into())] + }, + ] + ); + + workspace_a.update(cx_a, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.activate_prev_item(true, cx); + }); + }); + executor.run_until_parked(); + + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![ + (false, "1.txt".into()), + (true, "2.txt".into()), + (false, "4.txt".into()), + (false, "3.txt".into()), + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: client_a.peer_id(), + items: vec![ + (false, "3.txt".into()), + (false, "4.txt".into()), + (true, "2.txt".into()) + ] + }, + ] + ); + + workspace_a.update(cx_a, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.activate_prev_item(true, cx); + }); + }); + executor.run_until_parked(); + + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![ + (true, "1.txt".into()), + (false, "2.txt".into()), + (false, "4.txt".into()), + (false, "3.txt".into()), + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: client_a.peer_id(), + items: vec![ + (false, "3.txt".into()), + (false, "4.txt".into()), + (false, "2.txt".into()), + (true, "1.txt".into()), + ] + }, + ] + ); +} + +#[gpui::test(iterations = 10)] +async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + // 2 clients connect to a server. + let executor = cx_a.executor(); + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + // Client A shares a project. + client_a + .fs() + .insert_tree( + "/a", + json!({ + "1.txt": "one", + "2.txt": "two", + "3.txt": "three", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let _editor_a1 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client B starts following client A. + let pane_b = workspace_b.update(cx_b, |workspace, _| workspace.active_pane().clone()); + let leader_id = project_b.update(cx_b, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + workspace_b.update(cx_b, |workspace, cx| workspace.follow(leader_id, cx)); + executor.run_until_parked(); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + let editor_b2 = workspace_b.update(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + + // When client B moves, it automatically stops following client A. + editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx)); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); + + workspace_b.update(cx_b, |workspace, cx| workspace.follow(leader_id, cx)); + executor.run_until_parked(); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B edits, it automatically stops following client A. + editor_b2.update(cx_b, |editor, cx| editor.insert("X", cx)); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); + + workspace_b.update(cx_b, |workspace, cx| workspace.follow(leader_id, cx)); + executor.run_until_parked(); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B scrolls, it automatically stops following client A. + editor_b2.update(cx_b, |editor, cx| { + editor.set_scroll_position(point(0., 3.), cx) + }); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); + + workspace_b.update(cx_b, |workspace, cx| workspace.follow(leader_id, cx)); + executor.run_until_parked(); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B activates a different pane, it continues following client A in the original pane. + workspace_b.update(cx_b, |workspace, cx| { + workspace.split_and_clone(pane_b.clone(), SplitDirection::Right, cx) + }); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B activates a different item in the original pane, it automatically stops following client A. + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), None, true, cx) + }) + .await + .unwrap(); + assert_eq!( + workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); +} + +#[gpui::test(iterations = 10)] +async fn test_peers_simultaneously_following_each_other( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let executor = cx_a.executor(); + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a.fs().insert_tree("/a", json!({})).await; + let (project_a, _) = client_a.build_local_project("/a", cx_a).await; + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.build_remote_project(project_id, cx_b).await; + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + executor.run_until_parked(); + let client_a_id = project_b.update(cx_b, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + let client_b_id = project_a.update(cx_a, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + + workspace_a.update(cx_a, |workspace, cx| workspace.follow(client_b_id, cx)); + workspace_b.update(cx_b, |workspace, cx| workspace.follow(client_a_id, cx)); + executor.run_until_parked(); + + workspace_a.update(cx_a, |workspace, _| { + assert_eq!( + workspace.leader_for_pane(workspace.active_pane()), + Some(client_b_id) + ); + }); + workspace_b.update(cx_b, |workspace, _| { + assert_eq!( + workspace.leader_for_pane(workspace.active_pane()), + Some(client_a_id) + ); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + // a and b join a channel/call + // a shares project 1 + // b shares project 2 + // + // b follows a: causes project 2 to be joined, and b to follow a. + // b opens a different file in project 2, a follows b + // b opens a different file in project 1, a cannot follow b + // b shares the project, a joins the project and follows b + let executor = cx_a.executor(); + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + "/a", + json!({ + "w.rs": "", + "x.rs": "", + }), + ) + .await; + + client_b + .fs() + .insert_tree( + "/b", + json!({ + "y.rs": "", + "z.rs": "", + }), + ) + .await; + + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + let (project_a, worktree_id_a) = client_a.build_local_project("/a", cx_a).await; + let (project_b, worktree_id_b) = client_b.build_local_project("/b", cx_b).await; + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + cx_a.update(|cx| collab_ui::init(&client_a.app_state, cx)); + cx_b.update(|cx| collab_ui::init(&client_b.app_state, cx)); + + active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id_a, "w.rs"), None, true, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + assert_eq!(visible_push_notifications(cx_b).len(), 1); + + workspace_b.update(cx_b, |workspace, cx| { + workspace.follow(client_a.peer_id().unwrap(), cx) + }); + + executor.run_until_parked(); + let window_b_project_a = cx_b + .windows() + .iter() + .max_by_key(|window| window.window_id()) + .unwrap() + .clone(); + + let mut cx_b2 = VisualTestContext::from_window(window_b_project_a.clone(), cx_b); + + let workspace_b_project_a = window_b_project_a + .downcast::() + .unwrap() + .root(cx_b) + .unwrap(); + + // assert that b is following a in project a in w.rs + workspace_b_project_a.update(&mut cx_b2, |workspace, cx| { + assert!(workspace.is_being_followed(client_a.peer_id().unwrap())); + assert_eq!( + client_a.peer_id(), + workspace.leader_for_pane(workspace.active_pane()) + ); + let item = workspace.active_item(cx).unwrap(); + assert_eq!( + item.tab_description(0, cx).unwrap(), + SharedString::from("w.rs") + ); + }); + + // TODO: in app code, this would be done by the collab_ui. + active_call_b + .update(&mut cx_b2, |call, cx| { + let project = workspace_b_project_a.read(cx).project().clone(); + call.set_location(Some(&project), cx) + }) + .await + .unwrap(); + + // assert that there are no share notifications open + assert_eq!(visible_push_notifications(cx_b).len(), 0); + + // b moves to x.rs in a's project, and a follows + workspace_b_project_a + .update(&mut cx_b2, |workspace, cx| { + workspace.open_path((worktree_id_a, "x.rs"), None, true, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + workspace_b_project_a.update(&mut cx_b2, |workspace, cx| { + let item = workspace.active_item(cx).unwrap(); + assert_eq!( + item.tab_description(0, cx).unwrap(), + SharedString::from("x.rs") + ); + }); + + workspace_a.update(cx_a, |workspace, cx| { + workspace.follow(client_b.peer_id().unwrap(), cx) + }); + + executor.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert!(workspace.is_being_followed(client_b.peer_id().unwrap())); + assert_eq!( + client_b.peer_id(), + workspace.leader_for_pane(workspace.active_pane()) + ); + let item = workspace.active_pane().read(cx).active_item().unwrap(); + assert_eq!(item.tab_description(0, cx).unwrap(), "x.rs"); + }); + + // b moves to y.rs in b's project, a is still following but can't yet see + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id_b, "y.rs"), None, true, cx) + }) + .await + .unwrap(); + + // TODO: in app code, this would be done by the collab_ui. + active_call_b + .update(cx_b, |call, cx| { + let project = workspace_b.read(cx).project().clone(); + call.set_location(Some(&project), cx) + }) + .await + .unwrap(); + + let project_b_id = active_call_b + .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx)) + .await + .unwrap(); + + executor.run_until_parked(); + assert_eq!(visible_push_notifications(cx_a).len(), 1); + cx_a.update(|cx| { + workspace::join_remote_project( + project_b_id, + client_b.user_id().unwrap(), + client_a.app_state.clone(), + cx, + ) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + assert_eq!(visible_push_notifications(cx_a).len(), 0); + let window_a_project_b = cx_a + .windows() + .iter() + .max_by_key(|window| window.window_id()) + .unwrap() + .clone(); + let cx_a2 = &mut VisualTestContext::from_window(window_a_project_b.clone(), cx_a); + let workspace_a_project_b = window_a_project_b + .downcast::() + .unwrap() + .root(cx_a) + .unwrap(); + + workspace_a_project_b.update(cx_a2, |workspace, cx| { + assert_eq!(workspace.project().read(cx).remote_id(), Some(project_b_id)); + assert!(workspace.is_being_followed(client_b.peer_id().unwrap())); + assert_eq!( + client_b.peer_id(), + workspace.leader_for_pane(workspace.active_pane()) + ); + let item = workspace.active_item(cx).unwrap(); + assert_eq!( + item.tab_description(0, cx).unwrap(), + SharedString::from("y.rs") + ); + }); +} + +#[gpui::test] +async fn test_following_into_excluded_file( + mut cx_a: &mut TestAppContext, + mut cx_b: &mut TestAppContext, +) { + let executor = cx_a.executor(); + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + for cx in [&mut cx_a, &mut cx_b] { + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(vec!["**/.git".to_string()]); + }); + }); + }); + } + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + let peer_id_a = client_a.peer_id().unwrap(); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + "/a", + json!({ + ".git": { + "COMMIT_EDITMSG": "write your commit message here", + }, + "1.txt": "one\none\none", + "2.txt": "two\ntwo\ntwo", + "3.txt": "three\nthree\nthree", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + // Client A opens editors for a regular file and an excluded file. + let editor_for_regular = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let editor_for_excluded_a = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, ".git/COMMIT_EDITMSG"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client A updates their selections in those editors + editor_for_regular.update(cx_a, |editor, cx| { + editor.handle_input("a", cx); + editor.handle_input("b", cx); + editor.handle_input("c", cx); + editor.select_left(&Default::default(), cx); + assert_eq!(editor.selections.ranges(cx), vec![3..2]); + }); + editor_for_excluded_a.update(cx_a, |editor, cx| { + editor.select_all(&Default::default(), cx); + editor.handle_input("new commit message", cx); + editor.select_left(&Default::default(), cx); + assert_eq!(editor.selections.ranges(cx), vec![18..17]); + }); + + // When client B starts following client A, currently visible file is replicated + workspace_b.update(cx_b, |workspace, cx| workspace.follow(peer_id_a, cx)); + executor.run_until_parked(); + + let editor_for_excluded_b = workspace_b.update(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + assert_eq!( + cx_b.read(|cx| editor_for_excluded_b.project_path(cx)), + Some((worktree_id, ".git/COMMIT_EDITMSG").into()) + ); + assert_eq!( + editor_for_excluded_b.update(cx_b, |editor, cx| editor.selections.ranges(cx)), + vec![18..17] + ); + + // Changes from B to the excluded file are replicated in A's editor + editor_for_excluded_b.update(cx_b, |editor, cx| { + editor.handle_input("\nCo-Authored-By: B ", cx); + }); + executor.run_until_parked(); + editor_for_excluded_a.update(cx_a, |editor, cx| { + assert_eq!( + editor.text(cx), + "new commit messag\nCo-Authored-By: B " + ); + }); +} + +fn visible_push_notifications( + cx: &mut TestAppContext, +) -> Vec> { + let mut ret = Vec::new(); + for window in cx.windows() { + window + .update(cx, |window, _| { + if let Ok(handle) = window.downcast::() { + ret.push(handle) + } + }) + .unwrap(); + } + ret +} + +#[derive(Debug, PartialEq, Eq)] +struct PaneSummary { + active: bool, + leader: Option, + items: Vec<(bool, String)>, +} + +fn followers_by_leader(project_id: u64, cx: &TestAppContext) -> Vec<(PeerId, Vec)> { + cx.read(|cx| { + let active_call = ActiveCall::global(cx).read(cx); + let peer_id = active_call.client().peer_id(); + let room = active_call.room().unwrap().read(cx); + let mut result = room + .remote_participants() + .values() + .map(|participant| participant.peer_id) + .chain(peer_id) + .filter_map(|peer_id| { + let followers = room.followers_for(peer_id, project_id); + if followers.is_empty() { + None + } else { + Some((peer_id, followers.to_vec())) + } + }) + .collect::>(); + result.sort_by_key(|e| e.0); + result + }) +} + +fn pane_summaries(workspace: &View, cx: &mut VisualTestContext) -> Vec { + workspace.update(cx, |workspace, cx| { + let active_pane = workspace.active_pane(); + workspace + .panes() + .iter() + .map(|pane| { + let leader = workspace.leader_for_pane(pane); + let active = pane == active_pane; + let pane = pane.read(cx); + let active_ix = pane.active_item_index(); + PaneSummary { + active, + leader, + items: pane + .items() + .enumerate() + .map(|(ix, item)| { + ( + ix == active_ix, + item.tab_description(0, cx) + .map_or(String::new(), |s| s.to_string()), + ) + }) + .collect(), + } + }) + .collect() + }) +} diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 8206d89dce5901ba8954c55cc62b81a2b738847a..f845de3a939886fefa42343131e0c4ec18543fea 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -61,6 +61,7 @@ schemars.workspace = true postage.workspace = true serde.workspace = true serde_derive.workspace = true +serde_json.workspace = true time.workspace = true smallvec.workspace = true diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index b142fcbe7f93146ede3d6ba6edff8e03de1540e7..a13c0ed384f934d35a64cd29a5ebbc53070a7281 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -7,9 +7,9 @@ use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use gpui::{ - actions, div, list, prelude::*, px, serde_json, AnyElement, AppContext, AsyncWindowContext, - ClickEvent, ElementId, EventEmitter, FocusableView, ListOffset, ListScrollEvent, ListState, - Model, Render, Subscription, Task, View, ViewContext, VisualContext, WeakView, + actions, div, list, prelude::*, px, AnyElement, AppContext, AsyncWindowContext, ClickEvent, + ElementId, EventEmitter, FocusableView, ListOffset, ListScrollEvent, ListState, Model, Render, + Subscription, Task, View, ViewContext, VisualContext, WeakView, }; use language::LanguageRegistry; use menu::Confirm; diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 522db1042d45a757be1541cbdb2bacb5e86266ef..517fac4fbb377f425210d2468d3f18bb8d1ebb6a 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -271,11 +271,12 @@ mod tests { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + let http = FakeHttpClient::with_404_response(); let client = Client::new(http.clone(), cx); let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx)); - let settings = SettingsStore::test(cx); - cx.set_global(settings); theme::init(theme::LoadThemes::JustBase, cx); language::init(cx); editor::init(cx); diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 663883f9b4b86f6b0e70f28d31b345851a5be269..ac0925e7b0b6230ab2688d421af77ae5333f3d56 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -15,12 +15,12 @@ use editor::{Editor, EditorElement, EditorStyle}; use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, canvas, div, fill, list, overlay, point, prelude::*, px, serde_json, AnyElement, - AppContext, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter, - FocusHandle, FocusableView, FontStyle, FontWeight, InteractiveElement, IntoElement, ListOffset, - ListState, Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, - RenderOnce, SharedString, Styled, Subscription, Task, TextStyle, View, ViewContext, - VisualContext, WeakView, WhiteSpace, + actions, canvas, div, fill, list, overlay, point, prelude::*, px, AnyElement, AppContext, + AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle, + FocusableView, FontStyle, FontWeight, InteractiveElement, IntoElement, ListOffset, ListState, + Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, RenderOnce, + SharedString, Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext, + WeakView, WhiteSpace, }; use menu::{Cancel, Confirm, SelectNext, SelectPrev}; use project::{Fs, Project}; @@ -896,7 +896,7 @@ impl CollabPanel { .start_slot( h_stack() .gap_1() - .child(render_tree_branch(is_last, cx)) + .child(render_tree_branch(is_last, false, cx)) .child(IconButton::new(0, Icon::Folder)), ) .child(Label::new(project_name.clone())) @@ -917,7 +917,7 @@ impl CollabPanel { .start_slot( h_stack() .gap_1() - .child(render_tree_branch(is_last, cx)) + .child(render_tree_branch(is_last, false, cx)) .child(IconButton::new(0, Icon::Screen)), ) .child(Label::new("Screen")) @@ -958,7 +958,7 @@ impl CollabPanel { .start_slot( h_stack() .gap_1() - .child(render_tree_branch(false, cx)) + .child(render_tree_branch(false, true, cx)) .child(IconButton::new(0, Icon::File)), ) .child(div().h_7().w_full().child(Label::new("notes"))) @@ -979,7 +979,7 @@ impl CollabPanel { .start_slot( h_stack() .gap_1() - .child(render_tree_branch(false, cx)) + .child(render_tree_branch(false, false, cx)) .child(IconButton::new(0, Icon::MessageBubbles)), ) .child(Label::new("chat")) @@ -1007,7 +1007,7 @@ impl CollabPanel { .start_slot( h_stack() .gap_1() - .child(render_tree_branch(!has_visible_participants, cx)) + .child(render_tree_branch(!has_visible_participants, false, cx)) .child(""), ) .child(Label::new(if count == 1 { @@ -2404,11 +2404,11 @@ impl CollabPanel { } } -fn render_tree_branch(is_last: bool, cx: &mut WindowContext) -> impl IntoElement { +fn render_tree_branch(is_last: bool, overdraw: bool, cx: &mut WindowContext) -> impl IntoElement { let rem_size = cx.rem_size(); let line_height = cx.text_style().line_height_in_pixels(rem_size); let width = rem_size * 1.5; - let thickness = px(2.); + let thickness = px(1.); let color = cx.theme().colors().text; canvas(move |bounds, cx| { @@ -2422,7 +2422,11 @@ fn render_tree_branch(is_last: bool, cx: &mut WindowContext) -> impl IntoElement point(start_x, top), point( start_x + thickness, - if is_last { start_y } else { bounds.bottom() }, + if is_last { + start_y + } else { + bounds.bottom() + if overdraw { px(1.) } else { px(0.) } + }, ), ), color, diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 132410a62a833d80fed864253fafc0e7268e11a1..6ccad2db0d107f4ee877ecdfd38563e880a79be5 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -41,6 +41,7 @@ pub fn init(cx: &mut AppContext) { workspace.set_titlebar_item(titlebar_item.into(), cx) }) .detach(); + // todo!() // cx.add_action(CollabTitlebarItem::share_project); // cx.add_action(CollabTitlebarItem::unshare_project); // cx.add_action(CollabTitlebarItem::toggle_user_menu); @@ -92,7 +93,7 @@ impl Render for CollabTitlebarItem { .gap_1() .children(self.render_project_host(cx)) .child(self.render_project_name(cx)) - .children(self.render_project_branch(cx)) + .child(div().pr_1().children(self.render_project_branch(cx))) .when_some( current_user.clone().zip(client.peer_id()).zip(room.clone()), |this, ((current_user, peer_id), room)| { @@ -184,6 +185,16 @@ impl Render for CollabTitlebarItem { "toggle_sharing", if is_shared { "Unshare" } else { "Share" }, ) + .tooltip(move |cx| { + Tooltip::text( + if is_shared { + "Stop sharing project with call participants" + } else { + "Share project with call participants" + }, + cx, + ) + }) .style(ButtonStyle::Subtle) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .selected(is_shared) @@ -200,14 +211,19 @@ impl Render for CollabTitlebarItem { ) }) .child( - IconButton::new("leave-call", ui::Icon::Exit) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .on_click(move |_, cx| { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .detach_and_log_err(cx); - }), + div() + .child( + IconButton::new("leave-call", ui::Icon::Exit) + .style(ButtonStyle::Subtle) + .tooltip(|cx| Tooltip::text("Leave call", cx)) + .icon_size(IconSize::Small) + .on_click(move |_, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .detach_and_log_err(cx); + }), + ) + .pr_2(), ) .when(!read_only, |this| { this.child( @@ -219,6 +235,16 @@ impl Render for CollabTitlebarItem { ui::Icon::Mic }, ) + .tooltip(move |cx| { + Tooltip::text( + if is_muted { + "Unmute microphone" + } else { + "Mute microphone" + }, + cx, + ) + }) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) .selected(is_muted) @@ -260,11 +286,22 @@ impl Render for CollabTitlebarItem { .icon_size(IconSize::Small) .selected(is_screen_sharing) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .tooltip(move |cx| { + Tooltip::text( + if is_screen_sharing { + "Stop Sharing Screen" + } else { + "Share Screen" + }, + cx, + ) + }) .on_click(move |_, cx| { crate::toggle_screen_sharing(&Default::default(), cx) }), ) }) + .child(div().pr_2()) }) .map(|el| { let status = self.client.status(); @@ -284,11 +321,19 @@ impl Render for CollabTitlebarItem { fn render_color_ribbon(participant_index: ParticipantIndex, colors: &PlayerColors) -> gpui::Canvas { let color = colors.color_for_participant(participant_index.0).cursor; canvas(move |bounds, cx| { - let mut path = Path::new(bounds.lower_left()); let height = bounds.size.height; - path.curve_to(bounds.origin + point(height, px(0.)), bounds.origin); - path.line_to(bounds.upper_right() - point(height, px(0.))); - path.curve_to(bounds.lower_right(), bounds.upper_right()); + let horizontal_offset = height; + let vertical_offset = px(height.0 / 2.0); + let mut path = Path::new(bounds.lower_left()); + path.curve_to( + bounds.origin + point(horizontal_offset, vertical_offset), + bounds.origin + point(px(0.0), vertical_offset), + ); + path.line_to(bounds.upper_right() + point(-horizontal_offset, vertical_offset)); + path.curve_to( + bounds.lower_right(), + bounds.upper_right() + point(px(0.0), vertical_offset), + ); path.line_to(bounds.lower_left()); cx.paint_path(path, color); }) diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 6b81998a8adf0828f93e4cb74bed1aee78b61054..3c0473e67d0a687308c00097c554fc87f47645c1 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -58,7 +58,6 @@ pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) { room.id(), room.channel_id(), &client, - cx, ); Task::ready(room.unshare_screen(cx)) } else { @@ -67,7 +66,6 @@ pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) { room.id(), room.channel_id(), &client, - cx, ); room.share_screen(cx) } @@ -86,7 +84,7 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) { } else { "disable microphone" }; - report_call_event_for_room(operation, room.id(), room.channel_id(), &client, cx); + report_call_event_for_room(operation, room.id(), room.channel_id(), &client); room.toggle_mute(cx) }) diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index b7a18365bee610cbfa1cdebe1410fe9dda1f2891..e7c94984b229165aa26a43000221446d7b56e7a5 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -6,11 +6,11 @@ use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use futures::StreamExt; use gpui::{ - actions, div, img, list, px, serde_json, AnyElement, AppContext, AsyncWindowContext, - CursorStyle, DismissEvent, Element, EventEmitter, FocusHandle, FocusableView, - InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState, Model, - ParentElement, Render, StatefulInteractiveElement, Styled, Task, View, ViewContext, - VisualContext, WeakView, WindowContext, + actions, div, img, list, px, AnyElement, AppContext, AsyncWindowContext, CursorStyle, + DismissEvent, Element, EventEmitter, FocusHandle, FocusableView, InteractiveElement, + IntoElement, ListAlignment, ListScrollEvent, ListState, Model, ParentElement, Render, + StatefulInteractiveElement, Styled, Task, View, ViewContext, VisualContext, WeakView, + WindowContext, }; use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; use project::Fs; diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index da1f77b9afb0f1c4308362f42f479dc30890e77b..0c2d673d8e68b5bd43681788bde078442ed43d9d 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -24,19 +24,11 @@ impl Render for DiagnosticIndicator { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let diagnostic_indicator = match (self.summary.error_count, self.summary.warning_count) { (0, 0) => h_stack().map(|this| { - if !self.in_progress_checks.is_empty() { - this.child( - IconElement::new(Icon::ArrowCircle) - .size(IconSize::Small) - .color(Color::Muted), - ) - } else { - this.child( - IconElement::new(Icon::Check) - .size(IconSize::Small) - .color(Color::Default), - ) - } + this.child( + IconElement::new(Icon::Check) + .size(IconSize::Small) + .color(Color::Default), + ) }), (0, warning_count) => h_stack() .gap_1() @@ -72,9 +64,14 @@ impl Render for DiagnosticIndicator { let status = if !self.in_progress_checks.is_empty() { Some( - Label::new("Checking…") - .size(LabelSize::Small) - .color(Color::Muted) + h_stack() + .gap_2() + .child(IconElement::new(Icon::ArrowCircle).size(IconSize::Small)) + .child( + Label::new("Checking…") + .size(LabelSize::Small) + .into_any_element(), + ) .into_any_element(), ) } else if let Some(diagnostic) = &self.current_diagnostic { diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 8703f1ba40aa06aa0d346606af6098896fab3dfe..4511ffe407849162b603c4b3a44d51e8d552c1c9 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -240,7 +240,7 @@ impl DisplayMap { } pub fn clear_highlights(&mut self, type_id: TypeId) -> bool { let mut cleared = self.text_highlights.remove(&Some(type_id)).is_some(); - cleared |= self.inlay_highlights.remove(&type_id).is_none(); + cleared |= self.inlay_highlights.remove(&type_id).is_some(); cleared } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 455db1d7153d70a6f80ddbb48f880ea5addda39f..231f76218a44125e6c42f2a99f98027f98414ab1 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -8874,7 +8874,7 @@ impl Editor { let telemetry = project.read(cx).client().telemetry().clone(); - telemetry.report_copilot_event(suggestion_id, suggestion_accepted, file_extension, cx) + telemetry.report_copilot_event(suggestion_id, suggestion_accepted, file_extension) } #[cfg(any(test, feature = "test-support"))] @@ -8926,7 +8926,6 @@ impl Editor { operation, copilot_enabled, copilot_enabled_for_language, - cx, ) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index a84b866e1f8139a8ca558ccd54ac57c1d6e08bdd..66f28db3e463d39d14cff2ab3ab1890e6e98995f 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -9,11 +9,7 @@ use crate::{ }; use futures::StreamExt; -use gpui::{ - div, - serde_json::{self, json}, - TestAppContext, VisualTestContext, WindowBounds, WindowOptions, -}; +use gpui::{div, TestAppContext, VisualTestContext, WindowBounds, WindowOptions}; use indoc::indoc; use language::{ language_settings::{AllLanguageSettings, AllLanguageSettingsContent, LanguageSettingsContent}, @@ -24,6 +20,7 @@ use language::{ use parking_lot::Mutex; use project::project_settings::{LspSettings, ProjectSettings}; use project::FakeFs; +use serde_json::{self, json}; use std::sync::atomic; use std::sync::atomic::AtomicUsize; use std::{cell::RefCell, future::Future, rc::Rc, time::Instant}; @@ -8131,8 +8128,8 @@ fn assert_selection_ranges(marked_text: &str, view: &mut Editor, cx: &mut ViewCo /// Handle completion request passing a marked string specifying where the completion /// should be triggered from using '|' character, what range should be replaced, and what completions /// should be returned using '<' and '>' to delimit the range -pub fn handle_completion_request<'a>( - cx: &mut EditorLspTestContext<'a>, +pub fn handle_completion_request( + cx: &mut EditorLspTestContext, marked_string: &str, completions: Vec<&'static str>, ) -> impl Future { @@ -8177,8 +8174,8 @@ pub fn handle_completion_request<'a>( } } -fn handle_resolve_completion_request<'a>( - cx: &mut EditorLspTestContext<'a>, +fn handle_resolve_completion_request( + cx: &mut EditorLspTestContext, edits: Option>, ) -> impl Future { let edits = edits.map(|edits| { diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index e96cb5df0eab53d4bf691c4d13a3e20928856ba5..53a376c2842937a6029cfe6c848b9998153a8d77 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -795,7 +795,7 @@ impl EditorElement { cx.paint_quad(quad( highlight_bounds, Corners::all(1. * line_height), - gpui::yellow(), // todo!("use the right color") + cx.theme().status().modified, Edges::default(), transparent_black(), )); diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 7ee55cddba1edba9356be2c6773c3f097f57c1c8..70c1699b83d090ef24c74f393cd8530502b7ce02 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -21,19 +21,19 @@ use workspace::{AppState, Workspace, WorkspaceHandle}; use super::editor_test_context::{AssertionContextManager, EditorTestContext}; -pub struct EditorLspTestContext<'a> { - pub cx: EditorTestContext<'a>, +pub struct EditorLspTestContext { + pub cx: EditorTestContext, pub lsp: lsp::FakeLanguageServer, pub workspace: View, pub buffer_lsp_url: lsp::Url, } -impl<'a> EditorLspTestContext<'a> { +impl EditorLspTestContext { pub async fn new( mut language: Language, capabilities: lsp::ServerCapabilities, - cx: &'a mut gpui::TestAppContext, - ) -> EditorLspTestContext<'a> { + cx: &mut gpui::TestAppContext, + ) -> EditorLspTestContext { let app_state = cx.update(AppState::test); cx.update(|cx| { @@ -110,8 +110,8 @@ impl<'a> EditorLspTestContext<'a> { pub async fn new_rust( capabilities: lsp::ServerCapabilities, - cx: &'a mut gpui::TestAppContext, - ) -> EditorLspTestContext<'a> { + cx: &mut gpui::TestAppContext, + ) -> EditorLspTestContext { let language = Language::new( LanguageConfig { name: "Rust".into(), @@ -152,8 +152,8 @@ impl<'a> EditorLspTestContext<'a> { pub async fn new_typescript( capabilities: lsp::ServerCapabilities, - cx: &'a mut gpui::TestAppContext, - ) -> EditorLspTestContext<'a> { + cx: &mut gpui::TestAppContext, + ) -> EditorLspTestContext { let mut word_characters: HashSet = Default::default(); word_characters.insert('$'); word_characters.insert('#'); @@ -283,15 +283,15 @@ impl<'a> EditorLspTestContext<'a> { } } -impl<'a> Deref for EditorLspTestContext<'a> { - type Target = EditorTestContext<'a>; +impl Deref for EditorLspTestContext { + type Target = EditorTestContext; fn deref(&self) -> &Self::Target { &self.cx } } -impl<'a> DerefMut for EditorLspTestContext<'a> { +impl DerefMut for EditorLspTestContext { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.cx } diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index bd5acb99459c131d316a5376688f3d4bcb81da93..3289471e81c51c72586e21af1747c6ab8d376930 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -26,20 +26,20 @@ use util::{ use super::build_editor_with_project; -pub struct EditorTestContext<'a> { - pub cx: gpui::VisualTestContext<'a>, +pub struct EditorTestContext { + pub cx: gpui::VisualTestContext, pub window: AnyWindowHandle, pub editor: View, pub assertion_cx: AssertionContextManager, } -impl<'a> EditorTestContext<'a> { - pub async fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> { +impl EditorTestContext { + pub async fn new(cx: &mut gpui::TestAppContext) -> EditorTestContext { let fs = FakeFs::new(cx.executor()); // fs.insert_file("/file", "".to_owned()).await; fs.insert_tree( "/root", - gpui::serde_json::json!({ + serde_json::json!({ "file": "", }), ) @@ -342,7 +342,7 @@ impl<'a> EditorTestContext<'a> { } } -impl<'a> Deref for EditorTestContext<'a> { +impl Deref for EditorTestContext { type Target = gpui::TestAppContext; fn deref(&self) -> &Self::Target { @@ -350,7 +350,7 @@ impl<'a> Deref for EditorTestContext<'a> { } } -impl<'a> DerefMut for EditorTestContext<'a> { +impl DerefMut for EditorTestContext { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.cx } diff --git a/crates/feedback/Cargo.toml b/crates/feedback/Cargo.toml index e14df6a2d48264b37686a5bb942cc9ce9dcb7504..32ecee529c2bcd1de88b778105cffa5e58706e29 100644 --- a/crates/feedback/Cargo.toml +++ b/crates/feedback/Cargo.toml @@ -36,6 +36,7 @@ postage.workspace = true regex.workspace = true serde.workspace = true serde_derive.workspace = true +serde_json.workspace = true smallvec.workspace = true smol.workspace = true sysinfo.workspace = true diff --git a/crates/feedback/src/feedback_modal.rs b/crates/feedback/src/feedback_modal.rs index 87d39186711f8e054c8eb433c4e88f62ce4a388e..6c5308c1c64e3b6339dbdfff714c5a317dbe7842 100644 --- a/crates/feedback/src/feedback_modal.rs +++ b/crates/feedback/src/feedback_modal.rs @@ -7,8 +7,8 @@ use db::kvp::KEY_VALUE_STORE; use editor::{Editor, EditorEvent}; use futures::AsyncReadExt; use gpui::{ - div, red, rems, serde_json, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, - Model, PromptLevel, Render, Task, View, ViewContext, + div, red, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, + PromptLevel, Render, Task, View, ViewContext, }; use isahc::Request; use language::Buffer; diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 589f634d01b7db8c5889f134e9707eabad4943bc..ce68819646c9911ff8d89037527e1743d8c59fb7 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -505,8 +505,7 @@ impl FileFinderDelegate { || path_match.path_prefix.to_string(), |file_name| file_name.to_string_lossy().to_string(), ); - let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count() - - file_name.chars().count(); + let file_name_start = path_match.path_prefix.len() + path_string.len() - file_name.len(); let file_name_positions = path_positions .iter() .filter_map(|pos| { @@ -819,6 +818,44 @@ mod tests { } } + #[gpui::test] + async fn test_complex_path(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "其他": { + "S数据表格": { + "task.xlsx": "some content", + }, + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + + let (picker, workspace, cx) = build_find_picker(project, cx); + + cx.simulate_input("t"); + picker.update(cx, |picker, _| { + assert_eq!(picker.delegate.matches.len(), 1); + assert_eq!( + collect_search_results(picker), + vec![PathBuf::from("其他/S数据表格/task.xlsx")], + ) + }); + cx.dispatch_action(SelectNext); + cx.dispatch_action(Confirm); + cx.read(|cx| { + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + assert_eq!(active_editor.read(cx).title(cx), "task.xlsx"); + }); + } + #[gpui::test] async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -1843,7 +1880,7 @@ mod tests { expected_matches: usize, expected_editor_title: &str, workspace: &View, - cx: &mut gpui::VisualTestContext<'_>, + cx: &mut gpui::VisualTestContext, ) -> Vec { let picker = open_file_picker(&workspace, cx); cx.simulate_input(input); diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index 54682a30ef95419af76c09a9a31cae52df7b4496..e335c4255e4deb0d6c5720b03ce017952a6fa229 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -29,7 +29,7 @@ use std::any::{Any, TypeId}; /// macro, which only generates the code needed to register your action before `main`. /// /// ``` -/// #[derive(gpui::serde::Deserialize, std::cmp::PartialEq, std::clone::Clone)] +/// #[derive(gpui::private::serde::Deserialize, std::cmp::PartialEq, std::clone::Clone)] /// pub struct Paste { /// pub content: SharedString, /// } @@ -158,12 +158,12 @@ impl ActionRegistry { macro_rules! actions { ($namespace:path, [ $($name:ident),* $(,)? ]) => { $( - #[derive(::std::cmp::PartialEq, ::std::clone::Clone, ::std::default::Default, gpui::serde_derive::Deserialize)] - #[serde(crate = "gpui::serde")] + #[derive(::std::cmp::PartialEq, ::std::clone::Clone, ::std::default::Default, gpui::private::serde_derive::Deserialize)] + #[serde(crate = "gpui::private::serde")] pub struct $name; gpui::__impl_action!($namespace, $name, - fn build(_: gpui::serde_json::Value) -> gpui::Result<::std::boxed::Box> { + fn build(_: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box> { Ok(Box::new(Self)) } ); @@ -179,8 +179,8 @@ macro_rules! impl_actions { ($namespace:path, [ $($name:ident),* $(,)? ]) => { $( gpui::__impl_action!($namespace, $name, - fn build(value: gpui::serde_json::Value) -> gpui::Result<::std::boxed::Box> { - Ok(std::boxed::Box::new(gpui::serde_json::from_value::(value)?)) + fn build(value: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box> { + Ok(std::boxed::Box::new(gpui::private::serde_json::from_value::(value)?)) } ); diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 4ad9540043e1058ee9ab9f5e9df9ef9bbce92057..638396abc51e97f58a93fabec816bd8d48a50135 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -43,7 +43,7 @@ use util::{ ResultExt, }; -/// Temporary(?) wrapper around RefCell to help us debug any double borrows. +/// Temporary(?) wrapper around [`RefCell`] to help us debug any double borrows. /// Strongly consider removing after stabilization. pub struct AppCell { app: RefCell, @@ -964,7 +964,7 @@ impl AppContext { /// Event handlers propagate events by default. Call this method to stop dispatching to /// event handlers with a lower z-index (mouse) or higher in the tree (keyboard). This is - /// the opposite of [propagate]. It's also possible to cancel a call to [propagate] by + /// the opposite of [`Self::propagate`]. It's also possible to cancel a call to [`Self::propagate`] by /// calling this method before effects are flushed. pub fn stop_propagation(&mut self) { self.propagate_event = false; @@ -972,7 +972,7 @@ impl AppContext { /// Action handlers stop propagation by default during the bubble phase of action dispatch /// dispatching to action handlers higher in the element tree. This is the opposite of - /// [stop_propagation]. It's also possible to cancel a call to [stop_propagate] by calling + /// [`Self::stop_propagation`]. It's also possible to cancel a call to [`Self::stop_propagation`] by calling /// this method before effects are flushed. pub fn propagate(&mut self) { self.propagate_event = true; @@ -1099,12 +1099,6 @@ impl AppContext { pub fn has_active_drag(&self) -> bool { self.active_drag.is_some() } - - pub fn active_drag(&self) -> Option<&T> { - self.active_drag - .as_ref() - .and_then(|drag| drag.value.downcast_ref()) - } } impl Context for AppContext { diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index 97f680560a3cabd0e7fb88e5da8ca691fd31364e..17f6e47ddfec85de1c82b1fc0b8cd6ffc285aa32 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -1,4 +1,4 @@ -use crate::{private::Sealed, AppContext, Context, Entity, ModelContext}; +use crate::{seal::Sealed, AppContext, Context, Entity, ModelContext}; use anyhow::{anyhow, Result}; use derive_more::{Deref, DerefMut}; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index a5f66be09b2f2ab15ad4e7a465876071d0b01414..0f71ea61a9ec347b01e601f5e3c1a2237b80e691 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -187,6 +187,10 @@ impl TestAppContext { self.test_window(window_handle).simulate_resize(size); } + pub fn windows(&self) -> Vec { + self.app.borrow().windows().clone() + } + pub fn spawn(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task where Fut: Future + 'static, @@ -479,21 +483,24 @@ impl View { } use derive_more::{Deref, DerefMut}; -#[derive(Deref, DerefMut)] -pub struct VisualTestContext<'a> { +#[derive(Deref, DerefMut, Clone)] +pub struct VisualTestContext { #[deref] #[deref_mut] - cx: &'a mut TestAppContext, + cx: TestAppContext, window: AnyWindowHandle, } -impl<'a> VisualTestContext<'a> { +impl<'a> VisualTestContext { pub fn update(&mut self, f: impl FnOnce(&mut WindowContext) -> R) -> R { self.cx.update_window(self.window, |_, cx| f(cx)).unwrap() } - pub fn from_window(window: AnyWindowHandle, cx: &'a mut TestAppContext) -> Self { - Self { cx, window } + pub fn from_window(window: AnyWindowHandle, cx: &TestAppContext) -> Self { + Self { + cx: cx.clone(), + window, + } } pub fn run_until_parked(&self) { @@ -525,9 +532,36 @@ impl<'a> VisualTestContext<'a> { } self.background_executor.run_until_parked(); } + /// Returns true if the window was closed. + pub fn simulate_close(&mut self) -> bool { + let handler = self + .cx + .update_window(self.window, |_, cx| { + cx.window + .platform_window + .as_test() + .unwrap() + .0 + .lock() + .should_close_handler + .take() + }) + .unwrap(); + if let Some(mut handler) = handler { + let should_close = handler(); + self.cx + .update_window(self.window, |_, cx| { + cx.window.platform_window.on_should_close(handler); + }) + .unwrap(); + should_close + } else { + false + } + } } -impl<'a> Context for VisualTestContext<'a> { +impl Context for VisualTestContext { type Result = ::Result; fn new_model( @@ -578,7 +612,7 @@ impl<'a> Context for VisualTestContext<'a> { } } -impl<'a> VisualContext for VisualTestContext<'a> { +impl VisualContext for VisualTestContext { fn new_view( &mut self, build_view: impl FnOnce(&mut ViewContext<'_, V>) -> V, @@ -587,7 +621,7 @@ impl<'a> VisualContext for VisualTestContext<'a> { V: 'static + Render, { self.window - .update(self.cx, |_, cx| cx.new_view(build_view)) + .update(&mut self.cx, |_, cx| cx.new_view(build_view)) .unwrap() } @@ -597,7 +631,7 @@ impl<'a> VisualContext for VisualTestContext<'a> { update: impl FnOnce(&mut V, &mut ViewContext<'_, V>) -> R, ) -> Self::Result { self.window - .update(self.cx, |_, cx| cx.update_view(view, update)) + .update(&mut self.cx, |_, cx| cx.update_view(view, update)) .unwrap() } @@ -609,13 +643,13 @@ impl<'a> VisualContext for VisualTestContext<'a> { V: 'static + Render, { self.window - .update(self.cx, |_, cx| cx.replace_root_view(build_view)) + .update(&mut self.cx, |_, cx| cx.replace_root_view(build_view)) .unwrap() } fn focus_view(&mut self, view: &View) -> Self::Result<()> { self.window - .update(self.cx, |_, cx| { + .update(&mut self.cx, |_, cx| { view.read(cx).focus_handle(cx).clone().focus(cx) }) .unwrap() @@ -626,7 +660,7 @@ impl<'a> VisualContext for VisualTestContext<'a> { V: crate::ManagedView, { self.window - .update(self.cx, |_, cx| { + .update(&mut self.cx, |_, cx| { view.update(cx, |_, cx| cx.emit(crate::DismissEvent)) }) .unwrap() diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index d68ab89f6c8c35d577f7be223db6f198546f717c..a50de8c344247c705d24c8d9a0e94c7c799278bb 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -1582,7 +1582,6 @@ impl From for Edges { /// Represents the corners of a box in a 2D space, such as border radius. /// /// Each field represents the size of the corner on one side of the box: `top_left`, `top_right`, `bottom_right`, and `bottom_left`. -/// ``` #[derive(Refineable, Clone, Default, Debug, Eq, PartialEq)] #[refineable(Debug)] #[repr(C)] @@ -2263,7 +2262,7 @@ impl From for GlobalPixels { } } -/// Represents a length in rems, a unit based on the font-size of the window, which can be assigned with [WindowContext::set_rem_size]. +/// Represents a length in rems, a unit based on the font-size of the window, which can be assigned with [`WindowContext::set_rem_size`][set_rem_size]. /// /// Rems are used for defining lengths that are scalable and consistent across different UI elements. /// The value of `1rem` is typically equal to the font-size of the root element (often the `` element in browsers), @@ -2271,6 +2270,8 @@ impl From for GlobalPixels { /// purpose, allowing for scalable and accessible design that can adjust to different display settings or user preferences. /// /// For example, if the root element's font-size is `16px`, then `1rem` equals `16px`. A length of `2rems` would then be `32px`. +/// +/// [set_rem_size]: crate::WindowContext::set_rem_size #[derive(Clone, Copy, Default, Add, Sub, Mul, Div, Neg)] pub struct Rems(pub f32); diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 2697a622c350d9b485df61fd7f96c40bbc663cf2..d5236d8f08c7f2fa4cb3551ee5a58695634f7e8a 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -30,7 +30,16 @@ mod util; mod view; mod window; -mod private { +/// Do not touch, here be dragons for use by gpui_macros and such. +#[doc(hidden)] +pub mod private { + pub use linkme; + pub use serde; + pub use serde_derive; + pub use serde_json; +} + +mod seal { /// A mechanism for restricting implementations of a trait to only those in GPUI. /// See: https://predr.ag/blog/definitive-guide-to-sealed-traits-in-rust/ pub trait Sealed {} @@ -47,22 +56,17 @@ pub use element::*; pub use elements::*; pub use executor::*; pub use geometry::*; -pub use gpui_macros::*; +pub use gpui_macros::{register_action, test, IntoElement, Render}; pub use image_cache::*; pub use input::*; pub use interactive::*; pub use key_dispatch::*; pub use keymap::*; -pub use linkme; pub use platform::*; -use private::Sealed; pub use refineable::*; pub use scene::*; -pub use serde; -pub use serde_derive; -pub use serde_json; +use seal::Sealed; pub use shared_string::*; -pub use smallvec; pub use smol::Timer; pub use style::*; pub use styled::*; diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index 8592eeffeb3573e4d77e55e252a814630d3c959d..da240a77a8b633683d11d96356451dd889e17818 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -5,8 +5,8 @@ use std::ops::Range; /// Implement this trait to allow views to handle textual input when implementing an editor, field, etc. /// -/// Once your view `V` implements this trait, you can use it to construct an [ElementInputHandler]. -/// This input handler can then be assigned during paint by calling [WindowContext::handle_input]. +/// Once your view `V` implements this trait, you can use it to construct an [`ElementInputHandler`]. +/// This input handler can then be assigned during paint by calling [`WindowContext::handle_input`]. pub trait InputHandler: 'static + Sized { fn text_for_range(&mut self, range: Range, cx: &mut ViewContext) -> Option; @@ -43,8 +43,10 @@ pub struct ElementInputHandler { } impl ElementInputHandler { - /// Used in [Element::paint] with the element's bounds and a view context for its + /// Used in [`Element::paint`][element_paint] with the element's bounds and a view context for its /// containing view. + /// + /// [element_paint]: crate::Element::paint pub fn new(element_bounds: Bounds, view: View, cx: &mut WindowContext) -> Self { ElementInputHandler { view, diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 0be917350df812ce07de2e95d1dac52cd59637ad..6f396d31aa481571d3331e816f30dbf788d47816 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -214,7 +214,7 @@ impl Render for ExternalPaths { pub enum FileDropEvent { Entered { position: Point, - files: ExternalPaths, + paths: ExternalPaths, }, Pending { position: Point, diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 0c4581904f42b19f99ac73300d8dd60721939fe4..0ef345d98d1de34bbb7c6b567b4716608e2e6dbf 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -37,7 +37,7 @@ pub use keystroke::*; pub use mac::*; #[cfg(any(test, feature = "test-support"))] pub use test::*; -pub use time::UtcOffset; +use time::UtcOffset; #[cfg(target_os = "macos")] pub(crate) fn current_platform() -> Rc { diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index d10793a618683ad3c1aa76bef860c468dfce2229..8f48b8ea94d8aa2193545267dd3d85a021a9f96c 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -106,11 +106,6 @@ impl From for Size { } } -pub trait NSRectExt { - fn size(&self) -> Size; - fn intersects(&self, other: Self) -> bool; -} - impl From for Size { fn from(rect: NSRect) -> Self { let NSSize { width, height } = rect.size; @@ -124,16 +119,3 @@ impl From for Size { size(width.into(), height.into()) } } - -// impl NSRectExt for NSRect { -// fn intersects(&self, other: Self) -> bool { -// self.size.width > 0. -// && self.size.height > 0. -// && other.size.width > 0. -// && other.size.height > 0. -// && self.origin.x <= other.origin.x + other.size.width -// && self.origin.x + self.size.width >= other.origin.x -// && self.origin.y <= other.origin.y + other.size.height -// && self.origin.y + self.size.height >= other.origin.y -// } -// } diff --git a/crates/gpui/src/platform/mac/dispatcher.rs b/crates/gpui/src/platform/mac/dispatcher.rs index 06bef49b7a96644695874a0aff2aca43f939a209..18e361885e71d58a8328bb6dc38c1b6b2d286041 100644 --- a/crates/gpui/src/platform/mac/dispatcher.rs +++ b/crates/gpui/src/platform/mac/dispatcher.rs @@ -13,9 +13,14 @@ use parking::{Parker, Unparker}; use parking_lot::Mutex; use std::{ffi::c_void, ptr::NonNull, sync::Arc, time::Duration}; -include!(concat!(env!("OUT_DIR"), "/dispatch_sys.rs")); +/// All items in the generated file are marked as pub, so we're gonna wrap it in a separate mod to prevent +/// these pub items from leaking into public API. +pub(crate) mod dispatch_sys { + include!(concat!(env!("OUT_DIR"), "/dispatch_sys.rs")); +} -pub fn dispatch_get_main_queue() -> dispatch_queue_t { +use dispatch_sys::*; +pub(crate) fn dispatch_get_main_queue() -> dispatch_queue_t { unsafe { &_dispatch_main_q as *const _ as dispatch_queue_t } } diff --git a/crates/gpui/src/platform/mac/display.rs b/crates/gpui/src/platform/mac/display.rs index 2458533f6a3e45a6c0f7789135a8780669012d8c..123cbf8159be02b0496bf8e3656f837e63b03bd4 100644 --- a/crates/gpui/src/platform/mac/display.rs +++ b/crates/gpui/src/platform/mac/display.rs @@ -51,7 +51,7 @@ impl MacDisplay { #[link(name = "ApplicationServices", kind = "framework")] extern "C" { - pub fn CGDisplayCreateUUIDFromDisplayID(display: CGDirectDisplayID) -> CFUUIDRef; + fn CGDisplayCreateUUIDFromDisplayID(display: CGDirectDisplayID) -> CFUUIDRef; } /// Convert the given rectangle from CoreGraphics' native coordinate space to GPUI's coordinate space. diff --git a/crates/gpui/src/platform/mac/display_linker.rs b/crates/gpui/src/platform/mac/display_linker.rs index e367d22b3d7099cdc84fb67a97b013d3c63503e8..8f1b233046fd85ffc4d3b875e73e5f891841c75a 100644 --- a/crates/gpui/src/platform/mac/display_linker.rs +++ b/crates/gpui/src/platform/mac/display_linker.rs @@ -94,7 +94,7 @@ unsafe extern "C" fn trampoline( mod sys { //! Derived from display-link crate under the fololwing license: - //! https://github.com/BrainiumLLC/display-link/blob/master/LICENSE-MIT + //! //! Apple docs: [CVDisplayLink](https://developer.apple.com/documentation/corevideo/cvdisplaylinkoutputcallback?language=objc) #![allow(dead_code, non_upper_case_globals)] diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index ff89f91730ae5d33e6720f1c9723ed8f2741f0ad..8370e2a4953c1280a59d4a9cb74a93ae97214db2 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -56,9 +56,6 @@ use time::UtcOffset; #[allow(non_upper_case_globals)] const NSUTF8StringEncoding: NSUInteger = 4; -#[allow(non_upper_case_globals)] -pub const NSViewLayerContentsRedrawDuringViewResize: NSInteger = 2; - const MAC_PLATFORM_IVAR: &str = "platform"; static mut APP_CLASS: *const Class = ptr::null(); static mut APP_DELEGATE_CLASS: *const Class = ptr::null(); @@ -404,7 +401,7 @@ impl Platform for MacPlatform { // this, we make quitting the application asynchronous so that we aren't holding borrows to // the app state on the stack when we actually terminate the app. - use super::dispatcher::{dispatch_async_f, dispatch_get_main_queue}; + use super::dispatcher::{dispatch_get_main_queue, dispatch_sys::dispatch_async_f}; unsafe { dispatch_async_f(dispatch_get_main_queue(), ptr::null_mut(), Some(quit)); diff --git a/crates/gpui/src/platform/mac/text_system.rs b/crates/gpui/src/platform/mac/text_system.rs index 88ebfd83514949bbe56e661ec5c42da053814d50..d9f7936066b248a7037fb2ea810b7c4a5dc431d2 100644 --- a/crates/gpui/src/platform/mac/text_system.rs +++ b/crates/gpui/src/platform/mac/text_system.rs @@ -500,9 +500,9 @@ impl<'a> StringIndexConverter<'a> { } #[repr(C)] -pub struct __CFTypesetter(c_void); +pub(crate) struct __CFTypesetter(c_void); -pub type CTTypesetterRef = *const __CFTypesetter; +type CTTypesetterRef = *const __CFTypesetter; #[link(name = "CoreText", kind = "framework")] extern "C" { diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 12ffc36afc08f5da22d244b7a3c14bebfb527acd..2beac528c18f53cfa9a39b008dbebf3825502b30 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -1673,10 +1673,7 @@ extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDr if send_new_event(&window_state, { let position = drag_event_position(&window_state, dragging_info); let paths = external_paths_from_event(dragging_info); - InputEvent::FileDrop(FileDropEvent::Entered { - position, - files: paths, - }) + InputEvent::FileDrop(FileDropEvent::Entered { position, paths }) }) { NSDragOperationCopy } else { diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 111fb839211b89c7c47b6d65f7448a92a5997675..695323e9c46b8e2a8f4260a682d8e214f58c43f4 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -266,7 +266,7 @@ impl Platform for TestPlatform { } fn local_timezone(&self) -> time::UtcOffset { - unimplemented!() + time::UtcOffset::UTC } fn path_for_auxiliary_executable(&self, _name: &str) -> Result { diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index f089531b0c94b556d48a1c9a8c6777b863649a10..91f965c10ac2987f4f6c8c15cb40a7cd94171370 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -18,7 +18,7 @@ pub struct TestWindowState { pub(crate) edited: bool, platform: Weak, sprite_atlas: Arc, - + pub(crate) should_close_handler: Option bool>>, input_callback: Option bool>>, active_status_change_callback: Option>, resize_callback: Option, f32)>>, @@ -44,7 +44,7 @@ impl TestWindow { sprite_atlas: Arc::new(TestAtlas::new()), title: Default::default(), edited: false, - + should_close_handler: None, input_callback: None, active_status_change_callback: None, resize_callback: None, @@ -117,6 +117,9 @@ impl TestWindow { self.0.lock().input_handler = Some(input_handler); } + pub fn edited(&self) -> bool { + self.0.lock().edited + } } impl PlatformWindow for TestWindow { @@ -235,8 +238,8 @@ impl PlatformWindow for TestWindow { self.0.lock().moved_callback = Some(callback) } - fn on_should_close(&self, _callback: Box bool>) { - unimplemented!() + fn on_should_close(&self, callback: Box bool>) { + self.0.lock().should_close_handler = Some(callback); } fn on_close(&self, _callback: Box) { diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 7e9504d43466570d96ad5441263c8d02b2e555e2..244ccebf2498fb9ff275d0818215a9ba658ffc02 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -561,6 +561,12 @@ impl From for Fill { } } +impl From for Fill { + fn from(color: Rgba) -> Self { + Self::Color(color.into()) + } +} + impl From for HighlightStyle { fn from(other: TextStyle) -> Self { Self::from(&other) diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 3106a5a961514157d2daf4d0360c395fae45c2df..0969560e95d62e6d74dc82e88eb4b13958a77480 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -33,7 +33,7 @@ pub struct FontId(pub usize); #[derive(Hash, PartialEq, Eq, Clone, Copy, Debug)] pub struct FontFamilyId(pub usize); -pub const SUBPIXEL_VARIANTS: u8 = 4; +pub(crate) const SUBPIXEL_VARIANTS: u8 = 4; pub struct TextSystem { line_layout_cache: Arc, diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 88e564d27fe27b77967a5af86da10fa3b7798ea5..4472da02e71fda1bb17d4353056b67ad58639813 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -1,12 +1,12 @@ use crate::{ - private::Sealed, AnyElement, AnyModel, AnyWeakModel, AppContext, AvailableSpace, BorrowWindow, + seal::Sealed, AnyElement, AnyModel, AnyWeakModel, AppContext, AvailableSpace, BorrowWindow, Bounds, Element, ElementId, Entity, EntityId, Flatten, FocusHandle, FocusableView, IntoElement, LayoutId, Model, Pixels, Point, Render, Size, ViewContext, VisualContext, WeakModel, WindowContext, }; use anyhow::{Context, Result}; use std::{ - any::TypeId, + any::{type_name, TypeId}, fmt, hash::{Hash, Hasher}, }; @@ -104,6 +104,14 @@ impl Clone for View { } } +impl std::fmt::Debug for View { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct(&format!("View<{}>", type_name::())) + .field("entity_id", &self.model.entity_id) + .finish_non_exhaustive() + } +} + impl Hash for View { fn hash(&self, state: &mut H) { self.model.hash(state); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 71e6cb9e559a97634ee7d2df8610569838c32a5d..7e4c5f93f95e6ea770d404a63a3e6795d9a4be7d 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1462,12 +1462,12 @@ impl<'a> WindowContext<'a> { // Translate dragging and dropping of external files from the operating system // to internal drag and drop events. InputEvent::FileDrop(file_drop) => match file_drop { - FileDropEvent::Entered { position, files } => { + FileDropEvent::Entered { position, paths } => { self.window.mouse_position = position; if self.active_drag.is_none() { self.active_drag = Some(AnyDrag { - value: Box::new(files.clone()), - view: self.new_view(|_| files).into(), + value: Box::new(paths.clone()), + view: self.new_view(|_| paths).into(), cursor_offset: position, }); } @@ -1826,9 +1826,11 @@ impl<'a> WindowContext<'a> { result } - /// Set an input handler, such as [ElementInputHandler], which interfaces with the + /// Set an input handler, such as [`ElementInputHandler`][element_input_handler], which interfaces with the /// platform to receive textual input with proper integration with concerns such /// as IME interactions. + /// + /// [element_input_handler]: crate::ElementInputHandler pub fn handle_input( &mut self, focus_handle: &FocusHandle, @@ -2500,8 +2502,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { } /// Register a listener to be called when the given focus handle receives focus. - /// Unlike [on_focus_changed], returns a subscription and persists until the subscription - /// is dropped. + /// Returns a subscription and persists until the subscription is dropped. pub fn on_focus( &mut self, handle: &FocusHandle, @@ -2527,8 +2528,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { } /// Register a listener to be called when the given focus handle or one of its descendants receives focus. - /// Unlike [on_focus_changed], returns a subscription and persists until the subscription - /// is dropped. + /// Returns a subscription and persists until the subscription is dropped. pub fn on_focus_in( &mut self, handle: &FocusHandle, @@ -2554,8 +2554,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { } /// Register a listener to be called when the given focus handle loses focus. - /// Unlike [on_focus_changed], returns a subscription and persists until the subscription - /// is dropped. + /// Returns a subscription and persists until the subscription is dropped. pub fn on_blur( &mut self, handle: &FocusHandle, @@ -2581,8 +2580,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { } /// Register a listener to be called when the window loses focus. - /// Unlike [on_focus_changed], returns a subscription and persists until the subscription - /// is dropped. + /// Returns a subscription and persists until the subscription is dropped. pub fn on_blur_window( &mut self, mut listener: impl FnMut(&mut V, &mut ViewContext) + 'static, @@ -2597,8 +2595,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { } /// Register a listener to be called when the given focus handle or one of its descendants loses focus. - /// Unlike [on_focus_changed], returns a subscription and persists until the subscription - /// is dropped. + /// Returns a subscription and persists until the subscription is dropped. pub fn on_focus_out( &mut self, handle: &FocusHandle, diff --git a/crates/gpui/tests/action_macros.rs b/crates/gpui/tests/action_macros.rs index f77d8a2d0da31d64ba8e0dd818520ac91d430011..9e5f6dea16ca6ad0ad7a1eb7f7098b61e0fd4cea 100644 --- a/crates/gpui/tests/action_macros.rs +++ b/crates/gpui/tests/action_macros.rs @@ -11,7 +11,7 @@ fn test_action_macros() { impl_actions!(test, [AnotherTestAction]); - #[derive(PartialEq, Clone, gpui::serde_derive::Deserialize)] + #[derive(PartialEq, Clone, gpui::private::serde_derive::Deserialize)] struct RegisterableAction {} register_action!(RegisterableAction); diff --git a/crates/gpui_macros/src/register_action.rs b/crates/gpui_macros/src/register_action.rs index 9e3a473843df734b58190675e6f4fb78110b5c6f..c18e4f4b89a68859b1c413357649cf6ad025e8d5 100644 --- a/crates/gpui_macros/src/register_action.rs +++ b/crates/gpui_macros/src/register_action.rs @@ -36,8 +36,8 @@ pub(crate) fn register_action(type_name: &Ident) -> proc_macro2::TokenStream { quote! { #[doc(hidden)] - #[gpui::linkme::distributed_slice(gpui::__GPUI_ACTIONS)] - #[linkme(crate = gpui::linkme)] + #[gpui::private::linkme::distributed_slice(gpui::__GPUI_ACTIONS)] + #[linkme(crate = gpui::private::linkme)] static #static_slice_name: gpui::MacroActionBuilder = #action_builder_fn_name; /// This is an auto generated function, do not use. diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index a1236297ed1e3f0af70ddc557994087304493c7c..2ae74e7f5d5d60c9485c22235633b1a23f952605 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -11,7 +11,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use workspace::{AppState, Workspace}; +use workspace::{AppState, OpenVisible, Workspace}; actions!(journal, [NewJournalEntry]); @@ -100,7 +100,7 @@ pub fn new_journal_entry(app_state: Arc, cx: &mut WindowContext) { let opened = workspace .update(&mut cx, |workspace, cx| { - workspace.open_paths(vec![entry_path], true, cx) + workspace.open_paths(vec![entry_path], OpenVisible::All, None, cx) })? .await; diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index 0bf064092e4787c96b1b3c475f3fd09f72685a5d..1681e78425fa012fd08042a660e0283797d9879f 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -24,6 +24,7 @@ futures.workspace = true serde.workspace = true anyhow.workspace = true tree-sitter.workspace = true +serde_json.workspace = true [dev-dependencies] client = { path = "../client", features = ["test-support"] } diff --git a/crates/language_tools/src/lsp_log_tests.rs b/crates/language_tools/src/lsp_log_tests.rs index 194b6d1ae8d4f0a83dd1d98a8c8b0b360c7fdd78..14683ae8069771b730c4e008c19801b669160154 100644 --- a/crates/language_tools/src/lsp_log_tests.rs +++ b/crates/language_tools/src/lsp_log_tests.rs @@ -4,9 +4,10 @@ use crate::lsp_log::LogMenuItem; use super::*; use futures::StreamExt; -use gpui::{serde_json::json, Context, TestAppContext, VisualTestContext}; +use gpui::{Context, TestAppContext, VisualTestContext}; use language::{tree_sitter_rust, FakeLspAdapter, Language, LanguageConfig, LanguageServerName}; use project::{FakeFs, Project}; +use serde_json::json; use settings::SettingsStore; #[gpui::test] diff --git a/crates/project_symbols/Cargo.toml b/crates/project_symbols/Cargo.toml index 91cc882e41e126fc8fb297866b3ffa4f9394b3a0..da96fb2db56418e117f4c62c982c4d39884f86aa 100644 --- a/crates/project_symbols/Cargo.toml +++ b/crates/project_symbols/Cargo.toml @@ -24,6 +24,7 @@ anyhow.workspace = true ordered-float.workspace = true postage.workspace = true smol.workspace = true +serde_json.workspace = true [dev-dependencies] futures.workspace = true diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 3c2760f720c02f411da1ef08f1d8e8bad926716f..ed31ebd94997bd01d53b56df4f62dfff0518f737 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -260,9 +260,10 @@ impl PickerDelegate for ProjectSymbolsDelegate { mod tests { use super::*; use futures::StreamExt; - use gpui::{serde_json::json, TestAppContext, VisualContext}; + use gpui::{TestAppContext, VisualContext}; use language::{FakeLspAdapter, Language, LanguageConfig}; use project::FakeFs; + use serde_json::json; use settings::SettingsStore; use std::{path::Path, sync::Arc}; diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 3f1cbce1bded6c7b7517597606f176062c34e06b..c889f0a4a4c11d3f104e130e34e5b87c092565d6 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -1091,13 +1091,10 @@ mod tests { theme::init(theme::LoadThemes::JustBase, cx); }); } + fn init_test( cx: &mut TestAppContext, - ) -> ( - View, - View, - &mut VisualTestContext<'_>, - ) { + ) -> (View, View, &mut VisualTestContext) { init_globals(cx); let buffer = cx.new_model(|cx| { Buffer::new( diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index d43508bdbe0100b6130b1a655e01160433962477..e1605eb4fb4e35b16c7e08bee6107537462cc2ed 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -1311,34 +1311,33 @@ impl Terminal { }) } - pub fn title(&self) -> String { + pub fn title(&self, truncate: bool) -> String { self.foreground_process_info .as_ref() .map(|fpi| { - format!( - "{} — {}", - truncate_and_trailoff( - &fpi.cwd - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default(), - 25 - ), - truncate_and_trailoff( - &{ - format!( - "{}{}", - fpi.name, - if fpi.argv.len() >= 1 { - format!(" {}", (&fpi.argv[1..]).join(" ")) - } else { - "".to_string() - } - ) - }, - 25 + let process_file = fpi + .cwd + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + let process_name = format!( + "{}{}", + fpi.name, + if fpi.argv.len() >= 1 { + format!(" {}", (&fpi.argv[1..]).join(" ")) + } else { + "".to_string() + } + ); + let (process_file, process_name) = if truncate { + ( + truncate_and_trailoff(&process_file, 25), + truncate_and_trailoff(&process_name, 25), ) - ) + } else { + (process_file, process_name) + }; + format!("{process_file} — {process_name}") }) .unwrap_or_else(|| "Terminal".to_string()) } diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index d84846018346dc530465eb66835f9a9cc894ac59..811d420045dce27505cccf9b63fd0d49491501d9 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -36,6 +36,7 @@ thiserror.workspace = true lazy_static.workspace = true serde.workspace = true serde_derive.workspace = true +serde_json.workspace = true [dev-dependencies] editor = { path = "../editor", features = ["test-support"] } diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index ffdca7d8135d2702a3cbc9b4d42070e8ec4e41a4..d936716032a53b432d2f6f1a5dc6b79069656c8b 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1,12 +1,12 @@ use editor::{Cursor, HighlightedRange, HighlightedRangeLine}; use gpui::{ div, fill, point, px, red, relative, AnyElement, AsyncWindowContext, AvailableSpace, - BorrowWindow, Bounds, DispatchPhase, Element, ElementId, ExternalPaths, FocusHandle, Font, - FontStyle, FontWeight, HighlightStyle, Hsla, InteractiveBounds, InteractiveElement, + BorrowWindow, Bounds, DispatchPhase, Element, ElementId, FocusHandle, Font, FontStyle, + FontWeight, HighlightStyle, Hsla, InteractiveBounds, InteractiveElement, InteractiveElementState, Interactivity, IntoElement, LayoutId, Model, ModelContext, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, PlatformInputHandler, Point, - ShapedLine, StatefulInteractiveElement, StyleRefinement, Styled, TextRun, TextStyle, - TextSystem, UnderlineStyle, WhiteSpace, WindowContext, + ShapedLine, StatefulInteractiveElement, Styled, TextRun, TextStyle, TextSystem, UnderlineStyle, + WhiteSpace, WindowContext, }; use itertools::Itertools; use language::CursorShape; @@ -25,7 +25,7 @@ use terminal::{ use theme::{ActiveTheme, Theme, ThemeSettings}; use ui::Tooltip; -use std::{any::TypeId, mem}; +use std::mem; use std::{fmt::Debug, ops::RangeInclusive}; ///The information generated during layout that is necessary for painting @@ -677,28 +677,6 @@ impl TerminalElement { } }); - self.interactivity.drag_over_styles.push(( - TypeId::of::(), - StyleRefinement::default().bg(cx.theme().colors().drop_target_background), - )); - self.interactivity.on_drop::({ - let focus = focus.clone(); - let terminal = terminal.clone(); - move |external_paths, cx| { - cx.focus(&focus); - let mut new_text = external_paths - .paths() - .iter() - .map(|path| format!(" {path:?}")) - .join(""); - new_text.push(' '); - terminal.update(cx, |terminal, _| { - // todo!() long paths are not displayed properly albeit the text is there - terminal.paste(&new_text); - }); - } - }); - // Mouse mode handlers: // All mouse modes need the extra click handlers if mode.intersects(TermMode::MOUSE_MODE) { diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 32bad620ba03b0b42b59257c9af9f3686e104b38..99929535700e2badbe0c447ac6dfc4ee5998e7e2 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1,13 +1,14 @@ -use std::{path::PathBuf, sync::Arc}; +use std::{ops::ControlFlow, path::PathBuf, sync::Arc}; use crate::TerminalView; use db::kvp::KEY_VALUE_STORE; use gpui::{ - actions, serde_json, AppContext, AsyncWindowContext, Entity, EventEmitter, ExternalPaths, - FocusHandle, FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, - Task, View, ViewContext, VisualContext, WeakView, WindowContext, + actions, AppContext, AsyncWindowContext, Entity, EventEmitter, ExternalPaths, FocusHandle, + FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, Task, View, + ViewContext, VisualContext, WeakView, WindowContext, }; -use project::Fs; +use itertools::Itertools; +use project::{Fs, ProjectEntryId}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; @@ -19,7 +20,7 @@ use workspace::{ item::Item, pane, ui::Icon, - Pane, Workspace, + DraggedTab, Pane, Workspace, }; use anyhow::Result; @@ -59,18 +60,7 @@ impl TerminalPanel { workspace.weak_handle(), workspace.project().clone(), Default::default(), - Some(Arc::new(|a, cx| { - if let Some(tab) = a.downcast_ref::() { - if let Some(item) = tab.pane.read(cx).item_for_index(tab.ix) { - return item.downcast::().is_some(); - } - } - if a.downcast_ref::().is_some() { - return true; - } - - false - })), + None, cx, ); pane.set_can_split(false, cx); @@ -105,6 +95,52 @@ impl TerminalPanel { }) .into_any_element() }); + + let workspace = workspace.weak_handle(); + pane.set_custom_drop_handle(cx, move |pane, dropped_item, cx| { + if let Some(tab) = dropped_item.downcast_ref::() { + let item = if &tab.pane == cx.view() { + pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + if let Some(item) = item { + if item.downcast::().is_some() { + return ControlFlow::Continue(()); + } else if let Some(project_path) = item.project_path(cx) { + if let Some(entry_path) = workspace + .update(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .absolute_path(&project_path, cx) + }) + .log_err() + .flatten() + { + add_paths_to_terminal(pane, &[entry_path], cx); + } + } + } + } else if let Some(&entry_id) = dropped_item.downcast_ref::() { + if let Some(entry_path) = workspace + .update(cx, |workspace, cx| { + let project = workspace.project().read(cx); + project + .path_for_entry(entry_id, cx) + .and_then(|project_path| project.absolute_path(&project_path, cx)) + }) + .log_err() + .flatten() + { + add_paths_to_terminal(pane, &[entry_path], cx); + } + } else if let Some(paths) = dropped_item.downcast_ref::() { + add_paths_to_terminal(pane, paths.paths(), cx); + } + + ControlFlow::Break(()) + }); let buffer_search_bar = cx.new_view(search::BufferSearchBar::new); pane.toolbar() .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx)); @@ -217,7 +253,6 @@ impl TerminalPanel { pane::Event::Remove => cx.emit(PanelEvent::Close), pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn), pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut), - pane::Event::Focus => cx.emit(PanelEvent::Focus), pane::Event::AddItem { item } => { if let Some(workspace) = self.workspace.upgrade() { @@ -330,6 +365,22 @@ impl TerminalPanel { } } +fn add_paths_to_terminal(pane: &mut Pane, paths: &[PathBuf], cx: &mut ViewContext<'_, Pane>) { + if let Some(terminal_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + cx.focus_view(&terminal_view); + let mut new_text = paths.iter().map(|path| format!(" {path:?}")).join(""); + new_text.push(' '); + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.terminal().update(cx, |terminal, _| { + terminal.paste(&new_text); + }); + }); + } +} + impl EventEmitter for TerminalPanel {} impl Render for TerminalPanel { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index d519523974f7a63ee210cd4cb4e2c1c06f2da8db..8f5044e49e01e5ed2244581f24d1663f8ee8c73a 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -29,7 +29,8 @@ use workspace::{ notifications::NotifyResultExt, register_deserializable_item, searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}, - CloseActiveItem, NewCenterTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId, + CloseActiveItem, NewCenterTerminal, OpenVisible, Pane, ToolbarItemLocation, Workspace, + WorkspaceId, }; use anyhow::Context; @@ -192,12 +193,26 @@ impl TerminalView { } let potential_abs_paths = possible_open_targets(&workspace, maybe_path, cx); if let Some(path) = potential_abs_paths.into_iter().next() { - let is_dir = path.path_like.is_dir(); let task_workspace = workspace.clone(); cx.spawn(|_, mut cx| async move { + let fs = task_workspace.update(&mut cx, |workspace, cx| { + workspace.project().read(cx).fs().clone() + })?; + let is_dir = fs + .metadata(&path.path_like) + .await? + .with_context(|| { + format!("Missing metadata for file {:?}", path.path_like) + })? + .is_dir; let opened_items = task_workspace .update(&mut cx, |workspace, cx| { - workspace.open_paths(vec![path.path_like], is_dir, cx) + workspace.open_paths( + vec![path.path_like], + OpenVisible::OnlyDirectories, + None, + cx, + ) }) .context("workspace update")? .await; @@ -665,7 +680,7 @@ impl Item for TerminalView { type Event = ItemEvent; fn tab_tooltip_text(&self, cx: &AppContext) -> Option { - Some(self.terminal().read(cx).title().into()) + Some(self.terminal().read(cx).title(false).into()) } fn tab_content( @@ -674,8 +689,7 @@ impl Item for TerminalView { selected: bool, cx: &WindowContext, ) -> AnyElement { - let title = self.terminal().read(cx).title(); - + let title = self.terminal().read(cx).title(true); h_stack() .gap_2() .child(IconElement::new(Icon::Terminal)) diff --git a/crates/theme_importer/Cargo.toml b/crates/theme_importer/Cargo.toml index 1b0c39fee72c2cb1fdf6b5d419934441b2643c0f..7bcb2daa633e714ed18ef1a78e2aab4c55e885cd 100644 --- a/crates/theme_importer/Cargo.toml +++ b/crates/theme_importer/Cargo.toml @@ -18,6 +18,7 @@ palette = { version = "0.7.3", default-features = false, features = ["std"] } pathfinder_color = "0.5" rust-embed.workspace = true serde.workspace = true +serde_json.workspace = true simplelog = "0.9" strum = { version = "0.25.0", features = ["derive"] } theme = { path = "../theme", features = ["importing-themes"] } diff --git a/crates/theme_importer/src/main.rs b/crates/theme_importer/src/main.rs index 3a7e0b20d55e2ecd92ff627cb878af3faaae1775..ff20d36a5df6ad3ffebbe0dc4f58a85ec1383707 100644 --- a/crates/theme_importer/src/main.rs +++ b/crates/theme_importer/src/main.rs @@ -16,7 +16,6 @@ use any_ascii::any_ascii; use anyhow::{anyhow, Context, Result}; use clap::Parser; use convert_case::{Case, Casing}; -use gpui::serde_json; use indexmap::IndexMap; use indoc::formatdoc; use json_comments::StripComments; diff --git a/crates/theme_importer/src/zed1/converter.rs b/crates/theme_importer/src/zed1/converter.rs index 86c40dfde55a29cb7bb0c6745ae433ecba6553d3..9f40c3695fcc6773e549d829e84a922aceef567c 100644 --- a/crates/theme_importer/src/zed1/converter.rs +++ b/crates/theme_importer/src/zed1/converter.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use gpui::{serde_json, Hsla, Rgba}; +use gpui::{Hsla, Rgba}; use theme::{ color_alpha, Appearance, PlayerColor, PlayerColors, StatusColorsRefinement, ThemeColorsRefinement, UserFontStyle, UserFontWeight, UserHighlightStyle, UserSyntaxTheme, diff --git a/crates/theme_importer/src/zed1/theme.rs b/crates/theme_importer/src/zed1/theme.rs index 7743fba0b1be00719f5303decedbaa7f027fc2bc..70efb6ab2bfaef988cc668b6f30ce4981a3471f8 100644 --- a/crates/theme_importer/src/zed1/theme.rs +++ b/crates/theme_importer/src/zed1/theme.rs @@ -6,10 +6,10 @@ use std::fmt; use std::ops::{Deref, DerefMut}; use std::sync::Arc; -use gpui::serde_json::{self, Value}; use pathfinder_color::ColorU; use serde::de::{self, DeserializeOwned, Unexpected}; use serde::{Deserialize, Deserializer}; +use serde_json::{self, Value}; #[derive(Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] #[repr(transparent)] diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index cfb98ccd7455fd17c81a3de65a82b55f5d4bd877..2bb8c6648cab0ff1ef79f7f79cf5ee85da8af07c 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -182,7 +182,7 @@ impl PickerDelegate for ThemeSelectorDelegate { let theme_name = cx.theme().name.clone(); self.telemetry - .report_setting_event("theme", theme_name.to_string(), cx); + .report_setting_event("theme", theme_name.to_string()); update_settings_file::(self.fs.clone(), cx, move |settings| { settings.theme = Some(theme_name.to_string()); diff --git a/crates/vim/src/test/neovim_backed_binding_test_context.rs b/crates/vim/src/test/neovim_backed_binding_test_context.rs index 15fce99aad3f4ea0e03129342a4bca48fba4166f..0f64a6c849a32b6764cd5d605b5568c64759d89d 100644 --- a/crates/vim/src/test/neovim_backed_binding_test_context.rs +++ b/crates/vim/src/test/neovim_backed_binding_test_context.rs @@ -4,30 +4,27 @@ use crate::state::Mode; use super::{ExemptionFeatures, NeovimBackedTestContext, SUPPORTED_FEATURES}; -pub struct NeovimBackedBindingTestContext<'a, const COUNT: usize> { - cx: NeovimBackedTestContext<'a>, +pub struct NeovimBackedBindingTestContext { + cx: NeovimBackedTestContext, keystrokes_under_test: [&'static str; COUNT], } -impl<'a, const COUNT: usize> NeovimBackedBindingTestContext<'a, COUNT> { - pub fn new( - keystrokes_under_test: [&'static str; COUNT], - cx: NeovimBackedTestContext<'a>, - ) -> Self { +impl NeovimBackedBindingTestContext { + pub fn new(keystrokes_under_test: [&'static str; COUNT], cx: NeovimBackedTestContext) -> Self { Self { cx, keystrokes_under_test, } } - pub fn consume(self) -> NeovimBackedTestContext<'a> { + pub fn consume(self) -> NeovimBackedTestContext { self.cx } pub fn binding( self, keystrokes: [&'static str; NEW_COUNT], - ) -> NeovimBackedBindingTestContext<'a, NEW_COUNT> { + ) -> NeovimBackedBindingTestContext { self.consume().binding(keystrokes) } @@ -80,15 +77,15 @@ impl<'a, const COUNT: usize> NeovimBackedBindingTestContext<'a, COUNT> { } } -impl<'a, const COUNT: usize> Deref for NeovimBackedBindingTestContext<'a, COUNT> { - type Target = NeovimBackedTestContext<'a>; +impl Deref for NeovimBackedBindingTestContext { + type Target = NeovimBackedTestContext; fn deref(&self) -> &Self::Target { &self.cx } } -impl<'a, const COUNT: usize> DerefMut for NeovimBackedBindingTestContext<'a, COUNT> { +impl DerefMut for NeovimBackedBindingTestContext { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.cx } diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 7380537655b1e2765003aeaec37d7918c2607bfb..fe5c5db62f831a3725e753ef4df3d448c28c4e68 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -47,8 +47,8 @@ impl ExemptionFeatures { } } -pub struct NeovimBackedTestContext<'a> { - cx: VimTestContext<'a>, +pub struct NeovimBackedTestContext { + cx: VimTestContext, // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which // bindings are exempted. If None, all bindings are ignored for that insertion text. exemptions: HashMap>>, @@ -60,8 +60,8 @@ pub struct NeovimBackedTestContext<'a> { is_dirty: bool, } -impl<'a> NeovimBackedTestContext<'a> { - pub async fn new(cx: &'a mut gpui::TestAppContext) -> NeovimBackedTestContext<'a> { +impl NeovimBackedTestContext { + pub async fn new(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext { // rust stores the name of the test on the current thread. // We use this to automatically name a file that will store // the neovim connection's requests/responses so that we can @@ -393,20 +393,20 @@ impl<'a> NeovimBackedTestContext<'a> { pub fn binding( self, keystrokes: [&'static str; COUNT], - ) -> NeovimBackedBindingTestContext<'a, COUNT> { + ) -> NeovimBackedBindingTestContext { NeovimBackedBindingTestContext::new(keystrokes, self) } } -impl<'a> Deref for NeovimBackedTestContext<'a> { - type Target = VimTestContext<'a>; +impl Deref for NeovimBackedTestContext { + type Target = VimTestContext; fn deref(&self) -> &Self::Target { &self.cx } } -impl<'a> DerefMut for NeovimBackedTestContext<'a> { +impl DerefMut for NeovimBackedTestContext { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.cx } @@ -415,7 +415,7 @@ impl<'a> DerefMut for NeovimBackedTestContext<'a> { // a common mistake in tests is to call set_shared_state when // you mean asswert_shared_state. This notices that and lets // you know. -impl<'a> Drop for NeovimBackedTestContext<'a> { +impl Drop for NeovimBackedTestContext { fn drop(&mut self) { if self.is_dirty { panic!("Test context was dropped after set_shared_state before assert_shared_state") @@ -425,9 +425,8 @@ impl<'a> Drop for NeovimBackedTestContext<'a> { #[cfg(test)] mod test { - use gpui::TestAppContext; - use crate::test::NeovimBackedTestContext; + use gpui::TestAppContext; #[gpui::test] async fn neovim_backed_test_context_works(cx: &mut TestAppContext) { diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 21b041b2451f5dce4d930ebbc9c66705ed5a22a2..5ed5296bff44d3e76c32f2a4b768afd760d1d121 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -10,11 +10,11 @@ use search::BufferSearchBar; use crate::{state::Operator, *}; -pub struct VimTestContext<'a> { - cx: EditorLspTestContext<'a>, +pub struct VimTestContext { + cx: EditorLspTestContext, } -impl<'a> VimTestContext<'a> { +impl VimTestContext { pub fn init(cx: &mut gpui::TestAppContext) { if cx.has_global::() { dbg!("OOPS"); @@ -29,13 +29,13 @@ impl<'a> VimTestContext<'a> { }); } - pub async fn new(cx: &'a mut gpui::TestAppContext, enabled: bool) -> VimTestContext<'a> { + pub async fn new(cx: &mut gpui::TestAppContext, enabled: bool) -> VimTestContext { Self::init(cx); let lsp = EditorLspTestContext::new_rust(Default::default(), cx).await; Self::new_with_lsp(lsp, enabled) } - pub async fn new_typescript(cx: &'a mut gpui::TestAppContext) -> VimTestContext<'a> { + pub async fn new_typescript(cx: &mut gpui::TestAppContext) -> VimTestContext { Self::init(cx); Self::new_with_lsp( EditorLspTestContext::new_typescript(Default::default(), cx).await, @@ -43,7 +43,7 @@ impl<'a> VimTestContext<'a> { ) } - pub fn new_with_lsp(mut cx: EditorLspTestContext<'a>, enabled: bool) -> VimTestContext<'a> { + pub fn new_with_lsp(mut cx: EditorLspTestContext, enabled: bool) -> VimTestContext { cx.update(|cx| { cx.update_global(|store: &mut SettingsStore, cx| { store.update_user_settings::(cx, |s| *s = Some(enabled)); @@ -162,15 +162,15 @@ impl<'a> VimTestContext<'a> { } } -impl<'a> Deref for VimTestContext<'a> { - type Target = EditorTestContext<'a>; +impl Deref for VimTestContext { + type Target = EditorTestContext; fn deref(&self) -> &Self::Target { &self.cx } } -impl<'a> DerefMut for VimTestContext<'a> { +impl DerefMut for VimTestContext { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.cx } diff --git a/crates/welcome/src/base_keymap_picker.rs b/crates/welcome/src/base_keymap_picker.rs index 9a8edf0eb33b21b4d324c8390e638febe777e643..e22c89cef882d6e1977e2e349f49f5f4d98c14a1 100644 --- a/crates/welcome/src/base_keymap_picker.rs +++ b/crates/welcome/src/base_keymap_picker.rs @@ -1,4 +1,5 @@ use super::base_keymap_setting::BaseKeymap; +use client::telemetry::Telemetry; use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; use gpui::{ actions, AppContext, DismissEvent, EventEmitter, FocusableView, Render, Task, View, @@ -27,22 +28,22 @@ pub fn toggle( cx: &mut ViewContext, ) { let fs = workspace.app_state().fs.clone(); + let telemetry = workspace.client().telemetry().clone(); workspace.toggle_modal(cx, |cx| { BaseKeymapSelector::new( - BaseKeymapSelectorDelegate::new(cx.view().downgrade(), fs, cx), + BaseKeymapSelectorDelegate::new(cx.view().downgrade(), fs, telemetry, cx), cx, ) }); } pub struct BaseKeymapSelector { - focus_handle: gpui::FocusHandle, picker: View>, } impl FocusableView for BaseKeymapSelector { - fn focus_handle(&self, _cx: &AppContext) -> gpui::FocusHandle { - self.focus_handle.clone() + fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle { + self.picker.focus_handle(cx) } } @@ -55,17 +56,13 @@ impl BaseKeymapSelector { cx: &mut ViewContext, ) -> Self { let picker = cx.new_view(|cx| Picker::new(delegate, cx)); - let focus_handle = cx.focus_handle(); - Self { - focus_handle, - picker, - } + Self { picker } } } impl Render for BaseKeymapSelector { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - self.picker.clone() + v_stack().w(rems(34.)).child(self.picker.clone()) } } @@ -73,6 +70,7 @@ pub struct BaseKeymapSelectorDelegate { view: WeakView, matches: Vec, selected_index: usize, + telemetry: Arc, fs: Arc, } @@ -80,6 +78,7 @@ impl BaseKeymapSelectorDelegate { fn new( weak_view: WeakView, fs: Arc, + telemetry: Arc, cx: &mut ViewContext, ) -> Self { let base = BaseKeymap::get(None, cx); @@ -91,6 +90,7 @@ impl BaseKeymapSelectorDelegate { view: weak_view, matches: Vec::new(), selected_index, + telemetry, fs, } } @@ -172,6 +172,10 @@ impl PickerDelegate for BaseKeymapSelectorDelegate { fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { if let Some(selection) = self.matches.get(self.selected_index) { let base_keymap = BaseKeymap::from_names(&selection.string); + + self.telemetry + .report_setting_event("keymap", base_keymap.to_string()); + update_settings_file::(self.fs.clone(), cx, move |setting| { *setting = Some(base_keymap) }); @@ -184,7 +188,13 @@ impl PickerDelegate for BaseKeymapSelectorDelegate { .ok(); } - fn dismissed(&mut self, _cx: &mut ViewContext>) {} + fn dismissed(&mut self, cx: &mut ViewContext>) { + self.view + .update(cx, |_, cx| { + cx.emit(DismissEvent); + }) + .log_err(); + } fn render_match( &self, diff --git a/crates/welcome/src/base_keymap_setting.rs b/crates/welcome/src/base_keymap_setting.rs index cad6e894f95d7c0621f703fa28ed34a6d7726764..411caa820e34e2cc080c160c39f4053161901e92 100644 --- a/crates/welcome/src/base_keymap_setting.rs +++ b/crates/welcome/src/base_keymap_setting.rs @@ -1,3 +1,5 @@ +use std::fmt::{Display, Formatter}; + use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -12,6 +14,18 @@ pub enum BaseKeymap { TextMate, } +impl Display for BaseKeymap { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + BaseKeymap::VSCode => write!(f, "VSCode"), + BaseKeymap::JetBrains => write!(f, "JetBrains"), + BaseKeymap::SublimeText => write!(f, "Sublime Text"), + BaseKeymap::Atom => write!(f, "Atom"), + BaseKeymap::TextMate => write!(f, "TextMate"), + } + } +} + impl BaseKeymap { pub const OPTIONS: [(&'static str, Self); 5] = [ ("VSCode (Default)", Self::VSCode), diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs index d096248a28935224de782dcdd9b62d440d50730f..76988fadb06b9124f1b197178cb0c89106670f7a 100644 --- a/crates/welcome/src/welcome.rs +++ b/crates/welcome/src/welcome.rs @@ -1,7 +1,7 @@ mod base_keymap_picker; mod base_keymap_setting; -use client::TelemetrySettings; +use client::{telemetry::Telemetry, TelemetrySettings}; use db::kvp::KEY_VALUE_STORE; use gpui::{ svg, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, @@ -27,7 +27,7 @@ pub fn init(cx: &mut AppContext) { cx.observe_new_views(|workspace: &mut Workspace, _cx| { workspace.register_action(|workspace, _: &Welcome, cx| { - let welcome_page = cx.new_view(|cx| WelcomePage::new(workspace, cx)); + let welcome_page = WelcomePage::new(workspace, cx); workspace.add_item(Box::new(welcome_page), cx) }); }) @@ -39,7 +39,7 @@ pub fn init(cx: &mut AppContext) { pub fn show_welcome_view(app_state: &Arc, cx: &mut AppContext) { open_new(&app_state, cx, |workspace, cx| { workspace.toggle_dock(DockPosition::Left, cx); - let welcome_page = cx.new_view(|cx| WelcomePage::new(workspace, cx)); + let welcome_page = WelcomePage::new(workspace, cx); workspace.add_item_to_center(Box::new(welcome_page.clone()), cx); cx.focus_view(&welcome_page); cx.notify(); @@ -54,174 +54,213 @@ pub fn show_welcome_view(app_state: &Arc, cx: &mut AppContext) { pub struct WelcomePage { workspace: WeakView, focus_handle: FocusHandle, + telemetry: Arc, _settings_subscription: Subscription, } impl Render for WelcomePage { fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { - h_stack() - .full() - .bg(cx.theme().colors().editor_background) - .track_focus(&self.focus_handle) - .child( - v_stack() - .w_96() - .gap_4() - .mx_auto() - .child( - svg() - .path("icons/logo_96.svg") - .text_color(gpui::white()) - .w(px(96.)) - .h(px(96.)) - .mx_auto(), - ) - .child( - h_stack() - .justify_center() - .child(Label::new("Code at the speed of thought")), - ) - .child( - v_stack() - .gap_2() - .child( - Button::new("choose-theme", "Choose a theme") - .full_width() - .on_click(cx.listener(|this, _, cx| { - this.workspace - .update(cx, |workspace, cx| { - theme_selector::toggle( - workspace, - &Default::default(), - cx, - ) - }) - .ok(); - })), - ) - .child( - Button::new("choose-keymap", "Choose a keymap") - .full_width() - .on_click(cx.listener(|this, _, cx| { - this.workspace - .update(cx, |workspace, cx| { - base_keymap_picker::toggle( - workspace, - &Default::default(), - cx, - ) - }) - .ok(); - })), - ) - .child( - Button::new("install-cli", "Install the CLI") - .full_width() - .on_click(cx.listener(|_, _, cx| { - cx.app_mut() - .spawn(|cx| async move { - install_cli::install_cli(&cx).await - }) - .detach_and_log_err(cx); - })), - ), - ) - .child( - v_stack() - .p_3() - .gap_2() - .bg(cx.theme().colors().elevated_surface_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_md() - .child( - h_stack() - .gap_2() - .child( - Checkbox::new( - "enable-vim", - if VimModeSetting::get_global(cx).0 { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, + h_stack().full().track_focus(&self.focus_handle).child( + v_stack() + .w_96() + .gap_4() + .mx_auto() + .child( + svg() + .path("icons/logo_96.svg") + .text_color(gpui::white()) + .w(px(96.)) + .h(px(96.)) + .mx_auto(), + ) + .child( + h_stack() + .justify_center() + .child(Label::new("Code at the speed of thought")), + ) + .child( + v_stack() + .gap_2() + .child( + Button::new("choose-theme", "Choose a theme") + .full_width() + .on_click(cx.listener(|this, _, cx| { + this.telemetry + .report_app_event("welcome page: change theme"); + this.workspace + .update(cx, |workspace, cx| { + theme_selector::toggle( + workspace, + &Default::default(), + cx, + ) + }) + .ok(); + })), + ) + .child( + Button::new("choose-keymap", "Choose a keymap") + .full_width() + .on_click(cx.listener(|this, _, cx| { + this.telemetry + .report_app_event("welcome page: change keymap"); + this.workspace + .update(cx, |workspace, cx| { + base_keymap_picker::toggle( + workspace, + &Default::default(), + cx, + ) + }) + .ok(); + })), + ) + .child( + Button::new("install-cli", "Install the CLI") + .full_width() + .on_click(cx.listener(|this, _, cx| { + this.telemetry.report_app_event("welcome page: install cli"); + cx.app_mut() + .spawn( + |cx| async move { install_cli::install_cli(&cx).await }, ) - .on_click( - cx.listener(move |this, selection, cx| { - this.update_settings::( - selection, - cx, - |setting, value| *setting = Some(value), - ); - }), - ), + .detach_and_log_err(cx); + })), + ), + ) + .child( + v_stack() + .p_3() + .gap_2() + .bg(cx.theme().colors().elevated_surface_background) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .child( + h_stack() + .gap_2() + .child( + Checkbox::new( + "enable-vim", + if VimModeSetting::get_global(cx).0 { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, ) - .child(Label::new("Enable vim mode")), - ) - .child( - h_stack() - .gap_2() - .child( - Checkbox::new( - "enable-telemetry", - if TelemetrySettings::get_global(cx).metrics { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, - ) - .on_click( - cx.listener(move |this, selection, cx| { - this.update_settings::( - selection, - cx, - |settings, value| { - settings.metrics = Some(value) - }, - ); - }), - ), + .on_click(cx.listener( + move |this, selection, cx| { + this.telemetry + .report_app_event("welcome page: toggle vim"); + this.update_settings::( + selection, + cx, + |setting, value| *setting = Some(value), + ); + }, + )), + ) + .child(Label::new("Enable vim mode")), + ) + .child( + h_stack() + .gap_2() + .child( + Checkbox::new( + "enable-telemetry", + if TelemetrySettings::get_global(cx).metrics { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, ) - .child(Label::new("Send anonymous usage data")), - ) - .child( - h_stack() - .gap_2() - .child( - Checkbox::new( - "enable-crash", - if TelemetrySettings::get_global(cx).diagnostics { - ui::Selection::Selected - } else { - ui::Selection::Unselected - }, - ) - .on_click( - cx.listener(move |this, selection, cx| { - this.update_settings::( - selection, - cx, - |settings, value| { - settings.diagnostics = Some(value) - }, - ); - }), - ), + .on_click(cx.listener( + move |this, selection, cx| { + this.telemetry.report_app_event( + "welcome page: toggle metric telemetry", + ); + this.update_settings::( + selection, + cx, + { + let telemetry = this.telemetry.clone(); + + move |settings, value| { + settings.metrics = Some(value); + + telemetry.report_setting_event( + "metric telemetry", + value.to_string(), + ); + } + }, + ); + }, + )), + ) + .child(Label::new("Send anonymous usage data")), + ) + .child( + h_stack() + .gap_2() + .child( + Checkbox::new( + "enable-crash", + if TelemetrySettings::get_global(cx).diagnostics { + ui::Selection::Selected + } else { + ui::Selection::Unselected + }, ) - .child(Label::new("Send crash reports")), - ), - ), - ) + .on_click(cx.listener( + move |this, selection, cx| { + this.telemetry.report_app_event( + "welcome page: toggle diagnostic telemetry", + ); + this.update_settings::( + selection, + cx, + { + let telemetry = this.telemetry.clone(); + + move |settings, value| { + settings.diagnostics = Some(value); + + telemetry.report_setting_event( + "diagnostic telemetry", + value.to_string(), + ); + } + }, + ); + }, + )), + ) + .child(Label::new("Send crash reports")), + ), + ), + ) } } impl WelcomePage { - pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { - WelcomePage { - focus_handle: cx.focus_handle(), - workspace: workspace.weak_handle(), - _settings_subscription: cx.observe_global::(move |_, cx| cx.notify()), - } + pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> View { + let this = cx.new_view(|cx| { + cx.on_release(|this: &mut Self, _, _| { + this.telemetry.report_app_event("welcome page: close"); + }) + .detach(); + + WelcomePage { + focus_handle: cx.focus_handle(), + workspace: workspace.weak_handle(), + telemetry: workspace.client().telemetry().clone(), + _settings_subscription: cx + .observe_global::(move |_, cx| cx.notify()), + } + }); + + this } fn update_settings( @@ -279,6 +318,7 @@ impl Item for WelcomePage { Some(cx.new_view(|cx| WelcomePage { focus_handle: cx.focus_handle(), workspace: self.workspace.clone(), + telemetry: self.telemetry.clone(), _settings_subscription: cx.observe_global::(move |_, cx| cx.notify()), })) } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 236d96ef9fa547dd6ff35b8e2a44ee341ad3b0ba..c13a00b11c897b46ef5e2ac69ae10848c573ebf2 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -19,7 +19,6 @@ pub enum PanelEvent { ZoomOut, Activate, Close, - Focus, } pub trait Panel: FocusableView + EventEmitter { @@ -216,6 +215,28 @@ impl Dock { } }); + cx.on_focus_in(&focus_handle, { + let dock = dock.downgrade(); + move |workspace, cx| { + let Some(dock) = dock.upgrade() else { + return; + }; + let Some(panel) = dock.read(cx).active_panel() else { + return; + }; + if panel.is_zoomed(cx) { + workspace.zoomed = Some(panel.to_any().downgrade().into()); + workspace.zoomed_position = Some(position); + } else { + workspace.zoomed = None; + workspace.zoomed_position = None; + } + workspace.dismiss_zoomed_items_to_reveal(Some(position), cx); + workspace.update_active_view_for_followers(cx) + } + }) + .detach(); + cx.observe(&dock, move |workspace, dock, cx| { if dock.read(cx).is_open() { if let Some(panel) = dock.read(cx).active_panel() { @@ -394,7 +415,6 @@ impl Dock { this.set_open(false, cx); } } - PanelEvent::Focus => {} }), ]; @@ -561,6 +581,7 @@ impl Render for Dock { } div() + .track_focus(&self.focus_handle) .flex() .bg(cx.theme().colors().panel_background) .border_color(cx.theme().colors().border) @@ -584,7 +605,7 @@ impl Render for Dock { ) .child(handle) } else { - div() + div().track_focus(&self.focus_handle) } } } @@ -724,7 +745,7 @@ pub mod test { impl Render for TestPanel { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - div() + div().id("test").track_focus(&self.focus_handle) } } diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 38b76630307faee9f015c61e04f75d9fd703f153..45f6141df2f172ccad176c48bc1f83eab6174c3e 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -442,7 +442,7 @@ impl ItemHandle for View { ) && !pending_update_scheduled.load(Ordering::SeqCst) { pending_update_scheduled.store(true, Ordering::SeqCst); - cx.on_next_frame({ + cx.defer({ let pending_update = pending_update.clone(); let pending_update_scheduled = pending_update_scheduled.clone(); move |this, cx| { diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 21c5962bebb92904935b2a2c2207cda622e29dda..04a51fc655be0d7b5d2b890479bef484b5cbc14a 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2,15 +2,16 @@ use crate::{ item::{ClosePosition, Item, ItemHandle, ItemSettings, WeakItemHandle}, toolbar::Toolbar, workspace_settings::{AutosaveSetting, WorkspaceSettings}, - NewCenterTerminal, NewFile, NewSearch, SplitDirection, ToggleZoom, Workspace, + NewCenterTerminal, NewFile, NewSearch, OpenVisible, SplitDirection, ToggleZoom, Workspace, }; use anyhow::Result; use collections::{HashMap, HashSet, VecDeque}; use gpui::{ actions, impl_actions, overlay, prelude::*, Action, AnchorCorner, AnyElement, AppContext, - AsyncWindowContext, DismissEvent, Div, DragMoveEvent, EntityId, EventEmitter, FocusHandle, - FocusableView, Model, MouseButton, NavigationDirection, Pixels, Point, PromptLevel, Render, - ScrollHandle, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, + AsyncWindowContext, DismissEvent, Div, DragMoveEvent, EntityId, EventEmitter, ExternalPaths, + FocusHandle, FocusableView, Model, MouseButton, NavigationDirection, Pixels, Point, + PromptLevel, Render, ScrollHandle, Subscription, Task, View, ViewContext, VisualContext, + WeakView, WindowContext, }; use parking_lot::Mutex; use project::{Project, ProjectEntryId, ProjectPath}; @@ -19,6 +20,7 @@ use settings::Settings; use std::{ any::Any, cmp, fmt, mem, + ops::ControlFlow, path::{Path, PathBuf}, rc::Rc, sync::{ @@ -182,6 +184,8 @@ pub struct Pane { project: Model, drag_split_direction: Option, can_drop_predicate: Option bool>>, + custom_drop_handle: + Option) -> ControlFlow<(), ()>>>, can_split: bool, render_tab_bar_buttons: Rc) -> AnyElement>, _subscriptions: Vec, @@ -374,6 +378,7 @@ impl Pane { workspace, project, can_drop_predicate, + custom_drop_handle: None, can_split: true, render_tab_bar_buttons: Rc::new(move |pane, cx| { h_stack() @@ -500,13 +505,6 @@ impl Pane { self.active_item_index } - // pub fn on_can_drop(&mut self, can_drop: F) - // where - // F: 'static + Fn(&DragAndDrop, &WindowContext) -> bool, - // { - // self.can_drop = Rc::new(can_drop); - // } - pub fn set_can_split(&mut self, can_split: bool, cx: &mut ViewContext) { self.can_split = can_split; cx.notify(); @@ -527,6 +525,14 @@ impl Pane { cx.notify(); } + pub fn set_custom_drop_handle(&mut self, cx: &mut ViewContext, handle: F) + where + F: 'static + Fn(&mut Pane, &dyn Any, &mut ViewContext) -> ControlFlow<(), ()>, + { + self.custom_drop_handle = Some(Arc::new(handle)); + cx.notify(); + } + pub fn nav_history_for_item(&self, item: &View) -> ItemNavHistory { ItemNavHistory { history: self.nav_history.clone(), @@ -1555,6 +1561,10 @@ impl Pane { this.drag_split_direction = None; this.handle_project_entry_drop(entry_id, cx) })) + .on_drop(cx.listener(move |this, paths, cx| { + this.drag_split_direction = None; + this.handle_external_paths_drop(paths, cx) + })) .when_some(item.tab_tooltip_text(cx), |tab, text| { tab.tooltip(move |cx| Tooltip::text(text.clone(), cx)) }) @@ -1721,6 +1731,10 @@ impl Pane { .on_drop(cx.listener(move |this, entry_id: &ProjectEntryId, cx| { this.drag_split_direction = None; this.handle_project_entry_drop(entry_id, cx) + })) + .on_drop(cx.listener(move |this, paths, cx| { + this.drag_split_direction = None; + this.handle_external_paths_drop(paths, cx) })), ) } @@ -1809,8 +1823,13 @@ impl Pane { &mut self, dragged_tab: &DraggedTab, ix: usize, - cx: &mut ViewContext<'_, Pane>, + cx: &mut ViewContext<'_, Self>, ) { + if let Some(custom_drop_handle) = self.custom_drop_handle.clone() { + if let ControlFlow::Break(()) = custom_drop_handle(self, dragged_tab, cx) { + return; + } + } let mut to_pane = cx.view().clone(); let split_direction = self.drag_split_direction; let item_id = dragged_tab.item_id; @@ -1830,8 +1849,13 @@ impl Pane { fn handle_project_entry_drop( &mut self, project_entry_id: &ProjectEntryId, - cx: &mut ViewContext<'_, Pane>, + cx: &mut ViewContext<'_, Self>, ) { + if let Some(custom_drop_handle) = self.custom_drop_handle.clone() { + if let ControlFlow::Break(()) = custom_drop_handle(self, project_entry_id, cx) { + return; + } + } let mut to_pane = cx.view().clone(); let split_direction = self.drag_split_direction; let project_entry_id = *project_entry_id; @@ -1855,6 +1879,38 @@ impl Pane { .log_err(); } + fn handle_external_paths_drop( + &mut self, + paths: &ExternalPaths, + cx: &mut ViewContext<'_, Self>, + ) { + if let Some(custom_drop_handle) = self.custom_drop_handle.clone() { + if let ControlFlow::Break(()) = custom_drop_handle(self, paths, cx) { + return; + } + } + let mut to_pane = cx.view().clone(); + let split_direction = self.drag_split_direction; + let paths = paths.paths().to_vec(); + self.workspace + .update(cx, |_, cx| { + cx.defer(move |workspace, cx| { + if let Some(split_direction) = split_direction { + to_pane = workspace.split_pane(to_pane, split_direction, cx); + } + workspace + .open_paths( + paths, + OpenVisible::OnlyDirectories, + Some(to_pane.downgrade()), + cx, + ) + .detach(); + }); + }) + .log_err(); + } + pub fn display_nav_history_buttons(&mut self, display: bool) { self.display_nav_history_buttons = display; } @@ -1956,6 +2012,7 @@ impl Render for Pane { .group("") .on_drag_move::(cx.listener(Self::handle_drag_move)) .on_drag_move::(cx.listener(Self::handle_drag_move)) + .on_drag_move::(cx.listener(Self::handle_drag_move)) .map(|div| { if let Some(item) = self.active_item() { div.v_flex() @@ -1985,6 +2042,7 @@ impl Render for Pane { )) .group_drag_over::("", |style| style.visible()) .group_drag_over::("", |style| style.visible()) + .group_drag_over::("", |style| style.visible()) .when_some(self.can_drop_predicate.clone(), |this, p| { this.can_drop(move |a, cx| p(a, cx)) }) @@ -1994,6 +2052,9 @@ impl Render for Pane { .on_drop(cx.listener(move |this, entry_id, cx| { this.handle_project_entry_drop(entry_id, cx) })) + .on_drop(cx.listener(move |this, paths, cx| { + this.handle_external_paths_drop(paths, cx) + })) .map(|div| match self.drag_split_direction { None => div.top_0().left_0().right_0().bottom_0(), Some(SplitDirection::Up) => div.top_0().left_0().right_0().h_32(), diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index a7368f61360ce6639dffa26ffd31f2114cd2216b..4428e42830be725fb79979d6acf3e65a777d9386 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -487,6 +487,7 @@ impl PaneAxis { basis, self.flexes.clone(), self.bounding_boxes.clone(), + cx.view().downgrade(), ) .children(self.members.iter().enumerate().map(|(ix, member)| { if member.contains(active_pane) { @@ -575,21 +576,25 @@ mod element { use gpui::{ px, relative, Along, AnyElement, Axis, Bounds, CursorStyle, Element, InteractiveBounds, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, - Size, Style, WindowContext, + Size, Style, WeakView, WindowContext, }; use parking_lot::Mutex; use smallvec::SmallVec; use ui::prelude::*; + use util::ResultExt; + + use crate::Workspace; use super::{HANDLE_HITBOX_SIZE, HORIZONTAL_MIN_SIZE, VERTICAL_MIN_SIZE}; const DIVIDER_SIZE: f32 = 1.0; - pub fn pane_axis( + pub(super) fn pane_axis( axis: Axis, basis: usize, flexes: Arc>>, bounding_boxes: Arc>>>>, + workspace: WeakView, ) -> PaneAxisElement { PaneAxisElement { axis, @@ -598,6 +603,7 @@ mod element { bounding_boxes, children: SmallVec::new(), active_pane_ix: None, + workspace, } } @@ -608,6 +614,7 @@ mod element { bounding_boxes: Arc>>>>, children: SmallVec<[AnyElement; 2]>, active_pane_ix: Option, + workspace: WeakView, } impl PaneAxisElement { @@ -623,6 +630,7 @@ mod element { axis: Axis, child_start: Point, container_size: Size, + workspace: WeakView, cx: &mut WindowContext, ) { let min_size = match axis { @@ -697,7 +705,9 @@ mod element { } // todo!(schedule serialize) - // workspace.schedule_serialize(cx); + workspace + .update(cx, |this, cx| this.schedule_serialize(cx)) + .log_err(); cx.notify(); } @@ -708,6 +718,7 @@ mod element { ix: usize, pane_bounds: Bounds, axis_bounds: Bounds, + workspace: WeakView, cx: &mut WindowContext, ) { let handle_bounds = Bounds { @@ -742,24 +753,39 @@ mod element { cx.on_mouse_event({ let dragged_handle = dragged_handle.clone(); - move |e: &MouseDownEvent, phase, _cx| { + let flexes = flexes.clone(); + let workspace = workspace.clone(); + move |e: &MouseDownEvent, phase, cx| { if phase.bubble() && handle_bounds.contains(&e.position) { dragged_handle.replace(Some(ix)); + if e.click_count >= 2 { + let mut borrow = flexes.lock(); + *borrow = vec![1.; borrow.len()]; + workspace + .update(cx, |this, cx| this.schedule_serialize(cx)) + .log_err(); + cx.notify(); + } } } }); - cx.on_mouse_event(move |e: &MouseMoveEvent, phase, cx| { - let dragged_handle = dragged_handle.borrow(); - if phase.bubble() && *dragged_handle == Some(ix) { - Self::compute_resize( - &flexes, - e, - ix, - axis, - pane_bounds.origin, - axis_bounds.size, - cx, - ) + cx.on_mouse_event({ + let workspace = workspace.clone(); + move |e: &MouseMoveEvent, phase, cx| { + let dragged_handle = dragged_handle.borrow(); + + if phase.bubble() && *dragged_handle == Some(ix) { + Self::compute_resize( + &flexes, + e, + ix, + axis, + pane_bounds.origin, + axis_bounds.size, + workspace.clone(), + cx, + ) + } } }); }); @@ -840,6 +866,7 @@ mod element { ix, child_bounds, bounds, + self.workspace.clone(), cx, ); } diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index dbbe7de6a1897d0049ef65e500e7ab4483452a13..edfabed60d3a03e2290fb94dc9c8482a7e9b4a5e 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -66,12 +66,16 @@ impl FocusableView for SharedScreen { } } impl Render for SharedScreen { - fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { - div().track_focus(&self.focus).size_full().children( - self.frame - .as_ref() - .map(|frame| img(frame.image()).size_full()), - ) + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .bg(cx.theme().colors().editor_background) + .track_focus(&self.focus) + .size_full() + .children( + self.frame + .as_ref() + .map(|frame| img(frame.image()).size_full()), + ) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 474747c907d3ca1404bffcfb687aa0fcff257332..826a6693d7ca350a85efa4589e5c87ba90bbf94c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -431,6 +431,13 @@ pub enum Event { WorkspaceCreated(WeakView), } +pub enum OpenVisible { + All, + None, + OnlyFiles, + OnlyDirectories, +} + pub struct Workspace { weak_self: WeakView, workspace_actions: Vec) -> Div>>, @@ -651,7 +658,7 @@ impl Workspace { cx.on_release(|this, window, cx| { this.app_state.workspace_store.update(cx, |store, _| { let window = window.downcast::().unwrap(); - debug_assert!(store.workspaces.remove(&window)); + store.workspaces.remove(&window); }) }), ]; @@ -1258,9 +1265,7 @@ impl Workspace { } pub fn open(&mut self, _: &Open, cx: &mut ViewContext) { - self.client() - .telemetry() - .report_app_event("open project", false, cx); + self.client().telemetry().report_app_event("open project"); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: true, @@ -1317,7 +1322,8 @@ impl Workspace { pub fn open_paths( &mut self, mut abs_paths: Vec, - visible: bool, + visible: OpenVisible, + pane: Option>, cx: &mut ViewContext, ) -> Task, anyhow::Error>>>> { log::info!("open paths {abs_paths:?}"); @@ -1328,31 +1334,56 @@ impl Workspace { abs_paths.sort_unstable(); cx.spawn(move |this, mut cx| async move { let mut tasks = Vec::with_capacity(abs_paths.len()); + for abs_path in &abs_paths { - let project_path = match this - .update(&mut cx, |this, cx| { - Workspace::project_path_for_path( - this.project.clone(), - abs_path, - visible, - cx, - ) - }) - .log_err() - { - Some(project_path) => project_path.await.log_err(), + let visible = match visible { + OpenVisible::All => Some(true), + OpenVisible::None => Some(false), + OpenVisible::OnlyFiles => match fs.metadata(abs_path).await.log_err() { + Some(Some(metadata)) => Some(!metadata.is_dir), + Some(None) => { + log::error!("No metadata for file {abs_path:?}"); + None + } + None => None, + }, + OpenVisible::OnlyDirectories => match fs.metadata(abs_path).await.log_err() { + Some(Some(metadata)) => Some(metadata.is_dir), + Some(None) => { + log::error!("No metadata for file {abs_path:?}"); + None + } + None => None, + }, + }; + let project_path = match visible { + Some(visible) => match this + .update(&mut cx, |this, cx| { + Workspace::project_path_for_path( + this.project.clone(), + abs_path, + visible, + cx, + ) + }) + .log_err() + { + Some(project_path) => project_path.await.log_err(), + None => None, + }, None => None, }; let this = this.clone(); let abs_path = abs_path.clone(); let fs = fs.clone(); + let pane = pane.clone(); let task = cx.spawn(move |mut cx| async move { let (worktree, project_path) = project_path?; if fs.is_file(&abs_path).await { Some( this.update(&mut cx, |this, cx| { - this.open_path(project_path, None, true, cx) + this.open_path(project_path, pane, true, cx) }) .log_err()? .await, @@ -1398,7 +1429,9 @@ impl Workspace { cx.spawn(|this, mut cx| async move { if let Some(paths) = paths.await.log_err().flatten() { let results = this - .update(&mut cx, |this, cx| this.open_paths(paths, true, cx))? + .update(&mut cx, |this, cx| { + this.open_paths(paths, OpenVisible::All, None, cx) + })? .await; for result in results.into_iter().flatten() { result.log_err(); @@ -1784,7 +1817,16 @@ impl Workspace { cx.spawn(|workspace, mut cx| async move { let open_paths_task_result = workspace .update(&mut cx, |workspace, cx| { - workspace.open_paths(vec![abs_path.clone()], visible, cx) + workspace.open_paths( + vec![abs_path.clone()], + if visible { + OpenVisible::All + } else { + OpenVisible::None + }, + None, + cx, + ) }) .with_context(|| format!("open abs path {abs_path:?} task spawn"))? .await; @@ -2457,11 +2499,11 @@ impl Workspace { Some(leader_id) } - // pub fn is_being_followed(&self, peer_id: PeerId) -> bool { - // self.follower_states - // .values() - // .any(|state| state.leader_id == peer_id) - // } + pub fn is_being_followed(&self, peer_id: PeerId) -> bool { + self.follower_states + .values() + .any(|state| state.leader_id == peer_id) + } fn active_item_path_changed(&mut self, cx: &mut ViewContext) { let active_entry = self.active_project_path(cx); @@ -4083,7 +4125,7 @@ pub fn open_paths( existing.clone(), existing .update(&mut cx, |workspace, cx| { - workspace.open_paths(abs_paths, true, cx) + workspace.open_paths(abs_paths, OpenVisible::All, None, cx) })? .await, )) @@ -4131,7 +4173,7 @@ pub fn create_and_open_local_file( let mut items = workspace .update(&mut cx, |workspace, cx| { workspace.with_local_workspace(cx, |workspace, cx| { - workspace.open_paths(vec![path.to_path_buf()], false, cx) + workspace.open_paths(vec![path.to_path_buf()], OpenVisible::None, None, cx) }) })? .await? diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 9f2de56ffd0c37ed659142af85c901e7003ed603..5f323f2f8eecf34d3c68623476a358dfbd813518 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -146,8 +146,7 @@ uuid.workspace = true [dev-dependencies] call = { path = "../call", features = ["test-support"] } # client = { path = "../client", features = ["test-support"] } -# editor = { path = "../editor", features = ["test-support"] } -# gpui = { path = "../gpui", features = ["test-support"] } +editor = { path = "../editor", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } language = { path = "../language", features = ["test-support"] } # lsp = { path = "../lsp", features = ["test-support"] } @@ -156,7 +155,7 @@ project = { path = "../project", features = ["test-support"] } # settings = { path = "../settings", features = ["test-support"] } text = { path = "../text", features = ["test-support"] } # util = { path = "../util", features = ["test-support"] } -# workspace = { path = "../workspace", features = ["test-support"] } +workspace = { path = "../workspace", features = ["test-support"] } unindent.workspace = true [package.metadata.bundle-dev] diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e0da81edc4ae17702b55a306bae3ec8b9d7a2bfd..56109d9c9a532d97de0f8b76101b4057203879e2 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -45,7 +45,7 @@ use util::{ paths, ResultExt, }; use uuid::Uuid; -use welcome::{show_welcome_view, FIRST_OPEN}; +use welcome::{show_welcome_view, BaseKeymap, FIRST_OPEN}; use workspace::{AppState, WorkspaceStore}; use zed::{ app_menus, build_window_options, ensure_only_instance, handle_cli_connection, @@ -171,17 +171,15 @@ fn main() { }) .detach(); - client.telemetry().start(installation_id, session_id, cx); - client - .telemetry() - .report_setting_event("theme", cx.theme().name.to_string(), cx); - let event_operation = match existing_installation_id_found { + let telemetry = client.telemetry(); + telemetry.start(installation_id, session_id, cx); + telemetry.report_setting_event("theme", cx.theme().name.to_string()); + telemetry.report_setting_event("keymap", BaseKeymap::get_global(cx).to_string()); + telemetry.report_app_event(match existing_installation_id_found { Some(false) => "first open", _ => "open", - }; - client - .telemetry() - .report_app_event(event_operation, true, cx); + }); + telemetry.flush_events(); let app_state = Arc::new(AppState { languages: languages.clone(), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c7d30230ea035b29121ceb487e4ea3ce483d5d54..61b8d6eaf84332474ff992e5a72a85cbde5b4776 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -33,11 +33,11 @@ use util::{ }; use uuid::Uuid; use welcome::BaseKeymap; -use workspace::Pane; use workspace::{ create_and_open_local_file, notifications::simple_message_notification::MessageNotification, open_new, AppState, NewFile, NewWindow, Workspace, WorkspaceSettings, }; +use workspace::{dock::Panel, Pane}; use zed_actions::{OpenBrowser, OpenSettings, OpenZedURL, Quit}; actions!( @@ -114,9 +114,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { }) .detach(); - // cx.emit(workspace2::Event::PaneAdded( - // workspace.active_pane().clone(), - // )); + // cx.emit(workspace::Event::PaneAdded(workspace.active_pane().clone())); // let collab_titlebar_item = // cx.add_view(|cx| CollabTitlebarItem::new(workspace, &workspace_handle, cx)); @@ -187,6 +185,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { )?; workspace_handle.update(&mut cx, |workspace, cx| { + let position = project_panel.read(cx).position(cx); workspace.add_panel(project_panel, cx); workspace.add_panel(terminal_panel, cx); workspace.add_panel(assistant_panel, cx); @@ -194,19 +193,18 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { workspace.add_panel(chat_panel, cx); workspace.add_panel(notification_panel, cx); - // if !was_deserialized - // && workspace - // .project() - // .read(cx) - // .visible_worktrees(cx) - // .any(|tree| { - // tree.read(cx) - // .root_entry() - // .map_or(false, |entry| entry.is_dir()) - // }) - // { - // workspace.toggle_dock(project_panel_position, cx); - // } + if workspace + .project() + .read(cx) + .visible_worktrees(cx) + .any(|tree| { + tree.read(cx) + .root_entry() + .map_or(false, |entry| entry.is_dir()) + }) + { + workspace.toggle_dock(position, cx); + } cx.focus_self(); }) }) @@ -587,7 +585,6 @@ pub fn handle_keymap_file_changes( } } } - cx.update(|cx| reload_keymaps(cx, &user_keymap)).ok(); } }) @@ -770,1844 +767,2073 @@ fn open_bundled_file( } // todo!() -// #[cfg(test)] -// mod tests { -// use super::*; -// use assets::Assets; -// use editor::{scroll::autoscroll::Autoscroll, DisplayPoint, Editor}; -// use fs::{FakeFs, Fs}; -// use gpui::{ -// actions, elements::Empty, executor::Deterministic, Action, AnyElement, AnyWindowHandle, -// AppContext, AssetSource, Element, Entity, TestAppContext, View, ViewHandle, -// }; -// use language::LanguageRegistry; -// use project::{project_settings::ProjectSettings, Project, ProjectPath}; -// use serde_json::json; -// use settings::{handle_settings_file_changes, watch_config_file, SettingsStore}; -// use std::{ -// collections::HashSet, -// path::{Path, PathBuf}, -// }; -// use theme::{ThemeRegistry, ThemeSettings}; -// use workspace::{ -// item::{Item, ItemHandle}, -// open_new, open_paths, pane, NewFile, SaveIntent, SplitDirection, WorkspaceHandle, -// }; - -// #[gpui::test] -// async fn test_open_paths_action(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// app_state -// .fs -// .as_fake() -// .insert_tree( -// "/root", -// json!({ -// "a": { -// "aa": null, -// "ab": null, -// }, -// "b": { -// "ba": null, -// "bb": null, -// }, -// "c": { -// "ca": null, -// "cb": null, -// }, -// "d": { -// "da": null, -// "db": null, -// }, -// }), -// ) -// .await; - -// cx.update(|cx| { -// open_paths( -// &[PathBuf::from("/root/a"), PathBuf::from("/root/b")], -// &app_state, -// None, -// cx, -// ) -// }) -// .await -// .unwrap(); -// assert_eq!(cx.windows().len(), 1); - -// cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, None, cx)) -// .await -// .unwrap(); -// assert_eq!(cx.windows().len(), 1); -// let workspace_1 = cx.windows()[0].downcast::().unwrap().root(cx); -// workspace_1.update(cx, |workspace, cx| { -// assert_eq!(workspace.worktrees(cx).count(), 2); -// assert!(workspace.left_dock().read(cx).is_open()); -// assert!(workspace.active_pane().is_focused(cx)); -// }); - -// cx.update(|cx| { -// open_paths( -// &[PathBuf::from("/root/b"), PathBuf::from("/root/c")], -// &app_state, -// None, -// cx, -// ) -// }) -// .await -// .unwrap(); -// assert_eq!(cx.windows().len(), 2); - -// // Replace existing windows -// let window = cx.windows()[0].downcast::().unwrap(); -// cx.update(|cx| { -// open_paths( -// &[PathBuf::from("/root/c"), PathBuf::from("/root/d")], -// &app_state, -// Some(window), -// cx, -// ) -// }) -// .await -// .unwrap(); -// assert_eq!(cx.windows().len(), 2); -// let workspace_1 = cx.windows()[0].downcast::().unwrap().root(cx); -// workspace_1.update(cx, |workspace, cx| { -// assert_eq!( -// workspace -// .worktrees(cx) -// .map(|w| w.read(cx).abs_path()) -// .collect::>(), -// &[Path::new("/root/c").into(), Path::new("/root/d").into()] -// ); -// assert!(workspace.left_dock().read(cx).is_open()); -// assert!(workspace.active_pane().is_focused(cx)); -// }); -// } - -// #[gpui::test] -// async fn test_window_edit_state(executor: Arc, cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// app_state -// .fs -// .as_fake() -// .insert_tree("/root", json!({"a": "hey"})) -// .await; - -// cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, None, cx)) -// .await -// .unwrap(); -// assert_eq!(cx.windows().len(), 1); - -// // When opening the workspace, the window is not in a edited state. -// let window = cx.windows()[0].downcast::().unwrap(); -// let workspace = window.root(cx); -// let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); -// let editor = workspace.read_with(cx, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); -// assert!(!window.is_edited(cx)); - -// // Editing a buffer marks the window as edited. -// editor.update(cx, |editor, cx| editor.insert("EDIT", cx)); -// assert!(window.is_edited(cx)); - -// // Undoing the edit restores the window's edited state. -// editor.update(cx, |editor, cx| editor.undo(&Default::default(), cx)); -// assert!(!window.is_edited(cx)); - -// // Redoing the edit marks the window as edited again. -// editor.update(cx, |editor, cx| editor.redo(&Default::default(), cx)); -// assert!(window.is_edited(cx)); - -// // Closing the item restores the window's edited state. -// let close = pane.update(cx, |pane, cx| { -// drop(editor); -// pane.close_active_item(&Default::default(), cx).unwrap() -// }); -// executor.run_until_parked(); - -// window.simulate_prompt_answer(1, cx); -// close.await.unwrap(); -// assert!(!window.is_edited(cx)); - -// // Opening the buffer again doesn't impact the window's edited state. -// cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, None, cx)) -// .await -// .unwrap(); -// let editor = workspace.read_with(cx, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); -// assert!(!window.is_edited(cx)); - -// // Editing the buffer marks the window as edited. -// editor.update(cx, |editor, cx| editor.insert("EDIT", cx)); -// assert!(window.is_edited(cx)); - -// // Ensure closing the window via the mouse gets preempted due to the -// // buffer having unsaved changes. -// assert!(!window.simulate_close(cx)); -// executor.run_until_parked(); -// assert_eq!(cx.windows().len(), 1); - -// // The window is successfully closed after the user dismisses the prompt. -// window.simulate_prompt_answer(1, cx); -// executor.run_until_parked(); -// assert_eq!(cx.windows().len(), 0); -// } - -// #[gpui::test] -// async fn test_new_empty_workspace(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// cx.update(|cx| { -// open_new(&app_state, cx, |workspace, cx| { -// Editor::new_file(workspace, &Default::default(), cx) -// }) -// }) -// .await; - -// let window = cx -// .windows() -// .first() -// .unwrap() -// .downcast::() -// .unwrap(); -// let workspace = window.root(cx); - -// let editor = workspace.update(cx, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); - -// editor.update(cx, |editor, cx| { -// assert!(editor.text(cx).is_empty()); -// assert!(!editor.is_dirty(cx)); -// }); - -// let save_task = workspace.update(cx, |workspace, cx| { -// workspace.save_active_item(SaveIntent::Save, cx) -// }); -// app_state.fs.create_dir(Path::new("/root")).await.unwrap(); -// cx.foreground().run_until_parked(); -// cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name"))); -// save_task.await.unwrap(); -// editor.read_with(cx, |editor, cx| { -// assert!(!editor.is_dirty(cx)); -// assert_eq!(editor.title(cx), "the-new-name"); -// }); -// } - -// #[gpui::test] -// async fn test_open_entry(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// app_state -// .fs -// .as_fake() -// .insert_tree( -// "/root", -// json!({ -// "a": { -// "file1": "contents 1", -// "file2": "contents 2", -// "file3": "contents 3", -// }, -// }), -// ) -// .await; - -// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; -// let window = cx.add_window(|cx| Workspace::test_new(project, cx)); -// let workspace = window.root(cx); - -// let entries = cx.read(|cx| workspace.file_project_paths(cx)); -// let file1 = entries[0].clone(); -// let file2 = entries[1].clone(); -// let file3 = entries[2].clone(); - -// // Open the first entry -// let entry_1 = workspace -// .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) -// .await -// .unwrap(); -// cx.read(|cx| { -// let pane = workspace.read(cx).active_pane().read(cx); -// assert_eq!( -// pane.active_item().unwrap().project_path(cx), -// Some(file1.clone()) -// ); -// assert_eq!(pane.items_len(), 1); -// }); - -// // Open the second entry -// workspace -// .update(cx, |w, cx| w.open_path(file2.clone(), None, true, cx)) -// .await -// .unwrap(); -// cx.read(|cx| { -// let pane = workspace.read(cx).active_pane().read(cx); -// assert_eq!( -// pane.active_item().unwrap().project_path(cx), -// Some(file2.clone()) -// ); -// assert_eq!(pane.items_len(), 2); -// }); - -// // Open the first entry again. The existing pane item is activated. -// let entry_1b = workspace -// .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) -// .await -// .unwrap(); -// assert_eq!(entry_1.id(), entry_1b.id()); - -// cx.read(|cx| { -// let pane = workspace.read(cx).active_pane().read(cx); -// assert_eq!( -// pane.active_item().unwrap().project_path(cx), -// Some(file1.clone()) -// ); -// assert_eq!(pane.items_len(), 2); -// }); - -// // Split the pane with the first entry, then open the second entry again. -// workspace -// .update(cx, |w, cx| { -// w.split_and_clone(w.active_pane().clone(), SplitDirection::Right, cx); -// w.open_path(file2.clone(), None, true, cx) -// }) -// .await -// .unwrap(); - -// workspace.read_with(cx, |w, cx| { -// assert_eq!( -// w.active_pane() -// .read(cx) -// .active_item() -// .unwrap() -// .project_path(cx), -// Some(file2.clone()) -// ); -// }); - -// // Open the third entry twice concurrently. Only one pane item is added. -// let (t1, t2) = workspace.update(cx, |w, cx| { -// ( -// w.open_path(file3.clone(), None, true, cx), -// w.open_path(file3.clone(), None, true, cx), -// ) -// }); -// t1.await.unwrap(); -// t2.await.unwrap(); -// cx.read(|cx| { -// let pane = workspace.read(cx).active_pane().read(cx); -// assert_eq!( -// pane.active_item().unwrap().project_path(cx), -// Some(file3.clone()) -// ); -// let pane_entries = pane -// .items() -// .map(|i| i.project_path(cx).unwrap()) -// .collect::>(); -// assert_eq!(pane_entries, &[file1, file2, file3]); -// }); -// } - -// #[gpui::test] -// async fn test_open_paths(cx: &mut TestAppContext) { -// let app_state = init_test(cx); - -// app_state -// .fs -// .as_fake() -// .insert_tree( -// "/", -// json!({ -// "dir1": { -// "a.txt": "" -// }, -// "dir2": { -// "b.txt": "" -// }, -// "dir3": { -// "c.txt": "" -// }, -// "d.txt": "" -// }), -// ) -// .await; - -// cx.update(|cx| open_paths(&[PathBuf::from("/dir1/")], &app_state, None, cx)) -// .await -// .unwrap(); -// assert_eq!(cx.windows().len(), 1); -// let workspace = cx.windows()[0].downcast::().unwrap().root(cx); - -// #[track_caller] -// fn assert_project_panel_selection( -// workspace: &Workspace, -// expected_worktree_path: &Path, -// expected_entry_path: &Path, -// cx: &AppContext, -// ) { -// let project_panel = [ -// workspace.left_dock().read(cx).panel::(), -// workspace.right_dock().read(cx).panel::(), -// workspace.bottom_dock().read(cx).panel::(), -// ] -// .into_iter() -// .find_map(std::convert::identity) -// .expect("found no project panels") -// .read(cx); -// let (selected_worktree, selected_entry) = project_panel -// .selected_entry(cx) -// .expect("project panel should have a selected entry"); -// assert_eq!( -// selected_worktree.abs_path().as_ref(), -// expected_worktree_path, -// "Unexpected project panel selected worktree path" -// ); -// assert_eq!( -// selected_entry.path.as_ref(), -// expected_entry_path, -// "Unexpected project panel selected entry path" -// ); -// } - -// // Open a file within an existing worktree. -// workspace -// .update(cx, |view, cx| { -// view.open_paths(vec!["/dir1/a.txt".into()], true, cx) -// }) -// .await; -// cx.read(|cx| { -// let workspace = workspace.read(cx); -// assert_project_panel_selection(workspace, Path::new("/dir1"), Path::new("a.txt"), cx); -// assert_eq!( -// workspace -// .active_pane() -// .read(cx) -// .active_item() -// .unwrap() -// .as_any() -// .downcast_ref::() -// .unwrap() -// .read(cx) -// .title(cx), -// "a.txt" -// ); -// }); - -// // Open a file outside of any existing worktree. -// workspace -// .update(cx, |view, cx| { -// view.open_paths(vec!["/dir2/b.txt".into()], true, cx) -// }) -// .await; -// cx.read(|cx| { -// let workspace = workspace.read(cx); -// assert_project_panel_selection(workspace, Path::new("/dir2/b.txt"), Path::new(""), cx); -// let worktree_roots = workspace -// .worktrees(cx) -// .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) -// .collect::>(); -// assert_eq!( -// worktree_roots, -// vec!["/dir1", "/dir2/b.txt"] -// .into_iter() -// .map(Path::new) -// .collect(), -// ); -// assert_eq!( -// workspace -// .active_pane() -// .read(cx) -// .active_item() -// .unwrap() -// .as_any() -// .downcast_ref::() -// .unwrap() -// .read(cx) -// .title(cx), -// "b.txt" -// ); -// }); - -// // Ensure opening a directory and one of its children only adds one worktree. -// workspace -// .update(cx, |view, cx| { -// view.open_paths(vec!["/dir3".into(), "/dir3/c.txt".into()], true, cx) -// }) -// .await; -// cx.read(|cx| { -// let workspace = workspace.read(cx); -// assert_project_panel_selection(workspace, Path::new("/dir3"), Path::new("c.txt"), cx); -// let worktree_roots = workspace -// .worktrees(cx) -// .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) -// .collect::>(); -// assert_eq!( -// worktree_roots, -// vec!["/dir1", "/dir2/b.txt", "/dir3"] -// .into_iter() -// .map(Path::new) -// .collect(), -// ); -// assert_eq!( -// workspace -// .active_pane() -// .read(cx) -// .active_item() -// .unwrap() -// .as_any() -// .downcast_ref::() -// .unwrap() -// .read(cx) -// .title(cx), -// "c.txt" -// ); -// }); - -// // Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree. -// workspace -// .update(cx, |view, cx| { -// view.open_paths(vec!["/d.txt".into()], false, cx) -// }) -// .await; -// cx.read(|cx| { -// let workspace = workspace.read(cx); -// assert_project_panel_selection(workspace, Path::new("/d.txt"), Path::new(""), cx); -// let worktree_roots = workspace -// .worktrees(cx) -// .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) -// .collect::>(); -// assert_eq!( -// worktree_roots, -// vec!["/dir1", "/dir2/b.txt", "/dir3", "/d.txt"] -// .into_iter() -// .map(Path::new) -// .collect(), -// ); - -// let visible_worktree_roots = workspace -// .visible_worktrees(cx) -// .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) -// .collect::>(); -// assert_eq!( -// visible_worktree_roots, -// vec!["/dir1", "/dir2/b.txt", "/dir3"] -// .into_iter() -// .map(Path::new) -// .collect(), -// ); - -// assert_eq!( -// workspace -// .active_pane() -// .read(cx) -// .active_item() -// .unwrap() -// .as_any() -// .downcast_ref::() -// .unwrap() -// .read(cx) -// .title(cx), -// "d.txt" -// ); -// }); -// } - -// #[gpui::test] -// async fn test_opening_excluded_paths(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// cx.update(|cx| { -// cx.update_global::(|store, cx| { -// store.update_user_settings::(cx, |project_settings| { -// project_settings.file_scan_exclusions = -// Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]); -// }); -// }); -// }); -// app_state -// .fs -// .as_fake() -// .insert_tree( -// "/root", -// json!({ -// ".gitignore": "ignored_dir\n", -// ".git": { -// "HEAD": "ref: refs/heads/main", -// }, -// "regular_dir": { -// "file": "regular file contents", -// }, -// "ignored_dir": { -// "ignored_subdir": { -// "file": "ignored subfile contents", -// }, -// "file": "ignored file contents", -// }, -// "excluded_dir": { -// "file": "excluded file contents", -// }, -// }), -// ) -// .await; - -// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; -// let window = cx.add_window(|cx| Workspace::test_new(project, cx)); -// let workspace = window.root(cx); - -// let initial_entries = cx.read(|cx| workspace.file_project_paths(cx)); -// let paths_to_open = [ -// Path::new("/root/excluded_dir/file").to_path_buf(), -// Path::new("/root/.git/HEAD").to_path_buf(), -// Path::new("/root/excluded_dir/ignored_subdir").to_path_buf(), -// ]; -// let (opened_workspace, new_items) = cx -// .update(|cx| workspace::open_paths(&paths_to_open, &app_state, None, cx)) -// .await -// .unwrap(); - -// assert_eq!( -// opened_workspace.id(), -// workspace.id(), -// "Excluded files in subfolders of a workspace root should be opened in the workspace" -// ); -// let mut opened_paths = cx.read(|cx| { -// assert_eq!( -// new_items.len(), -// paths_to_open.len(), -// "Expect to get the same number of opened items as submitted paths to open" -// ); -// new_items -// .iter() -// .zip(paths_to_open.iter()) -// .map(|(i, path)| { -// match i { -// Some(Ok(i)) => { -// Some(i.project_path(cx).map(|p| p.path.display().to_string())) -// } -// Some(Err(e)) => panic!("Excluded file {path:?} failed to open: {e:?}"), -// None => None, -// } -// .flatten() -// }) -// .collect::>() -// }); -// opened_paths.sort(); -// assert_eq!( -// opened_paths, -// vec![ -// None, -// Some(".git/HEAD".to_string()), -// Some("excluded_dir/file".to_string()), -// ], -// "Excluded files should get opened, excluded dir should not get opened" -// ); - -// let entries = cx.read(|cx| workspace.file_project_paths(cx)); -// assert_eq!( -// initial_entries, entries, -// "Workspace entries should not change after opening excluded files and directories paths" -// ); - -// cx.read(|cx| { -// let pane = workspace.read(cx).active_pane().read(cx); -// let mut opened_buffer_paths = pane -// .items() -// .map(|i| { -// i.project_path(cx) -// .expect("all excluded files that got open should have a path") -// .path -// .display() -// .to_string() -// }) -// .collect::>(); -// opened_buffer_paths.sort(); -// assert_eq!( -// opened_buffer_paths, -// vec![".git/HEAD".to_string(), "excluded_dir/file".to_string()], -// "Despite not being present in the worktrees, buffers for excluded files are opened and added to the pane" -// ); -// }); -// } - -// #[gpui::test] -// async fn test_save_conflicting_item(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// app_state -// .fs -// .as_fake() -// .insert_tree("/root", json!({ "a.txt": "" })) -// .await; - -// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; -// let window = cx.add_window(|cx| Workspace::test_new(project, cx)); -// let workspace = window.root(cx); - -// // Open a file within an existing worktree. -// workspace -// .update(cx, |view, cx| { -// view.open_paths(vec![PathBuf::from("/root/a.txt")], true, cx) -// }) -// .await; -// let editor = cx.read(|cx| { -// let pane = workspace.read(cx).active_pane().read(cx); -// let item = pane.active_item().unwrap(); -// item.downcast::().unwrap() -// }); - -// editor.update(cx, |editor, cx| editor.handle_input("x", cx)); -// app_state -// .fs -// .as_fake() -// .insert_file("/root/a.txt", "changed".to_string()) -// .await; -// editor -// .condition(cx, |editor, cx| editor.has_conflict(cx)) -// .await; -// cx.read(|cx| assert!(editor.is_dirty(cx))); - -// let save_task = workspace.update(cx, |workspace, cx| { -// workspace.save_active_item(SaveIntent::Save, cx) -// }); -// cx.foreground().run_until_parked(); -// window.simulate_prompt_answer(0, cx); -// save_task.await.unwrap(); -// editor.read_with(cx, |editor, cx| { -// assert!(!editor.is_dirty(cx)); -// assert!(!editor.has_conflict(cx)); -// }); -// } - -// #[gpui::test] -// async fn test_open_and_save_new_file(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// app_state.fs.create_dir(Path::new("/root")).await.unwrap(); - -// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; -// project.update(cx, |project, _| project.languages().add(rust_lang())); -// let window = cx.add_window(|cx| Workspace::test_new(project, cx)); -// let workspace = window.root(cx); -// let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap()); - -// // Create a new untitled buffer -// cx.dispatch_action(window.into(), NewFile); -// let editor = workspace.read_with(cx, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); - -// editor.update(cx, |editor, cx| { -// assert!(!editor.is_dirty(cx)); -// assert_eq!(editor.title(cx), "untitled"); -// assert!(Arc::ptr_eq( -// &editor.language_at(0, cx).unwrap(), -// &languages::PLAIN_TEXT -// )); -// editor.handle_input("hi", cx); -// assert!(editor.is_dirty(cx)); -// }); - -// // Save the buffer. This prompts for a filename. -// let save_task = workspace.update(cx, |workspace, cx| { -// workspace.save_active_item(SaveIntent::Save, cx) -// }); -// cx.foreground().run_until_parked(); -// cx.simulate_new_path_selection(|parent_dir| { -// assert_eq!(parent_dir, Path::new("/root")); -// Some(parent_dir.join("the-new-name.rs")) -// }); -// cx.read(|cx| { -// assert!(editor.is_dirty(cx)); -// assert_eq!(editor.read(cx).title(cx), "untitled"); -// }); - -// // When the save completes, the buffer's title is updated and the language is assigned based -// // on the path. -// save_task.await.unwrap(); -// editor.read_with(cx, |editor, cx| { -// assert!(!editor.is_dirty(cx)); -// assert_eq!(editor.title(cx), "the-new-name.rs"); -// assert_eq!(editor.language_at(0, cx).unwrap().name().as_ref(), "Rust"); -// }); - -// // Edit the file and save it again. This time, there is no filename prompt. -// editor.update(cx, |editor, cx| { -// editor.handle_input(" there", cx); -// assert!(editor.is_dirty(cx)); -// }); -// let save_task = workspace.update(cx, |workspace, cx| { -// workspace.save_active_item(SaveIntent::Save, cx) -// }); -// save_task.await.unwrap(); -// assert!(!cx.did_prompt_for_new_path()); -// editor.read_with(cx, |editor, cx| { -// assert!(!editor.is_dirty(cx)); -// assert_eq!(editor.title(cx), "the-new-name.rs") -// }); - -// // Open the same newly-created file in another pane item. The new editor should reuse -// // the same buffer. -// cx.dispatch_action(window.into(), NewFile); -// workspace -// .update(cx, |workspace, cx| { -// workspace.split_and_clone( -// workspace.active_pane().clone(), -// SplitDirection::Right, -// cx, -// ); -// workspace.open_path((worktree.read(cx).id(), "the-new-name.rs"), None, true, cx) -// }) -// .await -// .unwrap(); -// let editor2 = workspace.update(cx, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); -// cx.read(|cx| { -// assert_eq!( -// editor2.read(cx).buffer().read(cx).as_singleton().unwrap(), -// editor.read(cx).buffer().read(cx).as_singleton().unwrap() -// ); -// }) -// } - -// #[gpui::test] -// async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// app_state.fs.create_dir(Path::new("/root")).await.unwrap(); - -// let project = Project::test(app_state.fs.clone(), [], cx).await; -// project.update(cx, |project, _| project.languages().add(rust_lang())); -// let window = cx.add_window(|cx| Workspace::test_new(project, cx)); -// let workspace = window.root(cx); - -// // Create a new untitled buffer -// cx.dispatch_action(window.into(), NewFile); -// let editor = workspace.read_with(cx, |workspace, cx| { -// workspace -// .active_item(cx) -// .unwrap() -// .downcast::() -// .unwrap() -// }); - -// editor.update(cx, |editor, cx| { -// assert!(Arc::ptr_eq( -// &editor.language_at(0, cx).unwrap(), -// &languages::PLAIN_TEXT -// )); -// editor.handle_input("hi", cx); -// assert!(editor.is_dirty(cx)); -// }); - -// // Save the buffer. This prompts for a filename. -// let save_task = workspace.update(cx, |workspace, cx| { -// workspace.save_active_item(SaveIntent::Save, cx) -// }); -// cx.foreground().run_until_parked(); -// cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs"))); -// save_task.await.unwrap(); -// // The buffer is not dirty anymore and the language is assigned based on the path. -// editor.read_with(cx, |editor, cx| { -// assert!(!editor.is_dirty(cx)); -// assert_eq!(editor.language_at(0, cx).unwrap().name().as_ref(), "Rust") -// }); -// } - -// #[gpui::test] -// async fn test_pane_actions(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// app_state -// .fs -// .as_fake() -// .insert_tree( -// "/root", -// json!({ -// "a": { -// "file1": "contents 1", -// "file2": "contents 2", -// "file3": "contents 3", -// }, -// }), -// ) -// .await; - -// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; -// let window = cx.add_window(|cx| Workspace::test_new(project, cx)); -// let workspace = window.root(cx); - -// let entries = cx.read(|cx| workspace.file_project_paths(cx)); -// let file1 = entries[0].clone(); - -// let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone()); - -// workspace -// .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) -// .await -// .unwrap(); - -// let (editor_1, buffer) = pane_1.update(cx, |pane_1, cx| { -// let editor = pane_1.active_item().unwrap().downcast::().unwrap(); -// assert_eq!(editor.project_path(cx), Some(file1.clone())); -// let buffer = editor.update(cx, |editor, cx| { -// editor.insert("dirt", cx); -// editor.buffer().downgrade() -// }); -// (editor.downgrade(), buffer) -// }); - -// cx.dispatch_action(window.into(), pane::SplitRight); -// let editor_2 = cx.update(|cx| { -// let pane_2 = workspace.read(cx).active_pane().clone(); -// assert_ne!(pane_1, pane_2); - -// let pane2_item = pane_2.read(cx).active_item().unwrap(); -// assert_eq!(pane2_item.project_path(cx), Some(file1.clone())); - -// pane2_item.downcast::().unwrap().downgrade() -// }); -// cx.dispatch_action( -// window.into(), -// workspace::CloseActiveItem { save_intent: None }, -// ); - -// cx.foreground().run_until_parked(); -// workspace.read_with(cx, |workspace, _| { -// assert_eq!(workspace.panes().len(), 1); -// assert_eq!(workspace.active_pane(), &pane_1); -// }); - -// cx.dispatch_action( -// window.into(), -// workspace::CloseActiveItem { save_intent: None }, -// ); -// cx.foreground().run_until_parked(); -// window.simulate_prompt_answer(1, cx); -// cx.foreground().run_until_parked(); - -// workspace.read_with(cx, |workspace, cx| { -// assert_eq!(workspace.panes().len(), 1); -// assert!(workspace.active_item(cx).is_none()); -// }); - -// cx.assert_dropped(editor_1); -// cx.assert_dropped(editor_2); -// cx.assert_dropped(buffer); -// } - -// #[gpui::test] -// async fn test_navigation(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// app_state -// .fs -// .as_fake() -// .insert_tree( -// "/root", -// json!({ -// "a": { -// "file1": "contents 1\n".repeat(20), -// "file2": "contents 2\n".repeat(20), -// "file3": "contents 3\n".repeat(20), -// }, -// }), -// ) -// .await; - -// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; -// let workspace = cx -// .add_window(|cx| Workspace::test_new(project.clone(), cx)) -// .root(cx); -// let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - -// let entries = cx.read(|cx| workspace.file_project_paths(cx)); -// let file1 = entries[0].clone(); -// let file2 = entries[1].clone(); -// let file3 = entries[2].clone(); - -// let editor1 = workspace -// .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); -// editor1.update(cx, |editor, cx| { -// editor.change_selections(Some(Autoscroll::fit()), cx, |s| { -// s.select_display_ranges([DisplayPoint::new(10, 0)..DisplayPoint::new(10, 0)]) -// }); -// }); -// let editor2 = workspace -// .update(cx, |w, cx| w.open_path(file2.clone(), None, true, cx)) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); -// let editor3 = workspace -// .update(cx, |w, cx| w.open_path(file3.clone(), None, true, cx)) -// .await -// .unwrap() -// .downcast::() -// .unwrap(); - -// editor3 -// .update(cx, |editor, cx| { -// editor.change_selections(Some(Autoscroll::fit()), cx, |s| { -// s.select_display_ranges([DisplayPoint::new(12, 0)..DisplayPoint::new(12, 0)]) -// }); -// editor.newline(&Default::default(), cx); -// editor.newline(&Default::default(), cx); -// editor.move_down(&Default::default(), cx); -// editor.move_down(&Default::default(), cx); -// editor.save(project.clone(), cx) -// }) -// .await -// .unwrap(); -// editor3.update(cx, |editor, cx| { -// editor.set_scroll_position(vec2f(0., 12.5), cx) -// }); -// assert_eq!( -// active_location(&workspace, cx), -// (file3.clone(), DisplayPoint::new(16, 0), 12.5) -// ); - -// workspace -// .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file3.clone(), DisplayPoint::new(0, 0), 0.) -// ); - -// workspace -// .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file2.clone(), DisplayPoint::new(0, 0), 0.) -// ); - -// workspace -// .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file1.clone(), DisplayPoint::new(10, 0), 0.) -// ); - -// workspace -// .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file1.clone(), DisplayPoint::new(0, 0), 0.) -// ); - -// // Go back one more time and ensure we don't navigate past the first item in the history. -// workspace -// .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file1.clone(), DisplayPoint::new(0, 0), 0.) -// ); - -// workspace -// .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file1.clone(), DisplayPoint::new(10, 0), 0.) -// ); - -// workspace -// .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file2.clone(), DisplayPoint::new(0, 0), 0.) -// ); - -// // Go forward to an item that has been closed, ensuring it gets re-opened at the same -// // location. -// pane.update(cx, |pane, cx| { -// let editor3_id = editor3.id(); -// drop(editor3); -// pane.close_item_by_id(editor3_id, SaveIntent::Close, cx) -// }) -// .await -// .unwrap(); -// workspace -// .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file3.clone(), DisplayPoint::new(0, 0), 0.) -// ); - -// workspace -// .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file3.clone(), DisplayPoint::new(16, 0), 12.5) -// ); - -// workspace -// .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file3.clone(), DisplayPoint::new(0, 0), 0.) -// ); - -// // Go back to an item that has been closed and removed from disk, ensuring it gets skipped. -// pane.update(cx, |pane, cx| { -// let editor2_id = editor2.id(); -// drop(editor2); -// pane.close_item_by_id(editor2_id, SaveIntent::Close, cx) -// }) -// .await -// .unwrap(); -// app_state -// .fs -// .remove_file(Path::new("/root/a/file2"), Default::default()) -// .await -// .unwrap(); -// cx.foreground().run_until_parked(); - -// workspace -// .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file1.clone(), DisplayPoint::new(10, 0), 0.) -// ); -// workspace -// .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file3.clone(), DisplayPoint::new(0, 0), 0.) -// ); - -// // Modify file to collapse multiple nav history entries into the same location. -// // Ensure we don't visit the same location twice when navigating. -// editor1.update(cx, |editor, cx| { -// editor.change_selections(None, cx, |s| { -// s.select_display_ranges([DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)]) -// }) -// }); - -// for _ in 0..5 { -// editor1.update(cx, |editor, cx| { -// editor.change_selections(None, cx, |s| { -// s.select_display_ranges([DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)]) -// }); -// }); -// editor1.update(cx, |editor, cx| { -// editor.change_selections(None, cx, |s| { -// s.select_display_ranges([DisplayPoint::new(13, 0)..DisplayPoint::new(13, 0)]) -// }) -// }); -// } - -// editor1.update(cx, |editor, cx| { -// editor.transact(cx, |editor, cx| { -// editor.change_selections(None, cx, |s| { -// s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(14, 0)]) -// }); -// editor.insert("", cx); -// }) -// }); - -// editor1.update(cx, |editor, cx| { -// editor.change_selections(None, cx, |s| { -// s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) -// }) -// }); -// workspace -// .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file1.clone(), DisplayPoint::new(2, 0), 0.) -// ); -// workspace -// .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) -// .await -// .unwrap(); -// assert_eq!( -// active_location(&workspace, cx), -// (file1.clone(), DisplayPoint::new(3, 0), 0.) -// ); - -// fn active_location( -// workspace: &ViewHandle, -// cx: &mut TestAppContext, -// ) -> (ProjectPath, DisplayPoint, f32) { -// workspace.update(cx, |workspace, cx| { -// let item = workspace.active_item(cx).unwrap(); -// let editor = item.downcast::().unwrap(); -// let (selections, scroll_position) = editor.update(cx, |editor, cx| { -// ( -// editor.selections.display_ranges(cx), -// editor.scroll_position(cx), -// ) -// }); -// ( -// item.project_path(cx).unwrap(), -// selections[0].start, -// scroll_position.y(), -// ) -// }) -// } -// } - -// #[gpui::test] -// async fn test_reopening_closed_items(cx: &mut TestAppContext) { -// let app_state = init_test(cx); -// app_state -// .fs -// .as_fake() -// .insert_tree( -// "/root", -// json!({ -// "a": { -// "file1": "", -// "file2": "", -// "file3": "", -// "file4": "", -// }, -// }), -// ) -// .await; - -// let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; -// let workspace = cx -// .add_window(|cx| Workspace::test_new(project, cx)) -// .root(cx); -// let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - -// let entries = cx.read(|cx| workspace.file_project_paths(cx)); -// let file1 = entries[0].clone(); -// let file2 = entries[1].clone(); -// let file3 = entries[2].clone(); -// let file4 = entries[3].clone(); - -// let file1_item_id = workspace -// .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) -// .await -// .unwrap() -// .id(); -// let file2_item_id = workspace -// .update(cx, |w, cx| w.open_path(file2.clone(), None, true, cx)) -// .await -// .unwrap() -// .id(); -// let file3_item_id = workspace -// .update(cx, |w, cx| w.open_path(file3.clone(), None, true, cx)) -// .await -// .unwrap() -// .id(); -// let file4_item_id = workspace -// .update(cx, |w, cx| w.open_path(file4.clone(), None, true, cx)) -// .await -// .unwrap() -// .id(); -// assert_eq!(active_path(&workspace, cx), Some(file4.clone())); - -// // Close all the pane items in some arbitrary order. -// pane.update(cx, |pane, cx| { -// pane.close_item_by_id(file1_item_id, SaveIntent::Close, cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file4.clone())); - -// pane.update(cx, |pane, cx| { -// pane.close_item_by_id(file4_item_id, SaveIntent::Close, cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file3.clone())); - -// pane.update(cx, |pane, cx| { -// pane.close_item_by_id(file2_item_id, SaveIntent::Close, cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file3.clone())); - -// pane.update(cx, |pane, cx| { -// pane.close_item_by_id(file3_item_id, SaveIntent::Close, cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), None); - -// // Reopen all the closed items, ensuring they are reopened in the same order -// // in which they were closed. -// workspace -// .update(cx, Workspace::reopen_closed_item) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file3.clone())); - -// workspace -// .update(cx, Workspace::reopen_closed_item) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file2.clone())); - -// workspace -// .update(cx, Workspace::reopen_closed_item) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file4.clone())); - -// workspace -// .update(cx, Workspace::reopen_closed_item) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file1.clone())); - -// // Reopening past the last closed item is a no-op. -// workspace -// .update(cx, Workspace::reopen_closed_item) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file1.clone())); - -// // Reopening closed items doesn't interfere with navigation history. -// workspace -// .update(cx, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file4.clone())); - -// workspace -// .update(cx, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file2.clone())); - -// workspace -// .update(cx, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file3.clone())); - -// workspace -// .update(cx, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file4.clone())); - -// workspace -// .update(cx, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file3.clone())); - -// workspace -// .update(cx, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file2.clone())); - -// workspace -// .update(cx, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file1.clone())); - -// workspace -// .update(cx, |workspace, cx| { -// workspace.go_back(workspace.active_pane().downgrade(), cx) -// }) -// .await -// .unwrap(); -// assert_eq!(active_path(&workspace, cx), Some(file1.clone())); - -// fn active_path( -// workspace: &ViewHandle, -// cx: &TestAppContext, -// ) -> Option { -// workspace.read_with(cx, |workspace, cx| { -// let item = workspace.active_item(cx)?; -// item.project_path(cx) -// }) -// } -// } - -// #[gpui::test] -// async fn test_base_keymap(cx: &mut gpui::TestAppContext) { -// struct TestView; - -// impl Entity for TestView { -// type Event = (); -// } - -// impl View for TestView { -// fn ui_name() -> &'static str { -// "TestView" -// } - -// fn render(&mut self, _: &mut ViewContext) -> AnyElement { -// Empty::new().into_any() -// } -// } - -// let executor = cx.background(); -// let fs = FakeFs::new(executor.clone()); - -// actions!(test, [A, B]); -// // From the Atom keymap -// actions!(workspace, [ActivatePreviousPane]); -// // From the JetBrains keymap -// actions!(pane, [ActivatePrevItem]); - -// fs.save( -// "/settings.json".as_ref(), -// &r#" -// { -// "base_keymap": "Atom" -// } -// "# -// .into(), -// Default::default(), -// ) -// .await -// .unwrap(); - -// fs.save( -// "/keymap.json".as_ref(), -// &r#" -// [ -// { -// "bindings": { -// "backspace": "test::A" -// } -// } -// ] -// "# -// .into(), -// Default::default(), -// ) -// .await -// .unwrap(); - -// cx.update(|cx| { -// cx.set_global(SettingsStore::test(cx)); -// theme::init(Assets, cx); -// welcome::init(cx); - -// cx.add_global_action(|_: &A, _cx| {}); -// cx.add_global_action(|_: &B, _cx| {}); -// cx.add_global_action(|_: &ActivatePreviousPane, _cx| {}); -// cx.add_global_action(|_: &ActivatePrevItem, _cx| {}); - -// let settings_rx = watch_config_file( -// executor.clone(), -// fs.clone(), -// PathBuf::from("/settings.json"), -// ); -// let keymap_rx = -// watch_config_file(executor.clone(), fs.clone(), PathBuf::from("/keymap.json")); - -// handle_keymap_file_changes(keymap_rx, cx); -// handle_settings_file_changes(settings_rx, cx); -// }); - -// cx.foreground().run_until_parked(); - -// let window = cx.add_window(|_| TestView); - -// // Test loading the keymap base at all -// assert_key_bindings_for( -// window.into(), -// cx, -// vec![("backspace", &A), ("k", &ActivatePreviousPane)], -// line!(), -// ); - -// // Test modifying the users keymap, while retaining the base keymap -// fs.save( -// "/keymap.json".as_ref(), -// &r#" -// [ -// { -// "bindings": { -// "backspace": "test::B" -// } -// } -// ] -// "# -// .into(), -// Default::default(), -// ) -// .await -// .unwrap(); - -// cx.foreground().run_until_parked(); - -// assert_key_bindings_for( -// window.into(), -// cx, -// vec![("backspace", &B), ("k", &ActivatePreviousPane)], -// line!(), -// ); - -// // Test modifying the base, while retaining the users keymap -// fs.save( -// "/settings.json".as_ref(), -// &r#" -// { -// "base_keymap": "JetBrains" -// } -// "# -// .into(), -// Default::default(), -// ) -// .await -// .unwrap(); - -// cx.foreground().run_until_parked(); - -// assert_key_bindings_for( -// window.into(), -// cx, -// vec![("backspace", &B), ("[", &ActivatePrevItem)], -// line!(), -// ); - -// #[track_caller] -// fn assert_key_bindings_for<'a>( -// window: AnyWindowHandle, -// cx: &TestAppContext, -// actions: Vec<(&'static str, &'a dyn Action)>, -// line: u32, -// ) { -// for (key, action) in actions { -// // assert that... -// assert!( -// cx.available_actions(window, 0) -// .into_iter() -// .any(|(_, bound_action, b)| { -// // action names match... -// bound_action.name() == action.name() -// && bound_action.namespace() == action.namespace() -// // and key strokes contain the given key -// && b.iter() -// .any(|binding| binding.keystrokes().iter().any(|k| k.key == key)) -// }), -// "On {} Failed to find {} with key binding {}", -// line, -// action.name(), -// key -// ); -// } -// } -// } - -// #[gpui::test] -// async fn test_disabled_keymap_binding(cx: &mut gpui::TestAppContext) { -// struct TestView; - -// impl Entity for TestView { -// type Event = (); -// } - -// impl View for TestView { -// fn ui_name() -> &'static str { -// "TestView" -// } - -// fn render(&mut self, _: &mut ViewContext) -> AnyElement { -// Empty::new().into_any() -// } -// } - -// let executor = cx.background(); -// let fs = FakeFs::new(executor.clone()); - -// actions!(test, [A, B]); -// // From the Atom keymap -// actions!(workspace, [ActivatePreviousPane]); -// // From the JetBrains keymap -// actions!(pane, [ActivatePrevItem]); - -// fs.save( -// "/settings.json".as_ref(), -// &r#" -// { -// "base_keymap": "Atom" -// } -// "# -// .into(), -// Default::default(), -// ) -// .await -// .unwrap(); - -// fs.save( -// "/keymap.json".as_ref(), -// &r#" -// [ -// { -// "bindings": { -// "backspace": "test::A" -// } -// } -// ] -// "# -// .into(), -// Default::default(), -// ) -// .await -// .unwrap(); - -// cx.update(|cx| { -// cx.set_global(SettingsStore::test(cx)); -// theme::init(Assets, cx); -// welcome::init(cx); - -// cx.add_global_action(|_: &A, _cx| {}); -// cx.add_global_action(|_: &B, _cx| {}); -// cx.add_global_action(|_: &ActivatePreviousPane, _cx| {}); -// cx.add_global_action(|_: &ActivatePrevItem, _cx| {}); - -// let settings_rx = watch_config_file( -// executor.clone(), -// fs.clone(), -// PathBuf::from("/settings.json"), -// ); -// let keymap_rx = -// watch_config_file(executor.clone(), fs.clone(), PathBuf::from("/keymap.json")); - -// handle_keymap_file_changes(keymap_rx, cx); -// handle_settings_file_changes(settings_rx, cx); -// }); - -// cx.foreground().run_until_parked(); - -// let window = cx.add_window(|_| TestView); - -// // Test loading the keymap base at all -// assert_key_bindings_for( -// window.into(), -// cx, -// vec![("backspace", &A), ("k", &ActivatePreviousPane)], -// line!(), -// ); - -// // Test disabling the key binding for the base keymap -// fs.save( -// "/keymap.json".as_ref(), -// &r#" -// [ -// { -// "bindings": { -// "backspace": null -// } -// } -// ] -// "# -// .into(), -// Default::default(), -// ) -// .await -// .unwrap(); - -// cx.foreground().run_until_parked(); - -// assert_key_bindings_for( -// window.into(), -// cx, -// vec![("k", &ActivatePreviousPane)], -// line!(), -// ); - -// // Test modifying the base, while retaining the users keymap -// fs.save( -// "/settings.json".as_ref(), -// &r#" -// { -// "base_keymap": "JetBrains" -// } -// "# -// .into(), -// Default::default(), -// ) -// .await -// .unwrap(); - -// cx.foreground().run_until_parked(); - -// assert_key_bindings_for(window.into(), cx, vec![("[", &ActivatePrevItem)], line!()); - -// #[track_caller] -// fn assert_key_bindings_for<'a>( -// window: AnyWindowHandle, -// cx: &TestAppContext, -// actions: Vec<(&'static str, &'a dyn Action)>, -// line: u32, -// ) { -// for (key, action) in actions { -// // assert that... -// assert!( -// cx.available_actions(window, 0) -// .into_iter() -// .any(|(_, bound_action, b)| { -// // action names match... -// bound_action.name() == action.name() -// && bound_action.namespace() == action.namespace() -// // and key strokes contain the given key -// && b.iter() -// .any(|binding| binding.keystrokes().iter().any(|k| k.key == key)) -// }), -// "On {} Failed to find {} with key binding {}", -// line, -// action.name(), -// key -// ); -// } -// } -// } - -// #[gpui::test] -// fn test_bundled_settings_and_themes(cx: &mut AppContext) { -// cx.platform() -// .fonts() -// .add_fonts(&[ -// Assets -// .load("fonts/zed-sans/zed-sans-extended.ttf") -// .unwrap() -// .to_vec() -// .into(), -// Assets -// .load("fonts/zed-mono/zed-mono-extended.ttf") -// .unwrap() -// .to_vec() -// .into(), -// Assets -// .load("fonts/plex/IBMPlexSans-Regular.ttf") -// .unwrap() -// .to_vec() -// .into(), -// ]) -// .unwrap(); -// let themes = ThemeRegistry::new(Assets, cx.font_cache().clone()); -// let mut settings = SettingsStore::default(); -// settings -// .set_default_settings(&settings::default_settings(), cx) -// .unwrap(); -// cx.set_global(settings); -// theme::init(Assets, cx); - -// let mut has_default_theme = false; -// for theme_name in themes.list(false).map(|meta| meta.name) { -// let theme = themes.get(&theme_name).unwrap(); -// assert_eq!(theme.meta.name, theme_name); -// if theme.meta.name == settings::get::(cx).theme.meta.name { -// has_default_theme = true; -// } -// } -// assert!(has_default_theme); -// } - -// #[gpui::test] -// fn test_bundled_languages(cx: &mut AppContext) { -// cx.set_global(SettingsStore::test(cx)); -// let mut languages = LanguageRegistry::test(); -// languages.set_executor(cx.background().clone()); -// let languages = Arc::new(languages); -// let node_runtime = node_runtime::FakeNodeRuntime::new(); -// languages::init(languages.clone(), node_runtime, cx); -// for name in languages.language_names() { -// languages.language_for_name(&name); -// } -// cx.foreground().run_until_parked(); -// } - -// fn init_test(cx: &mut TestAppContext) -> Arc { -// cx.foreground().forbid_parking(); -// cx.update(|cx| { -// let mut app_state = AppState::test(cx); -// let state = Arc::get_mut(&mut app_state).unwrap(); -// state.initialize_workspace = initialize_workspace; -// state.build_window_options = build_window_options; -// theme::init((), cx); -// audio::init((), cx); -// channel::init(&app_state.client, app_state.user_store.clone(), cx); -// call::init(app_state.client.clone(), app_state.user_store.clone(), cx); -// notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); -// workspace::init(app_state.clone(), cx); -// Project::init_settings(cx); -// language::init(cx); -// editor::init(cx); -// project_panel::init_settings(cx); -// collab_ui::init(&app_state, cx); -// pane::init(cx); -// project_panel::init((), cx); -// terminal_view::init(cx); -// assistant::init(cx); -// app_state -// }) -// } - -// fn rust_lang() -> Arc { -// Arc::new(language::Language::new( -// language::LanguageConfig { -// name: "Rust".into(), -// path_suffixes: vec!["rs".to_string()], -// ..Default::default() -// }, -// Some(tree_sitter_rust::language()), -// )) -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use assets::Assets; + use editor::{scroll::autoscroll::Autoscroll, DisplayPoint, Editor, EditorEvent}; + use gpui::{ + actions, Action, AnyWindowHandle, AppContext, AssetSource, Entity, TestAppContext, + VisualTestContext, WindowHandle, + }; + use language::LanguageRegistry; + use project::{project_settings::ProjectSettings, Project, ProjectPath}; + use serde_json::json; + use settings::{handle_settings_file_changes, watch_config_file, SettingsStore}; + use std::{ + collections::HashSet, + path::{Path, PathBuf}, + }; + use theme::{ThemeRegistry, ThemeSettings}; + use workspace::{ + item::{Item, ItemHandle}, + open_new, open_paths, pane, NewFile, OpenVisible, SaveIntent, SplitDirection, + WorkspaceHandle, + }; + + // #[gpui::test] + // async fn test_open_paths_action(cx: &mut TestAppContext) { + // let app_state = init_test(cx); + // app_state + // .fs + // .as_fake() + // .insert_tree( + // "/root", + // json!({ + // "a": { + // "aa": null, + // "ab": null, + // }, + // "b": { + // "ba": null, + // "bb": null, + // }, + // "c": { + // "ca": null, + // "cb": null, + // }, + // "d": { + // "da": null, + // "db": null, + // }, + // }), + // ) + // .await; + + // cx.update(|cx| { + // open_paths( + // &[PathBuf::from("/root/a"), PathBuf::from("/root/b")], + // &app_state, + // None, + // cx, + // ) + // }) + // .await + // .unwrap(); + // assert_eq!(cx.read(|cx| cx.windows().len()), 1); + + // cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, None, cx)) + // .await + // .unwrap(); + // assert_eq!(cx.read(|cx| cx.windows().len()), 1); + // let workspace_1 = cx + // .read(|cx| cx.windows()[0].downcast::()) + // .unwrap(); + // workspace_1 + // .update(cx, |workspace, cx| { + // assert_eq!(workspace.worktrees(cx).count(), 2); + // assert!(workspace.left_dock().read(cx).is_open()); + // assert!(workspace + // .active_pane() + // .read(cx) + // .focus_handle(cx) + // .is_focused(cx)); + // }) + // .unwrap(); + + // cx.update(|cx| { + // open_paths( + // &[PathBuf::from("/root/b"), PathBuf::from("/root/c")], + // &app_state, + // None, + // cx, + // ) + // }) + // .await + // .unwrap(); + // assert_eq!(cx.read(|cx| cx.windows().len()), 2); + + // // Replace existing windows + // let window = cx + // .update(|cx| cx.windows()[0].downcast::()) + // .unwrap(); + // cx.update(|cx| { + // open_paths( + // &[PathBuf::from("/root/c"), PathBuf::from("/root/d")], + // &app_state, + // Some(window), + // cx, + // ) + // }) + // .await + // .unwrap(); + // assert_eq!(cx.read(|cx| cx.windows().len()), 2); + // let workspace_1 = cx + // .update(|cx| cx.windows()[0].downcast::()) + // .unwrap(); + // workspace_1 + // .update(cx, |workspace, cx| { + // assert_eq!( + // workspace + // .worktrees(cx) + // .map(|w| w.read(cx).abs_path()) + // .collect::>(), + // &[Path::new("/root/c").into(), Path::new("/root/d").into()] + // ); + // assert!(workspace.left_dock().read(cx).is_open()); + // assert!(workspace.active_pane().focus_handle(cx).is_focused(cx)); + // }) + // .unwrap(); + // } + + #[gpui::test] + async fn test_window_edit_state(cx: &mut TestAppContext) { + let executor = cx.executor(); + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree("/root", json!({"a": "hey"})) + .await; + + cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, None, cx)) + .await + .unwrap(); + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + + // When opening the workspace, the window is not in a edited state. + let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + + let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { + cx.test_window(window.into()).edited() + }; + let pane = window + .read_with(cx, |workspace, _| workspace.active_pane().clone()) + .unwrap(); + let editor = window + .read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }) + .unwrap(); + + assert!(!window_is_edited(window, cx)); + + // Editing a buffer marks the window as edited. + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| editor.insert("EDIT", cx)); + }) + .unwrap(); + + assert!(window_is_edited(window, cx)); + + // Undoing the edit restores the window's edited state. + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| editor.undo(&Default::default(), cx)); + }) + .unwrap(); + assert!(!window_is_edited(window, cx)); + + // Redoing the edit marks the window as edited again. + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| editor.redo(&Default::default(), cx)); + }) + .unwrap(); + assert!(window_is_edited(window, cx)); + + // Closing the item restores the window's edited state. + let close = window + .update(cx, |_, cx| { + pane.update(cx, |pane, cx| { + drop(editor); + pane.close_active_item(&Default::default(), cx).unwrap() + }) + }) + .unwrap(); + executor.run_until_parked(); + + cx.simulate_prompt_answer(1); + close.await.unwrap(); + assert!(!window_is_edited(window, cx)); + + // Opening the buffer again doesn't impact the window's edited state. + cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, None, cx)) + .await + .unwrap(); + let editor = window + .read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }) + .unwrap(); + assert!(!window_is_edited(window, cx)); + + // Editing the buffer marks the window as edited. + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| editor.insert("EDIT", cx)); + }) + .unwrap(); + assert!(window_is_edited(window, cx)); + + // Ensure closing the window via the mouse gets preempted due to the + // buffer having unsaved changes. + assert!(!VisualTestContext::from_window(window.into(), cx).simulate_close()); + executor.run_until_parked(); + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + + // The window is successfully closed after the user dismisses the prompt. + cx.simulate_prompt_answer(1); + executor.run_until_parked(); + assert_eq!(cx.update(|cx| cx.windows().len()), 0); + } + + #[gpui::test] + async fn test_new_empty_workspace(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + open_new(&app_state, cx, |workspace, cx| { + Editor::new_file(workspace, &Default::default(), cx) + }) + }) + .await; + + let workspace = cx + .update(|cx| cx.windows().first().unwrap().downcast::()) + .unwrap(); + + let editor = workspace + .update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap(); + editor.update(cx, |editor, cx| { + assert!(editor.text(cx).is_empty()); + assert!(!editor.is_dirty(cx)); + }); + + editor + }) + .unwrap(); + + let save_task = workspace + .update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }) + .unwrap(); + app_state.fs.create_dir(Path::new("/root")).await.unwrap(); + cx.background_executor.run_until_parked(); + cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name"))); + save_task.await.unwrap(); + workspace + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| { + assert!(!editor.is_dirty(cx)); + assert_eq!(editor.title(cx), "the-new-name"); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_open_entry(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "a": { + "file1": "contents 1", + "file2": "contents 2", + "file3": "contents 3", + }, + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + let workspace = window.root(cx).unwrap(); + + let entries = cx.read(|cx| workspace.file_project_paths(cx)); + let file1 = entries[0].clone(); + let file2 = entries[1].clone(); + let file3 = entries[2].clone(); + + // Open the first entry + let entry_1 = window + .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) + .unwrap() + .await + .unwrap(); + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!( + pane.active_item().unwrap().project_path(cx), + Some(file1.clone()) + ); + assert_eq!(pane.items_len(), 1); + }); + + // Open the second entry + window + .update(cx, |w, cx| w.open_path(file2.clone(), None, true, cx)) + .unwrap() + .await + .unwrap(); + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!( + pane.active_item().unwrap().project_path(cx), + Some(file2.clone()) + ); + assert_eq!(pane.items_len(), 2); + }); + + // Open the first entry again. The existing pane item is activated. + let entry_1b = window + .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) + .unwrap() + .await + .unwrap(); + assert_eq!(entry_1.item_id(), entry_1b.item_id()); + + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!( + pane.active_item().unwrap().project_path(cx), + Some(file1.clone()) + ); + assert_eq!(pane.items_len(), 2); + }); + + // Split the pane with the first entry, then open the second entry again. + window + .update(cx, |w, cx| { + w.split_and_clone(w.active_pane().clone(), SplitDirection::Right, cx); + w.open_path(file2.clone(), None, true, cx) + }) + .unwrap() + .await + .unwrap(); + + window + .read_with(cx, |w, cx| { + assert_eq!( + w.active_pane() + .read(cx) + .active_item() + .unwrap() + .project_path(cx), + Some(file2.clone()) + ); + }) + .unwrap(); + + // Open the third entry twice concurrently. Only one pane item is added. + let (t1, t2) = window + .update(cx, |w, cx| { + ( + w.open_path(file3.clone(), None, true, cx), + w.open_path(file3.clone(), None, true, cx), + ) + }) + .unwrap(); + t1.await.unwrap(); + t2.await.unwrap(); + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!( + pane.active_item().unwrap().project_path(cx), + Some(file3.clone()) + ); + let pane_entries = pane + .items() + .map(|i| i.project_path(cx).unwrap()) + .collect::>(); + assert_eq!(pane_entries, &[file1, file2, file3]); + }); + } + + #[gpui::test] + async fn test_open_paths(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + "/", + json!({ + "dir1": { + "a.txt": "" + }, + "dir2": { + "b.txt": "" + }, + "dir3": { + "c.txt": "" + }, + "d.txt": "" + }), + ) + .await; + + cx.update(|cx| open_paths(&[PathBuf::from("/dir1/")], &app_state, None, cx)) + .await + .unwrap(); + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let workspace = window.root(cx).unwrap(); + + #[track_caller] + fn assert_project_panel_selection( + workspace: &Workspace, + expected_worktree_path: &Path, + expected_entry_path: &Path, + cx: &AppContext, + ) { + let project_panel = [ + workspace.left_dock().read(cx).panel::(), + workspace.right_dock().read(cx).panel::(), + workspace.bottom_dock().read(cx).panel::(), + ] + .into_iter() + .find_map(std::convert::identity) + .expect("found no project panels") + .read(cx); + let (selected_worktree, selected_entry) = project_panel + .selected_entry(cx) + .expect("project panel should have a selected entry"); + assert_eq!( + selected_worktree.abs_path().as_ref(), + expected_worktree_path, + "Unexpected project panel selected worktree path" + ); + assert_eq!( + selected_entry.path.as_ref(), + expected_entry_path, + "Unexpected project panel selected entry path" + ); + } + + // Open a file within an existing worktree. + window + .update(cx, |view, cx| { + view.open_paths(vec!["/dir1/a.txt".into()], OpenVisible::All, None, cx) + }) + .unwrap() + .await; + cx.read(|cx| { + let workspace = workspace.read(cx); + assert_project_panel_selection(workspace, Path::new("/dir1"), Path::new("a.txt"), cx); + assert_eq!( + workspace + .active_pane() + .read(cx) + .active_item() + .unwrap() + .act_as::(cx) + .unwrap() + .read(cx) + .title(cx), + "a.txt" + ); + }); + + // Open a file outside of any existing worktree. + window + .update(cx, |view, cx| { + view.open_paths(vec!["/dir2/b.txt".into()], OpenVisible::All, None, cx) + }) + .unwrap() + .await; + cx.read(|cx| { + let workspace = workspace.read(cx); + assert_project_panel_selection(workspace, Path::new("/dir2/b.txt"), Path::new(""), cx); + let worktree_roots = workspace + .worktrees(cx) + .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) + .collect::>(); + assert_eq!( + worktree_roots, + vec!["/dir1", "/dir2/b.txt"] + .into_iter() + .map(Path::new) + .collect(), + ); + assert_eq!( + workspace + .active_pane() + .read(cx) + .active_item() + .unwrap() + .act_as::(cx) + .unwrap() + .read(cx) + .title(cx), + "b.txt" + ); + }); + + // Ensure opening a directory and one of its children only adds one worktree. + window + .update(cx, |view, cx| { + view.open_paths( + vec!["/dir3".into(), "/dir3/c.txt".into()], + OpenVisible::All, + None, + cx, + ) + }) + .unwrap() + .await; + cx.read(|cx| { + let workspace = workspace.read(cx); + assert_project_panel_selection(workspace, Path::new("/dir3"), Path::new("c.txt"), cx); + let worktree_roots = workspace + .worktrees(cx) + .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) + .collect::>(); + assert_eq!( + worktree_roots, + vec!["/dir1", "/dir2/b.txt", "/dir3"] + .into_iter() + .map(Path::new) + .collect(), + ); + assert_eq!( + workspace + .active_pane() + .read(cx) + .active_item() + .unwrap() + .act_as::(cx) + .unwrap() + .read(cx) + .title(cx), + "c.txt" + ); + }); + + // Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree. + window + .update(cx, |view, cx| { + view.open_paths(vec!["/d.txt".into()], OpenVisible::None, None, cx) + }) + .unwrap() + .await; + cx.read(|cx| { + let workspace = workspace.read(cx); + assert_project_panel_selection(workspace, Path::new("/d.txt"), Path::new(""), cx); + let worktree_roots = workspace + .worktrees(cx) + .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) + .collect::>(); + assert_eq!( + worktree_roots, + vec!["/dir1", "/dir2/b.txt", "/dir3", "/d.txt"] + .into_iter() + .map(Path::new) + .collect(), + ); + + let visible_worktree_roots = workspace + .visible_worktrees(cx) + .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref()) + .collect::>(); + assert_eq!( + visible_worktree_roots, + vec!["/dir1", "/dir2/b.txt", "/dir3"] + .into_iter() + .map(Path::new) + .collect(), + ); + + assert_eq!( + workspace + .active_pane() + .read(cx) + .active_item() + .unwrap() + .act_as::(cx) + .unwrap() + .read(cx) + .title(cx), + "d.txt" + ); + }); + } + + #[gpui::test] + async fn test_opening_excluded_paths(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = + Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]); + }); + }); + }); + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + ".gitignore": "ignored_dir\n", + ".git": { + "HEAD": "ref: refs/heads/main", + }, + "regular_dir": { + "file": "regular file contents", + }, + "ignored_dir": { + "ignored_subdir": { + "file": "ignored subfile contents", + }, + "file": "ignored file contents", + }, + "excluded_dir": { + "file": "excluded file contents", + }, + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + let workspace = window.root(cx).unwrap(); + + let initial_entries = cx.read(|cx| workspace.file_project_paths(cx)); + let paths_to_open = [ + Path::new("/root/excluded_dir/file").to_path_buf(), + Path::new("/root/.git/HEAD").to_path_buf(), + Path::new("/root/excluded_dir/ignored_subdir").to_path_buf(), + ]; + let (opened_workspace, new_items) = cx + .update(|cx| workspace::open_paths(&paths_to_open, &app_state, None, cx)) + .await + .unwrap(); + + assert_eq!( + opened_workspace.root_view(cx).unwrap().entity_id(), + workspace.entity_id(), + "Excluded files in subfolders of a workspace root should be opened in the workspace" + ); + let mut opened_paths = cx.read(|cx| { + assert_eq!( + new_items.len(), + paths_to_open.len(), + "Expect to get the same number of opened items as submitted paths to open" + ); + new_items + .iter() + .zip(paths_to_open.iter()) + .map(|(i, path)| { + match i { + Some(Ok(i)) => { + Some(i.project_path(cx).map(|p| p.path.display().to_string())) + } + Some(Err(e)) => panic!("Excluded file {path:?} failed to open: {e:?}"), + None => None, + } + .flatten() + }) + .collect::>() + }); + opened_paths.sort(); + assert_eq!( + opened_paths, + vec![ + None, + Some(".git/HEAD".to_string()), + Some("excluded_dir/file".to_string()), + ], + "Excluded files should get opened, excluded dir should not get opened" + ); + + let entries = cx.read(|cx| workspace.file_project_paths(cx)); + assert_eq!( + initial_entries, entries, + "Workspace entries should not change after opening excluded files and directories paths" + ); + + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + let mut opened_buffer_paths = pane + .items() + .map(|i| { + i.project_path(cx) + .expect("all excluded files that got open should have a path") + .path + .display() + .to_string() + }) + .collect::>(); + opened_buffer_paths.sort(); + assert_eq!( + opened_buffer_paths, + vec![".git/HEAD".to_string(), "excluded_dir/file".to_string()], + "Despite not being present in the worktrees, buffers for excluded files are opened and added to the pane" + ); + }); + } + + #[gpui::test] + async fn test_save_conflicting_item(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree("/root", json!({ "a.txt": "" })) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + let workspace = window.root(cx).unwrap(); + + // Open a file within an existing worktree. + window + .update(cx, |view, cx| { + view.open_paths( + vec![PathBuf::from("/root/a.txt")], + OpenVisible::All, + None, + cx, + ) + }) + .unwrap() + .await; + let editor = cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + let item = pane.active_item().unwrap(); + item.downcast::().unwrap() + }); + + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| editor.handle_input("x", cx)); + }) + .unwrap(); + + app_state + .fs + .as_fake() + .insert_file("/root/a.txt", "changed".to_string()) + .await; + editor + .condition::(cx, |editor, cx| editor.has_conflict(cx)) + .await; + cx.read(|cx| assert!(editor.is_dirty(cx))); + + let save_task = window + .update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }) + .unwrap(); + cx.background_executor.run_until_parked(); + cx.simulate_prompt_answer(0); + save_task.await.unwrap(); + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| { + assert!(!editor.is_dirty(cx)); + assert!(!editor.has_conflict(cx)); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_open_and_save_new_file(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state.fs.create_dir(Path::new("/root")).await.unwrap(); + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + project.update(cx, |project, _| project.languages().add(rust_lang())); + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + let worktree = cx.update(|cx| window.read(cx).unwrap().worktrees(cx).next().unwrap()); + + // Create a new untitled buffer + cx.dispatch_action(window.into(), NewFile); + let editor = window + .read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }) + .unwrap(); + + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| { + assert!(!editor.is_dirty(cx)); + assert_eq!(editor.title(cx), "untitled"); + assert!(Arc::ptr_eq( + &editor.buffer().read(cx).language_at(0, cx).unwrap(), + &languages::PLAIN_TEXT + )); + editor.handle_input("hi", cx); + assert!(editor.is_dirty(cx)); + }); + }) + .unwrap(); + + // Save the buffer. This prompts for a filename. + let save_task = window + .update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }) + .unwrap(); + cx.background_executor.run_until_parked(); + cx.simulate_new_path_selection(|parent_dir| { + assert_eq!(parent_dir, Path::new("/root")); + Some(parent_dir.join("the-new-name.rs")) + }); + cx.read(|cx| { + assert!(editor.is_dirty(cx)); + assert_eq!(editor.read(cx).title(cx), "untitled"); + }); + + // When the save completes, the buffer's title is updated and the language is assigned based + // on the path. + save_task.await.unwrap(); + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| { + assert!(!editor.is_dirty(cx)); + assert_eq!(editor.title(cx), "the-new-name.rs"); + assert_eq!( + editor + .buffer() + .read(cx) + .language_at(0, cx) + .unwrap() + .name() + .as_ref(), + "Rust" + ); + }); + }) + .unwrap(); + + // Edit the file and save it again. This time, there is no filename prompt. + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| { + editor.handle_input(" there", cx); + assert!(editor.is_dirty(cx)); + }); + }) + .unwrap(); + + let save_task = window + .update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }) + .unwrap(); + save_task.await.unwrap(); + // todo!() po + //assert!(!cx.did_prompt_for_new_path()); + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| { + assert!(!editor.is_dirty(cx)); + assert_eq!(editor.title(cx), "the-new-name.rs") + }); + }) + .unwrap(); + + // Open the same newly-created file in another pane item. The new editor should reuse + // the same buffer. + cx.dispatch_action(window.into(), NewFile); + window + .update(cx, |workspace, cx| { + workspace.split_and_clone( + workspace.active_pane().clone(), + SplitDirection::Right, + cx, + ); + workspace.open_path((worktree.read(cx).id(), "the-new-name.rs"), None, true, cx) + }) + .unwrap() + .await + .unwrap(); + let editor2 = window + .update(cx, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }) + .unwrap(); + cx.read(|cx| { + assert_eq!( + editor2.read(cx).buffer().read(cx).as_singleton().unwrap(), + editor.read(cx).buffer().read(cx).as_singleton().unwrap() + ); + }) + } + + #[gpui::test] + async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state.fs.create_dir(Path::new("/root")).await.unwrap(); + + let project = Project::test(app_state.fs.clone(), [], cx).await; + project.update(cx, |project, _| project.languages().add(rust_lang())); + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + + // Create a new untitled buffer + cx.dispatch_action(window.into(), NewFile); + let editor = window + .read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }) + .unwrap(); + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| { + assert!(Arc::ptr_eq( + &editor.buffer().read(cx).language_at(0, cx).unwrap(), + &languages::PLAIN_TEXT + )); + editor.handle_input("hi", cx); + assert!(editor.is_dirty(cx)); + }); + }) + .unwrap(); + + // Save the buffer. This prompts for a filename. + let save_task = window + .update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }) + .unwrap(); + cx.background_executor.run_until_parked(); + cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs"))); + save_task.await.unwrap(); + // The buffer is not dirty anymore and the language is assigned based on the path. + window + .update(cx, |_, cx| { + editor.update(cx, |editor, cx| { + assert!(!editor.is_dirty(cx)); + assert_eq!( + editor + .buffer() + .read(cx) + .language_at(0, cx) + .unwrap() + .name() + .as_ref(), + "Rust" + ) + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_pane_actions(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "a": { + "file1": "contents 1", + "file2": "contents 2", + "file3": "contents 3", + }, + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + let workspace = window.root(cx).unwrap(); + + let entries = cx.read(|cx| workspace.file_project_paths(cx)); + let file1 = entries[0].clone(); + + let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone()); + + window + .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) + .unwrap() + .await + .unwrap(); + + let (editor_1, buffer) = window + .update(cx, |_, cx| { + pane_1.update(cx, |pane_1, cx| { + let editor = pane_1.active_item().unwrap().downcast::().unwrap(); + assert_eq!(editor.project_path(cx), Some(file1.clone())); + let buffer = editor.update(cx, |editor, cx| { + editor.insert("dirt", cx); + editor.buffer().downgrade() + }); + (editor.downgrade(), buffer) + }) + }) + .unwrap(); + + cx.dispatch_action(window.into(), pane::SplitRight); + let editor_2 = cx.update(|cx| { + let pane_2 = workspace.read(cx).active_pane().clone(); + assert_ne!(pane_1, pane_2); + + let pane2_item = pane_2.read(cx).active_item().unwrap(); + assert_eq!(pane2_item.project_path(cx), Some(file1.clone())); + + pane2_item.downcast::().unwrap().downgrade() + }); + cx.dispatch_action( + window.into(), + workspace::CloseActiveItem { save_intent: None }, + ); + + cx.background_executor.run_until_parked(); + window + .read_with(cx, |workspace, _| { + assert_eq!(workspace.panes().len(), 1); + assert_eq!(workspace.active_pane(), &pane_1); + }) + .unwrap(); + + cx.dispatch_action( + window.into(), + workspace::CloseActiveItem { save_intent: None }, + ); + cx.background_executor.run_until_parked(); + cx.simulate_prompt_answer(1); + cx.background_executor.run_until_parked(); + + window + .read_with(cx, |workspace, cx| { + assert_eq!(workspace.panes().len(), 1); + assert!(workspace.active_item(cx).is_none()); + }) + .unwrap(); + editor_1.assert_dropped(); + editor_2.assert_dropped(); + buffer.assert_dropped(); + } + + #[gpui::test] + async fn test_navigation(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "a": { + "file1": "contents 1\n".repeat(20), + "file2": "contents 2\n".repeat(20), + "file3": "contents 3\n".repeat(20), + }, + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let pane = workspace + .read_with(cx, |workspace, _| workspace.active_pane().clone()) + .unwrap(); + + let entries = cx.update(|cx| workspace.root(cx).unwrap().file_project_paths(cx)); + let file1 = entries[0].clone(); + let file2 = entries[1].clone(); + let file3 = entries[2].clone(); + + let editor1 = workspace + .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + workspace + .update(cx, |_, cx| { + editor1.update(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_display_ranges( + [DisplayPoint::new(10, 0)..DisplayPoint::new(10, 0)], + ) + }); + }); + }) + .unwrap(); + + let editor2 = workspace + .update(cx, |w, cx| w.open_path(file2.clone(), None, true, cx)) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + let editor3 = workspace + .update(cx, |w, cx| w.open_path(file3.clone(), None, true, cx)) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + + workspace + .update(cx, |_, cx| { + editor3.update(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_display_ranges( + [DisplayPoint::new(12, 0)..DisplayPoint::new(12, 0)], + ) + }); + editor.newline(&Default::default(), cx); + editor.newline(&Default::default(), cx); + editor.move_down(&Default::default(), cx); + editor.move_down(&Default::default(), cx); + editor.save(project.clone(), cx) + }) + }) + .unwrap() + .await + .unwrap(); + workspace + .update(cx, |_, cx| { + editor3.update(cx, |editor, cx| { + editor.set_scroll_position(point(0., 12.5), cx) + }); + }) + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file3.clone(), DisplayPoint::new(16, 0), 12.5) + ); + + workspace + .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file3.clone(), DisplayPoint::new(0, 0), 0.) + ); + + workspace + .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file2.clone(), DisplayPoint::new(0, 0), 0.) + ); + + workspace + .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file1.clone(), DisplayPoint::new(10, 0), 0.) + ); + + workspace + .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file1.clone(), DisplayPoint::new(0, 0), 0.) + ); + + // Go back one more time and ensure we don't navigate past the first item in the history. + workspace + .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file1.clone(), DisplayPoint::new(0, 0), 0.) + ); + + workspace + .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file1.clone(), DisplayPoint::new(10, 0), 0.) + ); + + workspace + .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file2.clone(), DisplayPoint::new(0, 0), 0.) + ); + + // Go forward to an item that has been closed, ensuring it gets re-opened at the same + // location. + workspace + .update(cx, |_, cx| { + pane.update(cx, |pane, cx| { + let editor3_id = editor3.entity_id(); + drop(editor3); + pane.close_item_by_id(editor3_id, SaveIntent::Close, cx) + }) + }) + .unwrap() + .await + .unwrap(); + workspace + .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file3.clone(), DisplayPoint::new(0, 0), 0.) + ); + + workspace + .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file3.clone(), DisplayPoint::new(16, 0), 12.5) + ); + + workspace + .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file3.clone(), DisplayPoint::new(0, 0), 0.) + ); + + // Go back to an item that has been closed and removed from disk, ensuring it gets skipped. + workspace + .update(cx, |_, cx| { + pane.update(cx, |pane, cx| { + let editor2_id = editor2.entity_id(); + drop(editor2); + pane.close_item_by_id(editor2_id, SaveIntent::Close, cx) + }) + }) + .unwrap() + .await + .unwrap(); + app_state + .fs + .remove_file(Path::new("/root/a/file2"), Default::default()) + .await + .unwrap(); + cx.background_executor.run_until_parked(); + + workspace + .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file1.clone(), DisplayPoint::new(10, 0), 0.) + ); + workspace + .update(cx, |w, cx| w.go_forward(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file3.clone(), DisplayPoint::new(0, 0), 0.) + ); + + // Modify file to collapse multiple nav history entries into the same location. + // Ensure we don't visit the same location twice when navigating. + workspace + .update(cx, |_, cx| { + editor1.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges( + [DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)], + ) + }) + }); + }) + .unwrap(); + for _ in 0..5 { + workspace + .update(cx, |_, cx| { + editor1.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0) + ]) + }); + }); + }) + .unwrap(); + + workspace + .update(cx, |_, cx| { + editor1.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(13, 0)..DisplayPoint::new(13, 0) + ]) + }) + }); + }) + .unwrap(); + } + workspace + .update(cx, |_, cx| { + editor1.update(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(2, 0)..DisplayPoint::new(14, 0) + ]) + }); + editor.insert("", cx); + }) + }); + }) + .unwrap(); + + workspace + .update(cx, |_, cx| { + editor1.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) + }) + }); + }) + .unwrap(); + workspace + .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file1.clone(), DisplayPoint::new(2, 0), 0.) + ); + workspace + .update(cx, |w, cx| w.go_back(w.active_pane().downgrade(), cx)) + .unwrap() + .await + .unwrap(); + assert_eq!( + active_location(&workspace, cx), + (file1.clone(), DisplayPoint::new(3, 0), 0.) + ); + + fn active_location( + workspace: &WindowHandle, + cx: &mut TestAppContext, + ) -> (ProjectPath, DisplayPoint, f32) { + workspace + .update(cx, |workspace, cx| { + let item = workspace.active_item(cx).unwrap(); + let editor = item.downcast::().unwrap(); + let (selections, scroll_position) = editor.update(cx, |editor, cx| { + ( + editor.selections.display_ranges(cx), + editor.scroll_position(cx), + ) + }); + ( + item.project_path(cx).unwrap(), + selections[0].start, + scroll_position.y, + ) + }) + .unwrap() + } + } + + #[gpui::test] + async fn test_reopening_closed_items(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "a": { + "file1": "", + "file2": "", + "file3": "", + "file4": "", + }, + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project, cx)); + let pane = workspace + .read_with(cx, |workspace, _| workspace.active_pane().clone()) + .unwrap(); + + let entries = cx.update(|cx| workspace.root(cx).unwrap().file_project_paths(cx)); + let file1 = entries[0].clone(); + let file2 = entries[1].clone(); + let file3 = entries[2].clone(); + let file4 = entries[3].clone(); + + let file1_item_id = workspace + .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx)) + .unwrap() + .await + .unwrap() + .item_id(); + let file2_item_id = workspace + .update(cx, |w, cx| w.open_path(file2.clone(), None, true, cx)) + .unwrap() + .await + .unwrap() + .item_id(); + let file3_item_id = workspace + .update(cx, |w, cx| w.open_path(file3.clone(), None, true, cx)) + .unwrap() + .await + .unwrap() + .item_id(); + let file4_item_id = workspace + .update(cx, |w, cx| w.open_path(file4.clone(), None, true, cx)) + .unwrap() + .await + .unwrap() + .item_id(); + assert_eq!(active_path(&workspace, cx), Some(file4.clone())); + + // Close all the pane items in some arbitrary order. + workspace + .update(cx, |_, cx| { + pane.update(cx, |pane, cx| { + pane.close_item_by_id(file1_item_id, SaveIntent::Close, cx) + }) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file4.clone())); + + workspace + .update(cx, |_, cx| { + pane.update(cx, |pane, cx| { + pane.close_item_by_id(file4_item_id, SaveIntent::Close, cx) + }) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file3.clone())); + + workspace + .update(cx, |_, cx| { + pane.update(cx, |pane, cx| { + pane.close_item_by_id(file2_item_id, SaveIntent::Close, cx) + }) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file3.clone())); + workspace + .update(cx, |_, cx| { + pane.update(cx, |pane, cx| { + pane.close_item_by_id(file3_item_id, SaveIntent::Close, cx) + }) + }) + .unwrap() + .await + .unwrap(); + + assert_eq!(active_path(&workspace, cx), None); + + // Reopen all the closed items, ensuring they are reopened in the same order + // in which they were closed. + workspace + .update(cx, Workspace::reopen_closed_item) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file3.clone())); + + workspace + .update(cx, Workspace::reopen_closed_item) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file2.clone())); + + workspace + .update(cx, Workspace::reopen_closed_item) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file4.clone())); + + workspace + .update(cx, Workspace::reopen_closed_item) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file1.clone())); + + // Reopening past the last closed item is a no-op. + workspace + .update(cx, Workspace::reopen_closed_item) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file1.clone())); + + // Reopening closed items doesn't interfere with navigation history. + workspace + .update(cx, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file4.clone())); + + workspace + .update(cx, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file2.clone())); + + workspace + .update(cx, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file3.clone())); + + workspace + .update(cx, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file4.clone())); + + workspace + .update(cx, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file3.clone())); + + workspace + .update(cx, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file2.clone())); + + workspace + .update(cx, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file1.clone())); + + workspace + .update(cx, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(active_path(&workspace, cx), Some(file1.clone())); + + fn active_path( + workspace: &WindowHandle, + cx: &TestAppContext, + ) -> Option { + workspace + .read_with(cx, |workspace, cx| { + let item = workspace.active_item(cx)?; + item.project_path(cx) + }) + .unwrap() + } + } + fn init_keymap_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let app_state = AppState::test(cx); + + theme::init(theme::LoadThemes::JustBase, cx); + client::init(&app_state.client, cx); + language::init(cx); + workspace::init(app_state.clone(), cx); + welcome::init(cx); + Project::init_settings(cx); + app_state + }) + } + #[gpui::test] + async fn test_base_keymap(cx: &mut gpui::TestAppContext) { + let executor = cx.executor(); + let app_state = init_keymap_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + + actions!(test1, [A, B]); + // From the Atom keymap + use workspace::ActivatePreviousPane; + // From the JetBrains keymap + use workspace::ActivatePrevItem; + + app_state + .fs + .save( + "/settings.json".as_ref(), + &r#" + { + "base_keymap": "Atom" + } + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + app_state + .fs + .save( + "/keymap.json".as_ref(), + &r#" + [ + { + "bindings": { + "backspace": "test1::A" + } + } + ] + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + executor.run_until_parked(); + cx.update(|cx| { + let settings_rx = watch_config_file( + &executor, + app_state.fs.clone(), + PathBuf::from("/settings.json"), + ); + let keymap_rx = watch_config_file( + &executor, + app_state.fs.clone(), + PathBuf::from("/keymap.json"), + ); + handle_settings_file_changes(settings_rx, cx); + handle_keymap_file_changes(keymap_rx, cx); + }); + workspace + .update(cx, |workspace, _| { + workspace.register_action(|_, _: &A, _cx| {}); + workspace.register_action(|_, _: &B, _cx| {}); + workspace.register_action(|_, _: &ActivatePreviousPane, _cx| {}); + workspace.register_action(|_, _: &ActivatePrevItem, _cx| {}); + }) + .unwrap(); + executor.run_until_parked(); + // Test loading the keymap base at all + assert_key_bindings_for( + workspace.into(), + cx, + vec![("backspace", &A), ("k", &ActivatePreviousPane)], + line!(), + ); + + // Test modifying the users keymap, while retaining the base keymap + app_state + .fs + .save( + "/keymap.json".as_ref(), + &r#" + [ + { + "bindings": { + "backspace": "test1::B" + } + } + ] + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + executor.run_until_parked(); + + assert_key_bindings_for( + workspace.into(), + cx, + vec![("backspace", &B), ("k", &ActivatePreviousPane)], + line!(), + ); + + // Test modifying the base, while retaining the users keymap + app_state + .fs + .save( + "/settings.json".as_ref(), + &r#" + { + "base_keymap": "JetBrains" + } + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + executor.run_until_parked(); + + assert_key_bindings_for( + workspace.into(), + cx, + vec![("backspace", &B), ("[", &ActivatePrevItem)], + line!(), + ); + } + + #[gpui::test] + async fn test_disabled_keymap_binding(cx: &mut gpui::TestAppContext) { + let executor = cx.executor(); + let app_state = init_keymap_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + + actions!(test2, [A, B]); + // From the Atom keymap + use workspace::ActivatePreviousPane; + // From the JetBrains keymap + use pane::ActivatePrevItem; + workspace + .update(cx, |workspace, _| { + workspace + .register_action(|_, _: &A, _| {}) + .register_action(|_, _: &B, _| {}); + }) + .unwrap(); + app_state + .fs + .save( + "/settings.json".as_ref(), + &r#" + { + "base_keymap": "Atom" + } + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + app_state + .fs + .save( + "/keymap.json".as_ref(), + &r#" + [ + { + "bindings": { + "backspace": "test2::A" + } + } + ] + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + cx.update(|cx| { + let settings_rx = watch_config_file( + &executor, + app_state.fs.clone(), + PathBuf::from("/settings.json"), + ); + let keymap_rx = watch_config_file( + &executor, + app_state.fs.clone(), + PathBuf::from("/keymap.json"), + ); + + handle_settings_file_changes(settings_rx, cx); + handle_keymap_file_changes(keymap_rx, cx); + }); + + cx.background_executor.run_until_parked(); + + cx.background_executor.run_until_parked(); + // Test loading the keymap base at all + assert_key_bindings_for( + workspace.into(), + cx, + vec![("backspace", &A), ("k", &ActivatePreviousPane)], + line!(), + ); + + // Test disabling the key binding for the base keymap + app_state + .fs + .save( + "/keymap.json".as_ref(), + &r#" + [ + { + "bindings": { + "backspace": null + } + } + ] + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + cx.background_executor.run_until_parked(); + + assert_key_bindings_for( + workspace.into(), + cx, + vec![("k", &ActivatePreviousPane)], + line!(), + ); + + // Test modifying the base, while retaining the users keymap + app_state + .fs + .save( + "/settings.json".as_ref(), + &r#" + { + "base_keymap": "JetBrains" + } + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + cx.background_executor.run_until_parked(); + + assert_key_bindings_for( + workspace.into(), + cx, + vec![("[", &ActivatePrevItem)], + line!(), + ); + } + + #[gpui::test] + fn test_bundled_settings_and_themes(cx: &mut AppContext) { + cx.text_system() + .add_fonts(&[ + Assets + .load("fonts/zed-sans/zed-sans-extended.ttf") + .unwrap() + .to_vec() + .into(), + Assets + .load("fonts/zed-mono/zed-mono-extended.ttf") + .unwrap() + .to_vec() + .into(), + Assets + .load("fonts/plex/IBMPlexSans-Regular.ttf") + .unwrap() + .to_vec() + .into(), + ]) + .unwrap(); + let themes = ThemeRegistry::default(); + let mut settings = SettingsStore::default(); + settings + .set_default_settings(&settings::default_settings(), cx) + .unwrap(); + cx.set_global(settings); + theme::init(theme::LoadThemes::JustBase, cx); + + let mut has_default_theme = false; + for theme_name in themes.list(false).map(|meta| meta.name) { + let theme = themes.get(&theme_name).unwrap(); + assert_eq!(theme.name, theme_name); + if theme.name == ThemeSettings::get(None, cx).active_theme.name { + has_default_theme = true; + } + } + assert!(has_default_theme); + } + + #[gpui::test] + fn test_bundled_languages(cx: &mut AppContext) { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + let mut languages = LanguageRegistry::test(); + languages.set_executor(cx.background_executor().clone()); + let languages = Arc::new(languages); + let node_runtime = node_runtime::FakeNodeRuntime::new(); + languages::init(languages.clone(), node_runtime, cx); + for name in languages.language_names() { + languages.language_for_name(&name); + } + cx.background_executor().run_until_parked(); + } + + fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let mut app_state = AppState::test(cx); + + let state = Arc::get_mut(&mut app_state).unwrap(); + + state.build_window_options = build_window_options; + theme::init(theme::LoadThemes::JustBase, cx); + audio::init((), cx); + channel::init(&app_state.client, app_state.user_store.clone(), cx); + call::init(app_state.client.clone(), app_state.user_store.clone(), cx); + notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + language::init(cx); + editor::init(cx); + project_panel::init_settings(cx); + collab_ui::init(&app_state, cx); + project_panel::init((), cx); + terminal_view::init(cx); + assistant::init(cx); + initialize_workspace(app_state.clone(), cx); + app_state + }) + } + + fn rust_lang() -> Arc { + Arc::new(language::Language::new( + language::LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + )) + } + #[track_caller] + fn assert_key_bindings_for<'a>( + window: AnyWindowHandle, + cx: &TestAppContext, + actions: Vec<(&'static str, &'a dyn Action)>, + line: u32, + ) { + let available_actions = cx + .update(|cx| window.update(cx, |_, cx| cx.available_actions())) + .unwrap(); + for (key, action) in actions { + let bindings = cx + .update(|cx| window.update(cx, |_, cx| cx.bindings_for_action(action))) + .unwrap(); + // assert that... + assert!( + available_actions.iter().any(|bound_action| { + // actions match... + bound_action.partial_eq(action) + }), + "On {} Failed to find {}", + line, + action.name(), + ); + assert!( + // and key strokes contain the given key + bindings + .into_iter() + .any(|binding| binding.keystrokes().iter().any(|k| k.key == key)), + "On {} Failed to find {} with key binding {}", + line, + action.name(), + key + ); + } + } +}