Merge branch 'main' into randomized-tests-operation-script

This commit is contained in:
Max Brunsfeld 2023-04-03 13:09:25 -07:00
commit c960277349
483 changed files with 32884 additions and 10315 deletions

View file

@ -33,6 +33,19 @@ struct LspStatus {
status: LanguageServerBinaryStatus,
}
struct PendingWork<'a> {
language_server_name: &'a str,
progress_token: &'a str,
progress: &'a LanguageServerProgress,
}
#[derive(Default)]
struct Content {
icon: Option<&'static str>,
message: String,
action: Option<Box<dyn Action>>,
}
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(ActivityIndicator::show_error_message);
cx.add_action(ActivityIndicator::dismiss_error_message);
@ -69,6 +82,8 @@ impl ActivityIndicator {
if let Some(auto_updater) = auto_updater.as_ref() {
cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
}
cx.observe_active_labeled_tasks(|_, cx| cx.notify())
.detach();
Self {
statuses: Default::default(),
@ -130,7 +145,7 @@ impl ActivityIndicator {
fn pending_language_server_work<'a>(
&self,
cx: &'a AppContext,
) -> impl Iterator<Item = (&'a str, &'a str, &'a LanguageServerProgress)> {
) -> impl Iterator<Item = PendingWork<'a>> {
self.project
.read(cx)
.language_server_statuses()
@ -142,23 +157,29 @@ impl ActivityIndicator {
let mut pending_work = status
.pending_work
.iter()
.map(|(token, progress)| (status.name.as_str(), token.as_str(), progress))
.map(|(token, progress)| PendingWork {
language_server_name: status.name.as_str(),
progress_token: token.as_str(),
progress,
})
.collect::<SmallVec<[_; 4]>>();
pending_work.sort_by_key(|(_, _, progress)| Reverse(progress.last_update_at));
pending_work.sort_by_key(|work| Reverse(work.progress.last_update_at));
Some(pending_work)
}
})
.flatten()
}
fn content_to_render(
&mut self,
cx: &mut RenderContext<Self>,
) -> (Option<&'static str>, String, Option<Box<dyn Action>>) {
fn content_to_render(&mut self, cx: &mut RenderContext<Self>) -> Content {
// Show any language server has pending activity.
let mut pending_work = self.pending_language_server_work(cx);
if let Some((lang_server_name, progress_token, progress)) = pending_work.next() {
let mut message = lang_server_name.to_string();
if let Some(PendingWork {
language_server_name,
progress_token,
progress,
}) = pending_work.next()
{
let mut message = language_server_name.to_string();
message.push_str(": ");
if let Some(progress_message) = progress.message.as_ref() {
@ -176,7 +197,11 @@ impl ActivityIndicator {
write!(&mut message, " + {} more", additional_work_count).unwrap();
}
return (None, message, None);
return Content {
icon: None,
message,
action: None,
};
}
// Show any language server installation info.
@ -199,19 +224,19 @@ impl ActivityIndicator {
}
if !downloading.is_empty() {
return (
Some(DOWNLOAD_ICON),
format!(
return Content {
icon: Some(DOWNLOAD_ICON),
message: format!(
"Downloading {} language server{}...",
downloading.join(", "),
if downloading.len() > 1 { "s" } else { "" }
),
None,
);
action: None,
};
} else if !checking_for_update.is_empty() {
return (
Some(DOWNLOAD_ICON),
format!(
return Content {
icon: Some(DOWNLOAD_ICON),
message: format!(
"Checking for updates to {} language server{}...",
checking_for_update.join(", "),
if checking_for_update.len() > 1 {
@ -220,53 +245,61 @@ impl ActivityIndicator {
""
}
),
None,
);
action: None,
};
} else if !failed.is_empty() {
return (
Some(WARNING_ICON),
format!(
return Content {
icon: Some(WARNING_ICON),
message: format!(
"Failed to download {} language server{}. Click to show error.",
failed.join(", "),
if failed.len() > 1 { "s" } else { "" }
),
Some(Box::new(ShowErrorMessage)),
);
action: Some(Box::new(ShowErrorMessage)),
};
}
// Show any application auto-update info.
if let Some(updater) = &self.auto_updater {
match &updater.read(cx).status() {
AutoUpdateStatus::Checking => (
Some(DOWNLOAD_ICON),
"Checking for Zed updates…".to_string(),
None,
),
AutoUpdateStatus::Downloading => (
Some(DOWNLOAD_ICON),
"Downloading Zed update…".to_string(),
None,
),
AutoUpdateStatus::Installing => (
Some(DOWNLOAD_ICON),
"Installing Zed update…".to_string(),
None,
),
AutoUpdateStatus::Updated => (
None,
"Click to restart and update Zed".to_string(),
Some(Box::new(workspace::Restart)),
),
AutoUpdateStatus::Errored => (
Some(WARNING_ICON),
"Auto update failed".to_string(),
Some(Box::new(DismissErrorMessage)),
),
return match &updater.read(cx).status() {
AutoUpdateStatus::Checking => Content {
icon: Some(DOWNLOAD_ICON),
message: "Checking for Zed updates…".to_string(),
action: None,
},
AutoUpdateStatus::Downloading => Content {
icon: Some(DOWNLOAD_ICON),
message: "Downloading Zed update…".to_string(),
action: None,
},
AutoUpdateStatus::Installing => Content {
icon: Some(DOWNLOAD_ICON),
message: "Installing Zed update…".to_string(),
action: None,
},
AutoUpdateStatus::Updated => Content {
icon: None,
message: "Click to restart and update Zed".to_string(),
action: Some(Box::new(workspace::Restart)),
},
AutoUpdateStatus::Errored => Content {
icon: Some(WARNING_ICON),
message: "Auto update failed".to_string(),
action: Some(Box::new(DismissErrorMessage)),
},
AutoUpdateStatus::Idle => Default::default(),
}
} else {
Default::default()
};
}
if let Some(most_recent_active_task) = cx.active_labeled_tasks().last() {
return Content {
icon: None,
message: most_recent_active_task.to_string(),
action: None,
};
}
Default::default()
}
}
@ -280,7 +313,11 @@ impl View for ActivityIndicator {
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
let (icon, message, action) = self.content_to_render(cx);
let Content {
icon,
message,
action,
} = self.content_to_render(cx);
let mut element = MouseEventHandler::<Self>::new(0, cx, |state, cx| {
let theme = &cx

View file

@ -22,7 +22,8 @@ anyhow = "1.0.38"
isahc = "1.7"
lazy_static = "1.4"
log = "0.4"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
smol = "1.2.5"
tempdir = "0.3.7"

View file

@ -1,8 +1,7 @@
mod update_notification;
use anyhow::{anyhow, Context, Result};
use client::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN};
use client::{ZED_APP_PATH, ZED_APP_VERSION};
use client::{ZED_APP_PATH, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
use db::kvp::KEY_VALUE_STORE;
use gpui::{
actions, platform::AppVersion, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
@ -14,6 +13,7 @@ use smol::{fs::File, io::AsyncReadExt, process::Command};
use std::{ffi::OsString, sync::Arc, time::Duration};
use update_notification::UpdateNotification;
use util::channel::ReleaseChannel;
use util::http::HttpClient;
use workspace::Workspace;
const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification";

View file

@ -78,7 +78,7 @@ impl View for UpdateNotification {
)
.with_child({
let style = theme.action_message.style_for(state, false);
Text::new("View the release notes".to_string(), style.text.clone())
Text::new("View the release notes", style.text.clone())
.contained()
.with_style(style.container)
.boxed()

View file

@ -18,6 +18,7 @@ search = { path = "../search" }
settings = { path = "../settings" }
theme = { path = "../theme" }
workspace = { path = "../workspace" }
outline = { path = "../outline" }
itertools = "0.10"
[dev-dependencies]

View file

@ -1,5 +1,6 @@
use gpui::{
elements::*, AppContext, Entity, RenderContext, Subscription, View, ViewContext, ViewHandle,
elements::*, AppContext, Entity, MouseButton, RenderContext, Subscription, View, ViewContext,
ViewHandle,
};
use itertools::Itertools;
use search::ProjectSearchView;
@ -14,6 +15,7 @@ pub enum Event {
}
pub struct Breadcrumbs {
pane_focused: bool,
active_item: Option<Box<dyn ItemHandle>>,
project_search: Option<ViewHandle<ProjectSearchView>>,
subscription: Option<Subscription>,
@ -22,6 +24,7 @@ pub struct Breadcrumbs {
impl Breadcrumbs {
pub fn new() -> Self {
Self {
pane_focused: false,
active_item: Default::default(),
subscription: Default::default(),
project_search: Default::default(),
@ -39,24 +42,53 @@ impl View for Breadcrumbs {
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
let active_item = match &self.active_item {
Some(active_item) => active_item,
None => return Empty::new().boxed(),
};
let not_editor = active_item.downcast::<editor::Editor>().is_none();
let theme = cx.global::<Settings>().theme.clone();
if let Some(breadcrumbs) = self
.active_item
.as_ref()
.and_then(|item| item.breadcrumbs(&theme, cx))
{
Flex::row()
.with_children(Itertools::intersperse_with(breadcrumbs.into_iter(), || {
Label::new("".to_string(), theme.breadcrumbs.text.clone()).boxed()
}))
.contained()
.with_style(theme.breadcrumbs.container)
let style = &theme.workspace.breadcrumbs;
let breadcrumbs = match active_item.breadcrumbs(&theme, cx) {
Some(breadcrumbs) => breadcrumbs,
None => return Empty::new().boxed(),
};
let crumbs = Flex::row()
.with_children(Itertools::intersperse_with(breadcrumbs.into_iter(), || {
Label::new("", style.default.text.clone()).boxed()
}))
.constrained()
.with_height(theme.workspace.breadcrumb_height)
.contained();
if not_editor || !self.pane_focused {
return crumbs
.with_style(style.default.container)
.aligned()
.left()
.boxed()
} else {
Empty::new().boxed()
.boxed();
}
MouseEventHandler::<Breadcrumbs>::new(0, cx, |state, _| {
let style = style.style_for(state, false);
crumbs.with_style(style.container).boxed()
})
.on_click(MouseButton::Left, |_, cx| {
cx.dispatch_action(outline::Toggle);
})
.with_tooltip::<Breadcrumbs, _>(
0,
"Show symbol outline".to_owned(),
Some(Box::new(outline::Toggle)),
theme.tooltip.clone(),
cx,
)
.aligned()
.left()
.boxed()
}
}
@ -103,4 +135,8 @@ impl ToolbarItemView for Breadcrumbs {
current_location
}
}
fn pane_focus_update(&mut self, pane_focused: bool, _: &mut gpui::MutableAppContext) {
self.pane_focused = pane_focused;
}
}

View file

@ -34,7 +34,7 @@ util = { path = "../util" }
anyhow = "1.0.38"
async-broadcast = "0.4"
futures = "0.3"
postage = { version = "0.4.1", features = ["futures-traits"] }
postage = { workspace = true }
[dev-dependencies]
client = { path = "../client", features = ["test-support"] }

View file

@ -264,12 +264,13 @@ impl ActiveCall {
Ok(())
}
pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
cx.notify();
if let Some((room, _)) = self.room.take() {
room.update(cx, |room, cx| room.leave(cx))?;
cx.notify();
room.update(cx, |room, cx| room.leave(cx))
} else {
Task::ready(Ok(()))
}
Ok(())
}
pub fn share_project(
@ -284,6 +285,18 @@ impl ActiveCall {
}
}
pub fn unshare_project(
&mut self,
project: ModelHandle<Project>,
cx: &mut ModelContext<Self>,
) -> Result<()> {
if let Some((room, _)) = self.room.as_ref() {
room.update(cx, |room, cx| room.unshare_project(project, cx))
} else {
Err(anyhow!("no active call"))
}
}
pub fn set_location(
&mut self,
project: Option<&ModelHandle<Project>>,

View file

@ -17,10 +17,10 @@ use language::LanguageRegistry;
use live_kit_client::{LocalTrackPublication, LocalVideoTrack, RemoteVideoTrackUpdate};
use postage::stream::Stream;
use project::Project;
use std::{mem, sync::Arc, time::Duration};
use std::{future::Future, mem, pin::Pin, sync::Arc, time::Duration};
use util::{post_inc, ResultExt, TryFutureExt};
pub const RECONNECT_TIMEOUT: Duration = client::RECEIVE_TIMEOUT;
pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event {
@ -55,6 +55,7 @@ pub struct Room {
leave_when_empty: bool,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
follows_by_leader_id_project_id: HashMap<(PeerId, u64), Vec<PeerId>>,
subscriptions: Vec<client::Subscription>,
pending_room_update: Option<Task<()>>,
maintain_connection: Option<Task<Option<()>>>,
@ -63,10 +64,27 @@ pub struct Room {
impl Entity for Room {
type Event = Event;
fn release(&mut self, _: &mut MutableAppContext) {
fn release(&mut self, cx: &mut MutableAppContext) {
if self.status.is_online() {
log::info!("room was released, sending leave message");
let _ = self.client.send(proto::LeaveRoom {});
self.leave_internal(cx).detach_and_log_err(cx);
}
}
fn app_will_quit(
&mut self,
cx: &mut MutableAppContext,
) -> Option<Pin<Box<dyn Future<Output = ()>>>> {
if self.status.is_online() {
let leave = self.leave_internal(cx);
Some(
cx.background()
.spawn(async move {
leave.await.log_err();
})
.boxed(),
)
} else {
None
}
}
}
@ -148,6 +166,7 @@ impl Room {
pending_room_update: None,
client,
user_store,
follows_by_leader_id_project_id: Default::default(),
maintain_connection: Some(maintain_connection),
}
}
@ -232,13 +251,17 @@ impl Room {
&& self.pending_call_count == 0
}
pub(crate) fn leave(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
if self.status.is_offline() {
return Err(anyhow!("room is offline"));
}
pub(crate) fn leave(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
cx.notify();
cx.emit(Event::Left);
self.leave_internal(cx)
}
fn leave_internal(&mut self, cx: &mut MutableAppContext) -> Task<Result<()>> {
if self.status.is_offline() {
return Task::ready(Err(anyhow!("room is offline")));
}
log::info!("leaving room");
for project in self.shared_projects.drain() {
@ -252,6 +275,7 @@ impl Room {
if let Some(project) = project.upgrade(cx) {
project.update(cx, |project, cx| {
project.disconnected_from_host(cx);
project.close(cx);
});
}
}
@ -264,8 +288,12 @@ impl Room {
self.live_kit.take();
self.pending_room_update.take();
self.maintain_connection.take();
self.client.send(proto::LeaveRoom {})?;
Ok(())
let leave_room = self.client.request(proto::LeaveRoom {});
cx.background().spawn(async move {
leave_room.await?;
anyhow::Ok(())
})
}
async fn maintain_connection(
@ -275,14 +303,12 @@ impl Room {
) -> Result<()> {
let mut client_status = client.status();
loop {
let is_connected = client_status
.next()
.await
.map_or(false, |s| s.is_connected());
let _ = client_status.try_recv();
let is_connected = client_status.borrow().is_connected();
// Even if we're initially connected, any future change of the status means we momentarily disconnected.
if !is_connected || client_status.next().await.is_some() {
log::info!("detected client disconnection");
this.upgrade(&cx)
.ok_or_else(|| anyhow!("room was dropped"))?
.update(&mut cx, |this, cx| {
@ -296,12 +322,7 @@ impl Room {
let client_reconnection = async {
let mut remaining_attempts = 3;
while remaining_attempts > 0 {
log::info!(
"waiting for client status change, remaining attempts {}",
remaining_attempts
);
let Some(status) = client_status.next().await else { break };
if status.is_connected() {
if client_status.borrow().is_connected() {
log::info!("client reconnected, attempting to rejoin room");
let Some(this) = this.upgrade(&cx) else { break };
@ -315,7 +336,15 @@ impl Room {
} else {
remaining_attempts -= 1;
}
} else if client_status.borrow().is_signed_out() {
return false;
}
log::info!(
"waiting for client status change, remaining attempts {}",
remaining_attempts
);
client_status.next().await;
}
false
}
@ -337,18 +366,20 @@ impl Room {
}
}
// The client failed to re-establish a connection to the server
// or an error occurred while trying to re-join the room. Either way
// we leave the room and return an error.
if let Some(this) = this.upgrade(&cx) {
log::info!("reconnection failed, leaving room");
let _ = this.update(&mut cx, |this, cx| this.leave(cx));
}
return Err(anyhow!(
"can't reconnect to room: client failed to re-establish connection"
));
break;
}
}
// The client failed to re-establish a connection to the server
// or an error occurred while trying to re-join the room. Either way
// we leave the room and return an error.
if let Some(this) = this.upgrade(&cx) {
log::info!("reconnection failed, leaving room");
let _ = this.update(&mut cx, |this, cx| this.leave(cx));
}
Err(anyhow!(
"can't reconnect to room: client failed to re-establish connection"
))
}
fn rejoin(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
@ -457,6 +488,12 @@ impl Room {
self.participant_user_ids.contains(&user_id)
}
pub fn followers_for(&self, leader_id: PeerId, project_id: u64) -> &[PeerId] {
self.follows_by_leader_id_project_id
.get(&(leader_id, project_id))
.map_or(&[], |v| v.as_slice())
}
async fn handle_room_updated(
this: ModelHandle<Self>,
envelope: TypedEnvelope<proto::RoomUpdated>,
@ -487,11 +524,13 @@ impl Room {
.iter()
.map(|p| p.user_id)
.collect::<Vec<_>>();
let remote_participant_user_ids = room
.participants
.iter()
.map(|p| p.user_id)
.collect::<Vec<_>>();
let (remote_participants, pending_participants) =
self.user_store.update(cx, move |user_store, cx| {
(
@ -499,6 +538,7 @@ impl Room {
user_store.get_users(pending_participant_user_ids, cx),
)
});
self.pending_room_update = Some(cx.spawn(|this, mut cx| async move {
let (remote_participants, pending_participants) =
futures::join!(remote_participants, pending_participants);
@ -587,7 +627,7 @@ impl Room {
if let Some(live_kit) = this.live_kit.as_ref() {
let tracks =
live_kit.room.remote_video_tracks(&peer_id.to_string());
live_kit.room.remote_video_tracks(&user.id.to_string());
for track in tracks {
this.remote_video_track_updated(
RemoteVideoTrackUpdate::Subscribed(track),
@ -620,6 +660,27 @@ impl Room {
}
}
this.follows_by_leader_id_project_id.clear();
for follower in room.followers {
let project_id = follower.project_id;
let (leader, follower) = match (follower.leader_id, follower.follower_id) {
(Some(leader), Some(follower)) => (leader, follower),
_ => {
log::error!("Follower message {follower:?} missing some state");
continue;
}
};
let list = this
.follows_by_leader_id_project_id
.entry((leader, project_id))
.or_insert(Vec::new());
if !list.contains(&follower) {
list.push(follower);
}
}
this.pending_room_update.take();
if this.should_leave() {
log::info!("room is empty, leaving");
@ -723,10 +784,10 @@ impl Room {
this.update(&mut cx, |this, cx| {
this.pending_call_count -= 1;
if this.should_leave() {
this.leave(cx)?;
this.leave(cx).detach_and_log_err(cx);
}
result
})?;
});
result?;
Ok(())
})
}
@ -793,6 +854,20 @@ impl Room {
})
}
pub(crate) fn unshare_project(
&mut self,
project: ModelHandle<Project>,
cx: &mut ModelContext<Self>,
) -> Result<()> {
let project_id = match project.read(cx).remote_id() {
Some(project_id) => project_id,
None => return Ok(()),
};
self.client.send(proto::UnshareProject { project_id })?;
project.update(cx, |this, cx| this.unshare(cx))
}
pub(crate) fn set_location(
&mut self,
project: Option<&ModelHandle<Project>>,

View file

@ -17,7 +17,8 @@ anyhow = "1.0"
clap = { version = "3.1", features = ["derive"] }
dirs = "3.0"
ipc-channel = "0.16"
serde = { version = "1.0", features = ["derive", "rc"] }
serde = { workspace = true }
serde_derive = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.9"

View file

@ -23,11 +23,10 @@ async-recursion = "0.3"
async-tungstenite = { version = "0.16", features = ["async-tls"] }
futures = "0.3"
image = "0.23"
isahc = "1.7"
lazy_static = "1.4.0"
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
parking_lot = "0.11.1"
postage = { version = "0.4.1", features = ["futures-traits"] }
postage = { workspace = true }
rand = "0.8.3"
smol = "1.2.5"
thiserror = "1.0.29"
@ -35,7 +34,8 @@ time = { version = "0.3", features = ["serde", "serde-well-known"] }
tiny_http = "0.8"
uuid = { version = "1.1.2", features = ["v4"] }
url = "2.2"
serde = { version = "*", features = ["derive"] }
serde = { workspace = true }
serde_derive = { workspace = true }
settings = { path = "../settings" }
tempfile = "3"

View file

@ -1,7 +1,6 @@
#[cfg(any(test, feature = "test-support"))]
pub mod test;
pub mod http;
pub mod telemetry;
pub mod user;
@ -18,7 +17,6 @@ use gpui::{
AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AppContext, AppVersion,
AsyncAppContext, Entity, ModelHandle, MutableAppContext, Task, View, ViewContext, ViewHandle,
};
use http::HttpClient;
use lazy_static::lazy_static;
use parking_lot::RwLock;
use postage::watch;
@ -41,6 +39,7 @@ use telemetry::Telemetry;
use thiserror::Error;
use url::Url;
use util::channel::ReleaseChannel;
use util::http::HttpClient;
use util::{ResultExt, TryFutureExt};
pub use rpc::*;
@ -66,12 +65,12 @@ pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100);
pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
actions!(client, [Authenticate]);
actions!(client, [SignIn, SignOut]);
pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
cx.add_global_action({
let client = client.clone();
move |_: &Authenticate, cx| {
move |_: &SignIn, cx| {
let client = client.clone();
cx.spawn(
|cx| async move { client.authenticate_and_connect(true, &cx).log_err().await },
@ -79,6 +78,16 @@ pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
.detach();
}
});
cx.add_global_action({
let client = client.clone();
move |_: &SignOut, cx| {
let client = client.clone();
cx.spawn(|cx| async move {
client.disconnect(&cx);
})
.detach();
}
});
}
pub struct Client {
@ -120,7 +129,7 @@ pub enum EstablishConnectionError {
#[error("{0}")]
Other(#[from] anyhow::Error),
#[error("{0}")]
Http(#[from] http::Error),
Http(#[from] util::http::Error),
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
@ -169,6 +178,10 @@ impl Status {
pub fn is_connected(&self) -> bool {
matches!(self, Self::Connected { .. })
}
pub fn is_signed_out(&self) -> bool {
matches!(self, Self::SignedOut | Self::UpgradeRequired)
}
}
struct ClientState {
@ -280,7 +293,7 @@ impl<T: Entity> PendingEntitySubscription<T> {
state
.entities_by_type_and_remote_id
.insert(id, WeakSubscriber::Model(model.downgrade().into()));
.insert(id, WeakSubscriber::Model(model.downgrade().into_any()));
drop(state);
for message in messages {
self.client.handle_message(message, cx);
@ -447,7 +460,7 @@ impl Client {
self.state
.write()
.entities_by_type_and_remote_id
.insert(id, WeakSubscriber::View(cx.weak_handle().into()));
.insert(id, WeakSubscriber::View(cx.weak_handle().into_any()));
Subscription::Entity {
client: Arc::downgrade(self),
id,
@ -491,7 +504,7 @@ impl Client {
let mut state = self.state.write();
state
.models_by_message_type
.insert(message_type_id, model.downgrade().into());
.insert(message_type_id, model.downgrade().into_any());
let prev_handler = state.message_handlers.insert(
message_type_id,
@ -1152,11 +1165,9 @@ impl Client {
})
}
pub fn disconnect(self: &Arc<Self>, cx: &AsyncAppContext) -> Result<()> {
let conn_id = self.connection_id()?;
self.peer.disconnect(conn_id);
pub fn disconnect(self: &Arc<Self>, cx: &AsyncAppContext) {
self.peer.teardown();
self.set_status(Status::SignedOut, cx);
Ok(())
}
fn connection_id(&self) -> Result<ConnectionId> {
@ -1384,10 +1395,11 @@ pub fn decode_worktree_url(url: &str) -> Option<(u64, String)> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test::{FakeHttpClient, FakeServer};
use crate::test::FakeServer;
use gpui::{executor::Deterministic, TestAppContext};
use parking_lot::Mutex;
use std::future;
use util::http::FakeHttpClient;
#[gpui::test(iterations = 10)]
async fn test_reconnection(cx: &mut TestAppContext) {

View file

@ -1,57 +0,0 @@
pub use anyhow::{anyhow, Result};
use futures::future::BoxFuture;
use isahc::{
config::{Configurable, RedirectPolicy},
AsyncBody,
};
pub use isahc::{
http::{Method, Uri},
Error,
};
use smol::future::FutureExt;
use std::{sync::Arc, time::Duration};
pub use url::Url;
pub type Request = isahc::Request<AsyncBody>;
pub type Response = isahc::Response<AsyncBody>;
pub trait HttpClient: Send + Sync {
fn send(&self, req: Request) -> BoxFuture<Result<Response, Error>>;
fn get<'a>(
&'a self,
uri: &str,
body: AsyncBody,
follow_redirects: bool,
) -> BoxFuture<'a, Result<Response, Error>> {
let request = isahc::Request::builder()
.redirect_policy(if follow_redirects {
RedirectPolicy::Follow
} else {
RedirectPolicy::None
})
.method(Method::GET)
.uri(uri)
.body(body);
match request {
Ok(request) => self.send(request),
Err(error) => async move { Err(error.into()) }.boxed(),
}
}
}
pub fn client() -> Arc<dyn HttpClient> {
Arc::new(
isahc::HttpClient::builder()
.connect_timeout(Duration::from_secs(5))
.low_speed_timeout(100, Duration::from_secs(5))
.build()
.unwrap(),
)
}
impl HttpClient for isahc::HttpClient {
fn send(&self, req: Request) -> BoxFuture<Result<Response, Error>> {
Box::pin(async move { self.send_async(req).await })
}
}

View file

@ -1,11 +1,9 @@
use crate::http::HttpClient;
use db::kvp::KEY_VALUE_STORE;
use gpui::{
executor::Background,
serde_json::{self, value::Map, Value},
AppContext, Task,
};
use isahc::Request;
use lazy_static::lazy_static;
use parking_lot::Mutex;
use serde::Serialize;
@ -19,6 +17,7 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tempfile::NamedTempFile;
use util::http::HttpClient;
use util::{channel::ReleaseChannel, post_inc, ResultExt, TryFutureExt};
use uuid::Uuid;
@ -220,11 +219,11 @@ impl Telemetry {
"App": true
}),
}])?;
let request = Request::post(MIXPANEL_ENGAGE_URL)
.header("Content-Type", "application/json")
.body(json_bytes.into())?;
this.http_client.send(request).await?;
Ok(())
this.http_client
.post_json(MIXPANEL_ENGAGE_URL, json_bytes.into())
.await?;
anyhow::Ok(())
}
.log_err(),
)
@ -316,11 +315,10 @@ impl Telemetry {
json_bytes.clear();
serde_json::to_writer(&mut json_bytes, &events)?;
let request = Request::post(MIXPANEL_EVENTS_URL)
.header("Content-Type", "application/json")
.body(json_bytes.into())?;
this.http_client.send(request).await?;
Ok(())
this.http_client
.post_json(MIXPANEL_EVENTS_URL, json_bytes.into())
.await?;
anyhow::Ok(())
}
.log_err(),
)

View file

@ -1,16 +1,14 @@
use crate::{
http::{self, HttpClient, Request, Response},
Client, Connection, Credentials, EstablishConnectionError, UserStore,
};
use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore};
use anyhow::{anyhow, Result};
use futures::{future::BoxFuture, stream::BoxStream, Future, StreamExt};
use futures::{stream::BoxStream, StreamExt};
use gpui::{executor, ModelHandle, TestAppContext};
use parking_lot::Mutex;
use rpc::{
proto::{self, GetPrivateUserInfo, GetPrivateUserInfoResponse},
ConnectionId, Peer, Receipt, TypedEnvelope,
};
use std::{fmt, rc::Rc, sync::Arc};
use std::{rc::Rc, sync::Arc};
use util::http::FakeHttpClient;
pub struct FakeServer {
peer: Arc<Peer>,
@ -219,46 +217,3 @@ impl Drop for FakeServer {
self.disconnect();
}
}
pub struct FakeHttpClient {
handler: Box<
dyn 'static
+ Send
+ Sync
+ Fn(Request) -> BoxFuture<'static, Result<Response, http::Error>>,
>,
}
impl FakeHttpClient {
pub fn create<Fut, F>(handler: F) -> Arc<dyn HttpClient>
where
Fut: 'static + Send + Future<Output = Result<Response, http::Error>>,
F: 'static + Send + Sync + Fn(Request) -> Fut,
{
Arc::new(Self {
handler: Box::new(move |req| Box::pin(handler(req))),
})
}
pub fn with_404_response() -> Arc<dyn HttpClient> {
Self::create(|_| async move {
Ok(isahc::Response::builder()
.status(404)
.body(Default::default())
.unwrap())
})
}
}
impl fmt::Debug for FakeHttpClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FakeHttpClient").finish()
}
}
impl HttpClient for FakeHttpClient {
fn send(&self, req: Request) -> BoxFuture<Result<Response, crate::http::Error>> {
let future = (self.handler)(req);
Box::pin(async move { future.await.map(Into::into) })
}
}

View file

@ -1,4 +1,4 @@
use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
use super::{proto, Client, Status, TypedEnvelope};
use anyhow::{anyhow, Context, Result};
use collections::{hash_map::Entry, HashMap, HashSet};
use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
@ -7,6 +7,7 @@ use postage::{sink::Sink, watch};
use rpc::proto::{RequestMessage, UsersResponse};
use settings::Settings;
use std::sync::{Arc, Weak};
use util::http::HttpClient;
use util::{StaffMode, TryFutureExt as _};
#[derive(Default, Debug)]
@ -183,6 +184,11 @@ impl UserStore {
}
}
#[cfg(feature = "test-support")]
pub fn clear_cache(&mut self) {
self.users.clear();
}
async fn handle_update_invite_info(
this: ModelHandle<Self>,
message: TypedEnvelope<proto::UpdateInviteInfo>,

View file

@ -1,4 +1,5 @@
DATABASE_URL = "postgres://postgres@localhost/zed"
DATABASE_MAX_CONNECTIONS = 5
HTTP_PORT = 8080
API_TOKEN = "secret"
INVITE_LINK_PREFIX = "http://localhost:3000/invites/"

View file

@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab"
edition = "2021"
name = "collab"
version = "0.5.4"
version = "0.8.2"
publish = false
[[bin]]
@ -31,6 +31,7 @@ futures = "0.3"
hyper = "0.14"
lazy_static = "1.4"
lipsum = { version = "0.8", optional = true }
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
nanoid = "0.4"
parking_lot = "0.11.1"
prometheus = "0.13"
@ -40,8 +41,9 @@ scrypt = "0.7"
# Remove fork dependency when a version with https://github.com/SeaQL/sea-orm/pull/1283 is released.
sea-orm = { git = "https://github.com/zed-industries/sea-orm", rev = "18f4c691085712ad014a51792af75a9044bacee6", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls"] }
sea-query = "0.27"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
sha-1 = "0.9"
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "json", "time", "uuid", "any"] }
time = { version = "0.3", features = ["serde", "serde-well-known"] }
@ -74,11 +76,10 @@ workspace = { path = "../workspace", features = ["test-support"] }
ctor = "0.1"
env_logger = "0.9"
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
util = { path = "../util" }
lazy_static = "1.4"
sea-orm = { git = "https://github.com/zed-industries/sea-orm", rev = "18f4c691085712ad014a51792af75a9044bacee6", features = ["sqlx-sqlite"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_json = { workspace = true }
sqlx = { version = "0.6", features = ["sqlite"] }
unindent = "0.1"

View file

@ -1,3 +1,4 @@
ZED_ENVIRONMENT=preview
RUST_LOG=info
INVITE_LINK_PREFIX=https://zed.dev/invites/
DATABASE_MAX_CONNECTIONS=10

View file

@ -1,3 +1,4 @@
ZED_ENVIRONMENT=production
RUST_LOG=info
INVITE_LINK_PREFIX=https://zed.dev/invites/
DATABASE_MAX_CONNECTIONS=85

View file

@ -1,3 +1,4 @@
ZED_ENVIRONMENT=staging
RUST_LOG=info
INVITE_LINK_PREFIX=https://staging.zed.dev/invites/
DATABASE_MAX_CONNECTIONS=5

View file

@ -59,6 +59,13 @@ spec:
ports:
- containerPort: 8080
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /
@ -73,6 +80,8 @@ spec:
secretKeyRef:
name: database
key: url
- name: DATABASE_MAX_CONNECTIONS
value: "${DATABASE_MAX_CONNECTIONS}"
- name: API_TOKEN
valueFrom:
secretKeyRef:

View file

@ -143,3 +143,17 @@ CREATE TABLE "servers" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"environment" VARCHAR NOT NULL
);
CREATE TABLE "followers" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE,
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"leader_connection_id" INTEGER NOT NULL,
"follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"follower_connection_id" INTEGER NOT NULL
);
CREATE UNIQUE INDEX
"index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id"
ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id");
CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");

View file

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS "followers" (
"id" SERIAL PRIMARY KEY,
"room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE,
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"leader_connection_id" INTEGER NOT NULL,
"follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"follower_connection_id" INTEGER NOT NULL
);
CREATE UNIQUE INDEX
"index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id"
ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id");
CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");

View file

@ -78,6 +78,7 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
struct AuthenticatedUserParams {
github_user_id: Option<i32>,
github_login: String,
github_email: Option<String>,
}
#[derive(Debug, Serialize)]
@ -92,7 +93,11 @@ async fn get_authenticated_user(
) -> Result<Json<AuthenticatedUserResponse>> {
let user = app
.db
.get_user_by_github_account(&params.github_login, params.github_user_id)
.get_or_create_user_by_github_account(
&params.github_login,
params.github_user_id,
params.github_email.as_deref(),
)
.await?
.ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "user not found".into()))?;
let metrics_id = app.db.get_user_metrics_id(user.id).await?;
@ -297,11 +302,7 @@ async fn create_access_token(
let mut user_id = user.id;
if let Some(impersonate) = params.impersonate {
if user.admin {
if let Some(impersonated_user) = app
.db
.get_user_by_github_account(&impersonate, None)
.await?
{
if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? {
user_id = impersonated_user.id;
} else {
return Err(Error::Http(

View file

@ -1,5 +1,5 @@
use crate::{
db::{self, UserId},
db::{self, AccessTokenId, Database, UserId},
AppState, Error, Result,
};
use anyhow::{anyhow, Context};
@ -8,12 +8,24 @@ use axum::{
middleware::Next,
response::IntoResponse,
};
use lazy_static::lazy_static;
use prometheus::{exponential_buckets, register_histogram, Histogram};
use rand::thread_rng;
use scrypt::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Scrypt,
};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::Instant};
lazy_static! {
static ref METRIC_ACCESS_TOKEN_HASHING_TIME: Histogram = register_histogram!(
"access_token_hashing_time",
"time spent hashing access tokens",
exponential_buckets(10.0, 2.0, 10).unwrap(),
)
.unwrap();
}
pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse {
let mut auth_header = req
@ -42,20 +54,14 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
)
})?;
let mut credentials_valid = false;
let state = req.extensions().get::<Arc<AppState>>().unwrap();
if let Some(admin_token) = access_token.strip_prefix("ADMIN_TOKEN:") {
if state.config.api_token == admin_token {
credentials_valid = true;
}
let credentials_valid = if let Some(admin_token) = access_token.strip_prefix("ADMIN_TOKEN:") {
state.config.api_token == admin_token
} else {
for password_hash in state.db.get_access_token_hashes(user_id).await? {
if verify_access_token(access_token, &password_hash)? {
credentials_valid = true;
break;
}
}
}
verify_access_token(&access_token, user_id, &state.db)
.await
.unwrap_or(false)
};
if credentials_valid {
let user = state
@ -75,13 +81,26 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
const MAX_ACCESS_TOKENS_TO_STORE: usize = 8;
#[derive(Serialize, Deserialize)]
struct AccessTokenJson {
version: usize,
id: AccessTokenId,
token: String,
}
pub async fn create_access_token(db: &db::Database, user_id: UserId) -> Result<String> {
const VERSION: usize = 1;
let access_token = rpc::auth::random_token();
let access_token_hash =
hash_access_token(&access_token).context("failed to hash access token")?;
db.create_access_token_hash(user_id, &access_token_hash, MAX_ACCESS_TOKENS_TO_STORE)
let id = db
.create_access_token(user_id, &access_token_hash, MAX_ACCESS_TOKENS_TO_STORE)
.await?;
Ok(access_token)
Ok(serde_json::to_string(&AccessTokenJson {
version: VERSION,
id,
token: access_token,
})?)
}
fn hash_access_token(token: &str) -> Result<String> {
@ -89,7 +108,7 @@ fn hash_access_token(token: &str) -> Result<String> {
let params = if cfg!(debug_assertions) {
scrypt::Params::new(1, 1, 1).unwrap()
} else {
scrypt::Params::recommended()
scrypt::Params::new(14, 8, 1).unwrap()
};
Ok(Scrypt
@ -112,7 +131,21 @@ pub fn encrypt_access_token(access_token: &str, public_key: String) -> Result<St
Ok(encrypted_access_token)
}
pub fn verify_access_token(token: &str, hash: &str) -> Result<bool> {
let hash = PasswordHash::new(hash).map_err(anyhow::Error::new)?;
Ok(Scrypt.verify_password(token.as_bytes(), &hash).is_ok())
pub async fn verify_access_token(token: &str, user_id: UserId, db: &Arc<Database>) -> Result<bool> {
let token: AccessTokenJson = serde_json::from_str(&token)?;
let db_token = db.get_access_token(token.id).await?;
if db_token.user_id != user_id {
return Err(anyhow!("no such access token"))?;
}
let db_hash = PasswordHash::new(&db_token.hash).map_err(anyhow::Error::new)?;
let t0 = Instant::now();
let is_valid = Scrypt
.verify_password(token.token.as_bytes(), &db_hash)
.is_ok();
let duration = t0.elapsed();
log::info!("hashed access token in {:?}", duration);
METRIC_ACCESS_TOKEN_HASHING_TIME.observe(duration.as_millis() as f64);
Ok(is_valid)
}

View file

@ -1,4 +1,4 @@
use collab::db;
use collab::{db, executor::Executor};
use db::{ConnectOptions, Database};
use serde::{de::DeserializeOwned, Deserialize};
use std::fmt::Write;
@ -13,7 +13,7 @@ struct GitHubUser {
#[tokio::main]
async fn main() {
let database_url = std::env::var("DATABASE_URL").expect("missing DATABASE_URL env var");
let db = Database::new(ConnectOptions::new(database_url))
let db = Database::new(ConnectOptions::new(database_url), Executor::Production)
.await
.expect("failed to connect to postgres database");
let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var");
@ -59,7 +59,7 @@ async fn main() {
for (github_user, admin) in zed_users {
if db
.get_user_by_github_account(&github_user.login, Some(github_user.id))
.get_user_by_github_login(&github_user.login)
.await
.expect("failed to fetch user")
.is_none()

View file

@ -1,5 +1,6 @@
mod access_token;
mod contact;
mod follower;
mod language_server;
mod project;
mod project_collaborator;
@ -14,6 +15,7 @@ mod worktree;
mod worktree_diagnostic_summary;
mod worktree_entry;
use crate::executor::Executor;
use crate::{Error, Result};
use anyhow::anyhow;
use collections::{BTreeMap, HashMap, HashSet};
@ -21,6 +23,8 @@ pub use contact::Contact;
use dashmap::DashMap;
use futures::StreamExt;
use hyper::StatusCode;
use rand::prelude::StdRng;
use rand::{Rng, SeedableRng};
use rpc::{proto, ConnectionId};
use sea_orm::Condition;
pub use sea_orm::ConnectOptions;
@ -45,20 +49,20 @@ pub struct Database {
options: ConnectOptions,
pool: DatabaseConnection,
rooms: DashMap<RoomId, Arc<Mutex<()>>>,
#[cfg(test)]
background: Option<std::sync::Arc<gpui::executor::Background>>,
rng: Mutex<StdRng>,
executor: Executor,
#[cfg(test)]
runtime: Option<tokio::runtime::Runtime>,
}
impl Database {
pub async fn new(options: ConnectOptions) -> Result<Self> {
pub async fn new(options: ConnectOptions, executor: Executor) -> Result<Self> {
Ok(Self {
options: options.clone(),
pool: sea_orm::Database::connect(options).await?,
rooms: DashMap::with_capacity(16384),
#[cfg(test)]
background: None,
rng: Mutex::new(StdRng::seed_from_u64(0)),
executor,
#[cfg(test)]
runtime: None,
})
@ -157,7 +161,7 @@ impl Database {
room_id: RoomId,
new_server_id: ServerId,
) -> Result<RoomGuard<RefreshedRoom>> {
self.room_transaction(|tx| async move {
self.room_transaction(room_id, |tx| async move {
let stale_participant_filter = Condition::all()
.add(room_participant::Column::RoomId.eq(room_id))
.add(room_participant::Column::AnsweringConnectionId.is_not_null())
@ -190,17 +194,18 @@ impl Database {
.filter(room_participant::Column::RoomId.eq(room_id))
.exec(&*tx)
.await?;
project::Entity::delete_many()
.filter(project::Column::RoomId.eq(room_id))
.exec(&*tx)
.await?;
room::Entity::delete_by_id(room_id).exec(&*tx).await?;
}
Ok((
room_id,
RefreshedRoom {
room,
stale_participant_user_ids,
canceled_calls_to_user_ids,
},
))
Ok(RefreshedRoom {
room,
stale_participant_user_ids,
canceled_calls_to_user_ids,
})
})
.await
}
@ -293,10 +298,21 @@ impl Database {
.await
}
pub async fn get_user_by_github_account(
pub async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
self.transaction(|tx| async move {
Ok(user::Entity::find()
.filter(user::Column::GithubLogin.eq(github_login))
.one(&*tx)
.await?)
})
.await
}
pub async fn get_or_create_user_by_github_account(
&self,
github_login: &str,
github_user_id: Option<i32>,
github_email: Option<&str>,
) -> Result<Option<User>> {
self.transaction(|tx| async move {
let tx = &*tx;
@ -318,7 +334,19 @@ impl Database {
user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
Ok(Some(user_by_github_login.update(tx).await?))
} else {
Ok(None)
let user = user::Entity::insert(user::ActiveModel {
email_address: ActiveValue::set(github_email.map(|email| email.into())),
github_login: ActiveValue::set(github_login.into()),
github_user_id: ActiveValue::set(Some(github_user_id)),
admin: ActiveValue::set(false),
invite_count: ActiveValue::set(0),
invite_code: ActiveValue::set(None),
metrics_id: ActiveValue::set(Uuid::new_v4()),
..Default::default()
})
.exec_with_returning(&*tx)
.await?;
Ok(Some(user))
}
} else {
Ok(user::Entity::find()
@ -1129,18 +1157,16 @@ impl Database {
user_id: UserId,
connection: ConnectionId,
live_kit_room: &str,
) -> Result<RoomGuard<proto::Room>> {
self.room_transaction(|tx| async move {
) -> Result<proto::Room> {
self.transaction(|tx| async move {
let room = room::ActiveModel {
live_kit_room: ActiveValue::set(live_kit_room.into()),
..Default::default()
}
.insert(&*tx)
.await?;
let room_id = room.id;
room_participant::ActiveModel {
room_id: ActiveValue::set(room_id),
room_id: ActiveValue::set(room.id),
user_id: ActiveValue::set(user_id),
answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
answering_connection_server_id: ActiveValue::set(Some(ServerId(
@ -1157,8 +1183,8 @@ impl Database {
.insert(&*tx)
.await?;
let room = self.get_room(room_id, &tx).await?;
Ok((room_id, room))
let room = self.get_room(room.id, &tx).await?;
Ok(room)
})
.await
}
@ -1171,7 +1197,7 @@ impl Database {
called_user_id: UserId,
initial_project_id: Option<ProjectId>,
) -> Result<RoomGuard<(proto::Room, proto::IncomingCall)>> {
self.room_transaction(|tx| async move {
self.room_transaction(room_id, |tx| async move {
room_participant::ActiveModel {
room_id: ActiveValue::set(room_id),
user_id: ActiveValue::set(called_user_id),
@ -1190,7 +1216,7 @@ impl Database {
let room = self.get_room(room_id, &tx).await?;
let incoming_call = Self::build_incoming_call(&room, called_user_id)
.ok_or_else(|| anyhow!("failed to build incoming call"))?;
Ok((room_id, (room, incoming_call)))
Ok((room, incoming_call))
})
.await
}
@ -1200,7 +1226,7 @@ impl Database {
room_id: RoomId,
called_user_id: UserId,
) -> Result<RoomGuard<proto::Room>> {
self.room_transaction(|tx| async move {
self.room_transaction(room_id, |tx| async move {
room_participant::Entity::delete_many()
.filter(
room_participant::Column::RoomId
@ -1210,7 +1236,7 @@ impl Database {
.exec(&*tx)
.await?;
let room = self.get_room(room_id, &tx).await?;
Ok((room_id, room))
Ok(room)
})
.await
}
@ -1257,7 +1283,7 @@ impl Database {
calling_connection: ConnectionId,
called_user_id: UserId,
) -> Result<RoomGuard<proto::Room>> {
self.room_transaction(|tx| async move {
self.room_transaction(room_id, |tx| async move {
let participant = room_participant::Entity::find()
.filter(
Condition::all()
@ -1276,14 +1302,13 @@ impl Database {
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no call to cancel"))?;
let room_id = participant.room_id;
room_participant::Entity::delete(participant.into_active_model())
.exec(&*tx)
.await?;
let room = self.get_room(room_id, &tx).await?;
Ok((room_id, room))
Ok(room)
})
.await
}
@ -1294,7 +1319,7 @@ impl Database {
user_id: UserId,
connection: ConnectionId,
) -> Result<RoomGuard<proto::Room>> {
self.room_transaction(|tx| async move {
self.room_transaction(room_id, |tx| async move {
let result = room_participant::Entity::update_many()
.filter(
Condition::all()
@ -1316,7 +1341,7 @@ impl Database {
Err(anyhow!("room does not exist or was already joined"))?
} else {
let room = self.get_room(room_id, &tx).await?;
Ok((room_id, room))
Ok(room)
}
})
.await
@ -1328,9 +1353,9 @@ impl Database {
user_id: UserId,
connection: ConnectionId,
) -> Result<RoomGuard<RejoinedRoom>> {
self.room_transaction(|tx| async {
let room_id = RoomId::from_proto(rejoin_room.id);
self.room_transaction(room_id, |tx| async {
let tx = tx;
let room_id = RoomId::from_proto(rejoin_room.id);
let participant_update = room_participant::Entity::update_many()
.filter(
Condition::all()
@ -1549,14 +1574,11 @@ impl Database {
}
let room = self.get_room(room_id, &tx).await?;
Ok((
room_id,
RejoinedRoom {
room,
rejoined_projects,
reshared_projects,
},
))
Ok(RejoinedRoom {
room,
rejoined_projects,
reshared_projects,
})
})
.await
}
@ -1717,13 +1739,75 @@ impl Database {
.await
}
pub async fn follow(
&self,
project_id: ProjectId,
leader_connection: ConnectionId,
follower_connection: ConnectionId,
) -> Result<RoomGuard<proto::Room>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
follower::ActiveModel {
room_id: ActiveValue::set(room_id),
project_id: ActiveValue::set(project_id),
leader_connection_server_id: ActiveValue::set(ServerId(
leader_connection.owner_id as i32,
)),
leader_connection_id: ActiveValue::set(leader_connection.id as i32),
follower_connection_server_id: ActiveValue::set(ServerId(
follower_connection.owner_id as i32,
)),
follower_connection_id: ActiveValue::set(follower_connection.id as i32),
..Default::default()
}
.insert(&*tx)
.await?;
let room = self.get_room(room_id, &*tx).await?;
Ok(room)
})
.await
}
pub async fn unfollow(
&self,
project_id: ProjectId,
leader_connection: ConnectionId,
follower_connection: ConnectionId,
) -> Result<RoomGuard<proto::Room>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
follower::Entity::delete_many()
.filter(
Condition::all()
.add(follower::Column::ProjectId.eq(project_id))
.add(
follower::Column::LeaderConnectionServerId
.eq(leader_connection.owner_id),
)
.add(follower::Column::LeaderConnectionId.eq(leader_connection.id))
.add(
follower::Column::FollowerConnectionServerId
.eq(follower_connection.owner_id),
)
.add(follower::Column::FollowerConnectionId.eq(follower_connection.id)),
)
.exec(&*tx)
.await?;
let room = self.get_room(room_id, &*tx).await?;
Ok(room)
})
.await
}
pub async fn update_room_participant_location(
&self,
room_id: RoomId,
connection: ConnectionId,
location: proto::ParticipantLocation,
) -> Result<RoomGuard<proto::Room>> {
self.room_transaction(|tx| async {
self.room_transaction(room_id, |tx| async {
let tx = tx;
let location_kind;
let location_project_id;
@ -1769,7 +1853,7 @@ impl Database {
if result.rows_affected == 1 {
let room = self.get_room(room_id, &tx).await?;
Ok((room_id, room))
Ok(room)
} else {
Err(anyhow!("could not update room participant location"))?
}
@ -1926,12 +2010,25 @@ impl Database {
}
}
}
drop(db_projects);
let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?;
let mut followers = Vec::new();
while let Some(db_follower) = db_followers.next().await {
let db_follower = db_follower?;
followers.push(proto::Follower {
leader_id: Some(db_follower.leader_connection().into()),
follower_id: Some(db_follower.follower_connection().into()),
project_id: db_follower.project_id.to_proto(),
});
}
Ok(proto::Room {
id: db_room.id.to_proto(),
live_kit_room: db_room.live_kit_room,
participants: participants.into_values().collect(),
pending_participants,
followers,
})
}
@ -1963,7 +2060,7 @@ impl Database {
connection: ConnectionId,
worktrees: &[proto::WorktreeMetadata],
) -> Result<RoomGuard<(ProjectId, proto::Room)>> {
self.room_transaction(|tx| async move {
self.room_transaction(room_id, |tx| async move {
let participant = room_participant::Entity::find()
.filter(
Condition::all()
@ -2024,7 +2121,7 @@ impl Database {
.await?;
let room = self.get_room(room_id, &tx).await?;
Ok((room_id, (project.id, room)))
Ok((project.id, room))
})
.await
}
@ -2034,7 +2131,8 @@ impl Database {
project_id: ProjectId,
connection: ConnectionId,
) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
self.room_transaction(|tx| async move {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
let project = project::Entity::find_by_id(project_id)
@ -2042,12 +2140,11 @@ impl Database {
.await?
.ok_or_else(|| anyhow!("project not found"))?;
if project.host_connection()? == connection {
let room_id = project.room_id;
project::Entity::delete(project.into_active_model())
.exec(&*tx)
.await?;
let room = self.get_room(room_id, &tx).await?;
Ok((room_id, (room, guest_connection_ids)))
Ok((room, guest_connection_ids))
} else {
Err(anyhow!("cannot unshare a project hosted by another user"))?
}
@ -2061,7 +2158,8 @@ impl Database {
connection: ConnectionId,
worktrees: &[proto::WorktreeMetadata],
) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
self.room_transaction(|tx| async move {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let project = project::Entity::find_by_id(project_id)
.filter(
Condition::all()
@ -2079,7 +2177,7 @@ impl Database {
let guest_connection_ids = self.project_guest_connection_ids(project.id, &tx).await?;
let room = self.get_room(project.room_id, &tx).await?;
Ok((project.room_id, (room, guest_connection_ids)))
Ok((room, guest_connection_ids))
})
.await
}
@ -2124,12 +2222,12 @@ impl Database {
update: &proto::UpdateWorktree,
connection: ConnectionId,
) -> Result<RoomGuard<Vec<ConnectionId>>> {
self.room_transaction(|tx| async move {
let project_id = ProjectId::from_proto(update.project_id);
let worktree_id = update.worktree_id as i64;
let project_id = ProjectId::from_proto(update.project_id);
let worktree_id = update.worktree_id as i64;
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
// Ensure the update comes from the host.
let project = project::Entity::find_by_id(project_id)
let _project = project::Entity::find_by_id(project_id)
.filter(
Condition::all()
.add(project::Column::HostConnectionId.eq(connection.id as i32))
@ -2140,7 +2238,6 @@ impl Database {
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
let room_id = project.room_id;
// Update metadata.
worktree::Entity::update(worktree::ActiveModel {
@ -2220,7 +2317,7 @@ impl Database {
}
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
Ok((room_id, connection_ids))
Ok(connection_ids)
})
.await
}
@ -2230,9 +2327,10 @@ impl Database {
update: &proto::UpdateDiagnosticSummary,
connection: ConnectionId,
) -> Result<RoomGuard<Vec<ConnectionId>>> {
self.room_transaction(|tx| async move {
let project_id = ProjectId::from_proto(update.project_id);
let worktree_id = update.worktree_id as i64;
let project_id = ProjectId::from_proto(update.project_id);
let worktree_id = update.worktree_id as i64;
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let summary = update
.summary
.as_ref()
@ -2274,7 +2372,7 @@ impl Database {
.await?;
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
Ok((project.room_id, connection_ids))
Ok(connection_ids)
})
.await
}
@ -2284,8 +2382,9 @@ impl Database {
update: &proto::StartLanguageServer,
connection: ConnectionId,
) -> Result<RoomGuard<Vec<ConnectionId>>> {
self.room_transaction(|tx| async move {
let project_id = ProjectId::from_proto(update.project_id);
let project_id = ProjectId::from_proto(update.project_id);
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let server = update
.server
.as_ref()
@ -2319,7 +2418,7 @@ impl Database {
.await?;
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
Ok((project.room_id, connection_ids))
Ok(connection_ids)
})
.await
}
@ -2329,7 +2428,8 @@ impl Database {
project_id: ProjectId,
connection: ConnectionId,
) -> Result<RoomGuard<(Project, ReplicaId)>> {
self.room_transaction(|tx| async move {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let participant = room_participant::Entity::find()
.filter(
Condition::all()
@ -2455,7 +2555,6 @@ impl Database {
.all(&*tx)
.await?;
let room_id = project.room_id;
let project = Project {
collaborators: collaborators
.into_iter()
@ -2475,7 +2574,7 @@ impl Database {
})
.collect(),
};
Ok((room_id, (project, replica_id as ReplicaId)))
Ok((project, replica_id as ReplicaId))
})
.await
}
@ -2484,8 +2583,9 @@ impl Database {
&self,
project_id: ProjectId,
connection: ConnectionId,
) -> Result<RoomGuard<LeftProject>> {
self.room_transaction(|tx| async move {
) -> Result<RoomGuard<(proto::Room, LeftProject)>> {
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let result = project_collaborator::Entity::delete_many()
.filter(
Condition::all()
@ -2515,13 +2615,39 @@ impl Database {
.map(|collaborator| collaborator.connection())
.collect();
follower::Entity::delete_many()
.filter(
Condition::any()
.add(
Condition::all()
.add(follower::Column::ProjectId.eq(project_id))
.add(
follower::Column::LeaderConnectionServerId
.eq(connection.owner_id),
)
.add(follower::Column::LeaderConnectionId.eq(connection.id)),
)
.add(
Condition::all()
.add(follower::Column::ProjectId.eq(project_id))
.add(
follower::Column::FollowerConnectionServerId
.eq(connection.owner_id),
)
.add(follower::Column::FollowerConnectionId.eq(connection.id)),
),
)
.exec(&*tx)
.await?;
let room = self.get_room(project.room_id, &tx).await?;
let left_project = LeftProject {
id: project_id,
host_user_id: project.host_user_id,
host_connection_id: project.host_connection()?,
connection_ids,
};
Ok((project.room_id, left_project))
Ok((room, left_project))
})
.await
}
@ -2531,11 +2657,8 @@ impl Database {
project_id: ProjectId,
connection_id: ConnectionId,
) -> Result<RoomGuard<Vec<ProjectCollaborator>>> {
self.room_transaction(|tx| async move {
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let collaborators = project_collaborator::Entity::find()
.filter(project_collaborator::Column::ProjectId.eq(project_id))
.all(&*tx)
@ -2553,7 +2676,7 @@ impl Database {
.iter()
.any(|collaborator| collaborator.connection_id == connection_id)
{
Ok((project.room_id, collaborators))
Ok(collaborators)
} else {
Err(anyhow!("no such project"))?
}
@ -2566,11 +2689,8 @@ impl Database {
project_id: ProjectId,
connection_id: ConnectionId,
) -> Result<RoomGuard<HashSet<ConnectionId>>> {
self.room_transaction(|tx| async move {
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
let room_id = self.room_id_for_project(project_id).await?;
self.room_transaction(room_id, |tx| async move {
let mut collaborators = project_collaborator::Entity::find()
.filter(project_collaborator::Column::ProjectId.eq(project_id))
.stream(&*tx)
@ -2583,7 +2703,7 @@ impl Database {
}
if connection_ids.contains(&connection_id) {
Ok((project.room_id, connection_ids))
Ok(connection_ids)
} else {
Err(anyhow!("no such project"))?
}
@ -2613,18 +2733,29 @@ impl Database {
Ok(guest_connection_ids)
}
async fn room_id_for_project(&self, project_id: ProjectId) -> Result<RoomId> {
self.transaction(|tx| async move {
let project = project::Entity::find_by_id(project_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("project {} not found", project_id))?;
Ok(project.room_id)
})
.await
}
// access tokens
pub async fn create_access_token_hash(
pub async fn create_access_token(
&self,
user_id: UserId,
access_token_hash: &str,
max_access_token_count: usize,
) -> Result<()> {
) -> Result<AccessTokenId> {
self.transaction(|tx| async {
let tx = tx;
access_token::ActiveModel {
let token = access_token::ActiveModel {
user_id: ActiveValue::set(user_id),
hash: ActiveValue::set(access_token_hash.into()),
..Default::default()
@ -2647,26 +2778,20 @@ impl Database {
)
.exec(&*tx)
.await?;
Ok(())
Ok(token.id)
})
.await
}
pub async fn get_access_token_hashes(&self, user_id: UserId) -> Result<Vec<String>> {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryAs {
Hash,
}
pub async fn get_access_token(
&self,
access_token_id: AccessTokenId,
) -> Result<access_token::Model> {
self.transaction(|tx| async move {
Ok(access_token::Entity::find()
.select_only()
.column(access_token::Column::Hash)
.filter(access_token::Column::UserId.eq(user_id))
.order_by_desc(access_token::Column::Id)
.into_values::<_, QueryAs>()
.all(&*tx)
.await?)
Ok(access_token::Entity::find_by_id(access_token_id)
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such access token"))?)
})
.await
}
@ -2677,30 +2802,26 @@ impl Database {
Fut: Send + Future<Output = Result<T>>,
{
let body = async {
let mut i = 0;
loop {
let (tx, result) = self.with_transaction(&f).await?;
match result {
Ok(result) => {
match tx.commit().await.map_err(Into::into) {
Ok(()) => return Ok(result),
Err(error) => {
if is_serialization_error(&error) {
// Retry (don't break the loop)
} else {
return Err(error);
}
Ok(result) => match tx.commit().await.map_err(Into::into) {
Ok(()) => return Ok(result),
Err(error) => {
if !self.retry_on_serialization_error(&error, i).await {
return Err(error);
}
}
}
},
Err(error) => {
tx.rollback().await?;
if is_serialization_error(&error) {
// Retry (don't break the loop)
} else {
if !self.retry_on_serialization_error(&error, i).await {
return Err(error);
}
}
}
i += 1;
}
};
@ -2713,6 +2834,7 @@ impl Database {
Fut: Send + Future<Output = Result<Option<(RoomId, T)>>>,
{
let body = async {
let mut i = 0;
loop {
let (tx, result) = self.with_transaction(&f).await?;
match result {
@ -2728,56 +2850,72 @@ impl Database {
}));
}
Err(error) => {
if is_serialization_error(&error) {
// Retry (don't break the loop)
} else {
if !self.retry_on_serialization_error(&error, i).await {
return Err(error);
}
}
}
}
Ok(None) => {
match tx.commit().await.map_err(Into::into) {
Ok(()) => return Ok(None),
Err(error) => {
if is_serialization_error(&error) {
// Retry (don't break the loop)
} else {
return Err(error);
}
Ok(None) => match tx.commit().await.map_err(Into::into) {
Ok(()) => return Ok(None),
Err(error) => {
if !self.retry_on_serialization_error(&error, i).await {
return Err(error);
}
}
}
},
Err(error) => {
tx.rollback().await?;
if is_serialization_error(&error) {
// Retry (don't break the loop)
} else {
if !self.retry_on_serialization_error(&error, i).await {
return Err(error);
}
}
}
i += 1;
}
};
self.run(body).await
}
async fn room_transaction<F, Fut, T>(&self, f: F) -> Result<RoomGuard<T>>
async fn room_transaction<F, Fut, T>(&self, room_id: RoomId, f: F) -> Result<RoomGuard<T>>
where
F: Send + Fn(TransactionHandle) -> Fut,
Fut: Send + Future<Output = Result<(RoomId, T)>>,
Fut: Send + Future<Output = Result<T>>,
{
let data = self
.optional_room_transaction(move |tx| {
let future = f(tx);
async {
let data = future.await?;
Ok(Some(data))
let body = async {
let mut i = 0;
loop {
let lock = self.rooms.entry(room_id).or_default().clone();
let _guard = lock.lock_owned().await;
let (tx, result) = self.with_transaction(&f).await?;
match result {
Ok(data) => match tx.commit().await.map_err(Into::into) {
Ok(()) => {
return Ok(RoomGuard {
data,
_guard,
_not_send: PhantomData,
});
}
Err(error) => {
if !self.retry_on_serialization_error(&error, i).await {
return Err(error);
}
}
},
Err(error) => {
tx.rollback().await?;
if !self.retry_on_serialization_error(&error, i).await {
return Err(error);
}
}
}
})
.await?;
Ok(data.unwrap())
i += 1;
}
};
self.run(body).await
}
async fn with_transaction<F, Fut, T>(&self, f: &F) -> Result<(DatabaseTransaction, Result<T>)>
@ -2799,14 +2937,14 @@ impl Database {
Ok((tx, result))
}
async fn run<F, T>(&self, future: F) -> T
async fn run<F, T>(&self, future: F) -> Result<T>
where
F: Future<Output = T>,
F: Future<Output = Result<T>>,
{
#[cfg(test)]
{
if let Some(background) = self.background.as_ref() {
background.simulate_random_delay().await;
if let Executor::Deterministic(executor) = &self.executor {
executor.simulate_random_delay().await;
}
self.runtime.as_ref().unwrap().block_on(future)
@ -2817,6 +2955,27 @@ impl Database {
future.await
}
}
async fn retry_on_serialization_error(&self, error: &Error, prev_attempt_count: u32) -> bool {
// If the error is due to a failure to serialize concurrent transactions, then retry
// this transaction after a delay. With each subsequent retry, double the delay duration.
// Also vary the delay randomly in order to ensure different database connections retry
// at different times.
if is_serialization_error(error) {
let base_delay = 4_u64 << prev_attempt_count.min(16);
let randomized_delay = base_delay as f32 * self.rng.lock().await.gen_range(0.5..=2.0);
log::info!(
"retrying transaction after serialization error. delay: {} ms.",
randomized_delay
);
self.executor
.sleep(Duration::from_millis(randomized_delay as u64))
.await;
true
} else {
false
}
}
}
fn is_serialization_error(error: &Error) -> bool {
@ -3011,6 +3170,7 @@ macro_rules! id_type {
id_type!(AccessTokenId);
id_type!(ContactId);
id_type!(FollowerId);
id_type!(RoomId);
id_type!(RoomParticipantId);
id_type!(ProjectId);
@ -3117,7 +3277,6 @@ mod test {
use gpui::executor::Background;
use lazy_static::lazy_static;
use parking_lot::Mutex;
use rand::prelude::*;
use sea_orm::ConnectionTrait;
use sqlx::migrate::MigrateDatabase;
use std::sync::Arc;
@ -3139,7 +3298,9 @@ mod test {
let mut db = runtime.block_on(async {
let mut options = ConnectOptions::new(url);
options.max_connections(5);
let db = Database::new(options).await.unwrap();
let db = Database::new(options, Executor::Deterministic(background))
.await
.unwrap();
let sql = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/migrations.sqlite/20221109000000_test_schema.sql"
@ -3154,7 +3315,6 @@ mod test {
db
});
db.background = Some(background);
db.runtime = Some(runtime);
Self {
@ -3188,13 +3348,14 @@ mod test {
options
.max_connections(5)
.idle_timeout(Duration::from_secs(0));
let db = Database::new(options).await.unwrap();
let db = Database::new(options, Executor::Deterministic(background))
.await
.unwrap();
let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations");
db.migrate(Path::new(migrations_path), false).await.unwrap();
db
});
db.background = Some(background);
db.runtime = Some(runtime);
Self {

View file

@ -0,0 +1,51 @@
use super::{FollowerId, ProjectId, RoomId, ServerId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
use serde::Serialize;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "followers")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: FollowerId,
pub room_id: RoomId,
pub project_id: ProjectId,
pub leader_connection_server_id: ServerId,
pub leader_connection_id: i32,
pub follower_connection_server_id: ServerId,
pub follower_connection_id: i32,
}
impl Model {
pub fn leader_connection(&self) -> ConnectionId {
ConnectionId {
owner_id: self.leader_connection_server_id.0 as u32,
id: self.leader_connection_id as u32,
}
}
pub fn follower_connection(&self) -> ConnectionId {
ConnectionId {
owner_id: self.follower_connection_server_id.0 as u32,
id: self.follower_connection_id as u32,
}
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::room::Entity",
from = "Column::RoomId",
to = "super::room::Column::Id"
)]
Room,
}
impl Related<super::room::Entity> for Entity {
fn to() -> RelationDef {
Relation::Room.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -15,6 +15,8 @@ pub enum Relation {
RoomParticipant,
#[sea_orm(has_many = "super::project::Entity")]
Project,
#[sea_orm(has_many = "super::follower::Entity")]
Follower,
}
impl Related<super::room_participant::Entity> for Entity {
@ -29,4 +31,10 @@ impl Related<super::project::Entity> for Entity {
}
}
impl Related<super::follower::Entity> for Entity {
fn to() -> RelationDef {
Relation::Follower.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -92,8 +92,8 @@ test_both_dbs!(
);
test_both_dbs!(
test_get_user_by_github_account_postgres,
test_get_user_by_github_account_sqlite,
test_get_or_create_user_by_github_account_postgres,
test_get_or_create_user_by_github_account_sqlite,
db,
{
let user_id1 = db
@ -124,7 +124,7 @@ test_both_dbs!(
.user_id;
let user = db
.get_user_by_github_account("login1", None)
.get_or_create_user_by_github_account("login1", None, None)
.await
.unwrap()
.unwrap();
@ -133,19 +133,28 @@ test_both_dbs!(
assert_eq!(user.github_user_id, Some(101));
assert!(db
.get_user_by_github_account("non-existent-login", None)
.get_or_create_user_by_github_account("non-existent-login", None, None)
.await
.unwrap()
.is_none());
let user = db
.get_user_by_github_account("the-new-login2", Some(102))
.get_or_create_user_by_github_account("the-new-login2", Some(102), None)
.await
.unwrap()
.unwrap();
assert_eq!(user.id, user_id2);
assert_eq!(&user.github_login, "the-new-login2");
assert_eq!(user.github_user_id, Some(102));
let user = db
.get_or_create_user_by_github_account("login3", Some(103), Some("user3@example.com"))
.await
.unwrap()
.unwrap();
assert_eq!(&user.github_login, "login3");
assert_eq!(user.github_user_id, Some(103));
assert_eq!(user.email_address, Some("user3@example.com".into()));
}
);
@ -168,30 +177,63 @@ test_both_dbs!(
.unwrap()
.user_id;
db.create_access_token_hash(user, "h1", 3).await.unwrap();
db.create_access_token_hash(user, "h2", 3).await.unwrap();
let token_1 = db.create_access_token(user, "h1", 2).await.unwrap();
let token_2 = db.create_access_token(user, "h2", 2).await.unwrap();
assert_eq!(
db.get_access_token_hashes(user).await.unwrap(),
&["h2".to_string(), "h1".to_string()]
db.get_access_token(token_1).await.unwrap(),
access_token::Model {
id: token_1,
user_id: user,
hash: "h1".into(),
}
);
assert_eq!(
db.get_access_token(token_2).await.unwrap(),
access_token::Model {
id: token_2,
user_id: user,
hash: "h2".into()
}
);
db.create_access_token_hash(user, "h3", 3).await.unwrap();
let token_3 = db.create_access_token(user, "h3", 2).await.unwrap();
assert_eq!(
db.get_access_token_hashes(user).await.unwrap(),
&["h3".to_string(), "h2".to_string(), "h1".to_string(),]
db.get_access_token(token_3).await.unwrap(),
access_token::Model {
id: token_3,
user_id: user,
hash: "h3".into()
}
);
assert_eq!(
db.get_access_token(token_2).await.unwrap(),
access_token::Model {
id: token_2,
user_id: user,
hash: "h2".into()
}
);
assert!(db.get_access_token(token_1).await.is_err());
db.create_access_token_hash(user, "h4", 3).await.unwrap();
let token_4 = db.create_access_token(user, "h4", 2).await.unwrap();
assert_eq!(
db.get_access_token_hashes(user).await.unwrap(),
&["h4".to_string(), "h3".to_string(), "h2".to_string(),]
db.get_access_token(token_4).await.unwrap(),
access_token::Model {
id: token_4,
user_id: user,
hash: "h4".into()
}
);
db.create_access_token_hash(user, "h5", 3).await.unwrap();
assert_eq!(
db.get_access_token_hashes(user).await.unwrap(),
&["h5".to_string(), "h4".to_string(), "h3".to_string()]
db.get_access_token(token_3).await.unwrap(),
access_token::Model {
id: token_3,
user_id: user,
hash: "h3".into()
}
);
assert!(db.get_access_token(token_2).await.is_err());
assert!(db.get_access_token(token_1).await.is_err());
}
);

View file

@ -10,6 +10,7 @@ mod tests;
use axum::{http::StatusCode, response::IntoResponse};
use db::Database;
use executor::Executor;
use serde::Deserialize;
use std::{path::PathBuf, sync::Arc};
@ -91,6 +92,7 @@ impl std::error::Error for Error {}
pub struct Config {
pub http_port: u16,
pub database_url: String,
pub database_max_connections: u32,
pub api_token: String,
pub invite_link_prefix: String,
pub live_kit_server: Option<String>,
@ -116,8 +118,8 @@ pub struct AppState {
impl AppState {
pub async fn new(config: Config) -> Result<Arc<Self>> {
let mut db_options = db::ConnectOptions::new(config.database_url.clone());
db_options.max_connections(5);
let db = Database::new(db_options).await?;
db_options.max_connections(config.database_max_connections);
let db = Database::new(db_options, Executor::Production).await?;
let live_kit_client = if let Some(((server, key), secret)) = config
.live_kit_server
.as_ref()

View file

@ -1,11 +1,12 @@
use anyhow::anyhow;
use axum::{routing::get, Router};
use axum::{routing::get, Extension, Router};
use collab::{db, env, executor::Executor, AppState, Config, MigrateConfig, Result};
use db::Database;
use std::{
env::args,
net::{SocketAddr, TcpListener},
path::Path,
sync::Arc,
};
use tokio::signal::unix::SignalKind;
use tracing_log::LogTracer;
@ -31,7 +32,7 @@ async fn main() -> Result<()> {
let config = envy::from_env::<MigrateConfig>().expect("error loading config");
let mut db_options = db::ConnectOptions::new(config.database_url.clone());
db_options.max_connections(5);
let db = Database::new(db_options).await?;
let db = Database::new(db_options, Executor::Production).await?;
let migrations_path = config
.migrations_path
@ -66,7 +67,12 @@ async fn main() -> Result<()> {
let app = collab::api::routes(rpc_server.clone(), state.clone())
.merge(collab::rpc::routes(rpc_server.clone()))
.merge(Router::new().route("/", get(handle_root)));
.merge(
Router::new()
.route("/", get(handle_root))
.route("/healthz", get(handle_liveness_probe))
.layer(Extension(state.clone())),
);
axum::Server::from_tcp(listener)?
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
@ -95,6 +101,11 @@ async fn handle_root() -> String {
format!("collab v{VERSION}")
}
async fn handle_liveness_probe(Extension(state): Extension<Arc<AppState>>) -> Result<String> {
state.db.get_all_users(0, 1).await?;
Ok("ok".to_string())
}
pub fn init_tracing(config: &Config) -> Option<()> {
use std::str::FromStr;
use tracing_subscriber::layer::SubscriberExt;

View file

@ -53,11 +53,11 @@ use std::{
},
time::Duration,
};
use tokio::sync::watch;
use tokio::sync::{watch, Semaphore};
use tower::ServiceBuilder;
use tracing::{info_span, instrument, Instrument};
pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(5);
pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
lazy_static! {
@ -186,7 +186,7 @@ impl Server {
.add_request_handler(create_room)
.add_request_handler(join_room)
.add_request_handler(rejoin_room)
.add_message_handler(leave_room)
.add_request_handler(leave_room)
.add_request_handler(call)
.add_request_handler(cancel_call)
.add_message_handler(decline_call)
@ -270,8 +270,11 @@ impl Server {
let mut live_kit_room = String::new();
let mut delete_live_kit_room = false;
if let Ok(mut refreshed_room) =
app_state.db.refresh_room(room_id, server_id).await
if let Some(mut refreshed_room) = app_state
.db
.refresh_room(room_id, server_id)
.await
.trace_err()
{
tracing::info!(
room_id = room_id.0,
@ -539,8 +542,13 @@ impl Server {
// This arrangement ensures we will attempt to process earlier messages first, but fall
// back to processing messages arrived later in the spirit of making progress.
let mut foreground_message_handlers = FuturesUnordered::new();
let concurrent_handlers = Arc::new(Semaphore::new(256));
loop {
let next_message = incoming_rx.next().fuse();
let next_message = async {
let permit = concurrent_handlers.clone().acquire_owned().await.unwrap();
let message = incoming_rx.next().await;
(permit, message)
}.fuse();
futures::pin_mut!(next_message);
futures::select_biased! {
_ = teardown.changed().fuse() => return Ok(()),
@ -551,7 +559,8 @@ impl Server {
break;
}
_ = foreground_message_handlers.next() => {}
message = next_message => {
next_message = next_message => {
let (permit, message) = next_message;
if let Some(message) = message {
let type_name = message.payload_type_name();
let span = tracing::info_span!("receive message", %user_id, %login, %connection_id, %address, type_name);
@ -561,7 +570,10 @@ impl Server {
let handle_message = (handler)(message, session.clone());
drop(span_enter);
let handle_message = handle_message.instrument(span);
let handle_message = async move {
handle_message.await;
drop(permit);
}.instrument(span);
if is_background {
executor.spawn_detached(handle_message);
} else {
@ -1090,8 +1102,14 @@ async fn rejoin_room(
Ok(())
}
async fn leave_room(_message: proto::LeaveRoom, session: Session) -> Result<()> {
leave_room_for_session(&session).await
async fn leave_room(
_: proto::LeaveRoom,
response: Response<proto::LeaveRoom>,
session: Session,
) -> Result<()> {
leave_room_for_session(&session).await?;
response.send(proto::Ack {})?;
Ok(())
}
async fn call(
@ -1312,6 +1330,7 @@ async fn join_project(
.filter(|collaborator| collaborator.connection_id != session.connection_id)
.map(|collaborator| collaborator.to_proto())
.collect::<Vec<_>>();
let worktrees = project
.worktrees
.iter()
@ -1404,7 +1423,7 @@ async fn leave_project(request: proto::LeaveProject, session: Session) -> Result
let sender_id = session.connection_id;
let project_id = ProjectId::from_proto(request.project_id);
let project = session
let (room, project) = &*session
.db()
.await
.leave_project(project_id, sender_id)
@ -1415,7 +1434,9 @@ async fn leave_project(request: proto::LeaveProject, session: Session) -> Result
host_connection_id = %project.host_connection_id,
"leave project"
);
project_left(&project, &session);
room_updated(&room, &session.peer);
Ok(())
}
@ -1724,6 +1745,7 @@ async fn follow(
.ok_or_else(|| anyhow!("invalid leader id"))?
.into();
let follower_id = session.connection_id;
{
let project_connection_ids = session
.db()
@ -1744,6 +1766,14 @@ async fn follow(
.views
.retain(|view| view.leader_id != Some(follower_id.into()));
response.send(response_payload)?;
let room = session
.db()
.await
.follow(project_id, leader_id, follower_id)
.await?;
room_updated(&room, &session.peer);
Ok(())
}
@ -1753,17 +1783,29 @@ async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
.leader_id
.ok_or_else(|| anyhow!("invalid leader id"))?
.into();
let project_connection_ids = session
let follower_id = session.connection_id;
if !session
.db()
.await
.project_connection_ids(project_id, session.connection_id)
.await?;
if !project_connection_ids.contains(&leader_id) {
.await?
.contains(&leader_id)
{
Err(anyhow!("no such peer"))?;
}
session
.peer
.forward_send(session.connection_id, leader_id, request)?;
let room = session
.db()
.await
.unfollow(project_id, leader_id, follower_id)
.await?;
room_updated(&room, &session.peer);
Ok(())
}
@ -1833,7 +1875,7 @@ async fn fuzzy_search_users(
1 | 2 => session
.db()
.await
.get_user_by_github_account(&query, None)
.get_user_by_github_login(&query)
.await?
.into_iter()
.collect(),

View file

@ -7,15 +7,12 @@ use crate::{
use anyhow::anyhow;
use call::ActiveCall;
use client::{
self, proto::PeerId, test::FakeHttpClient, Client, Connection, Credentials,
EstablishConnectionError, UserStore,
self, proto::PeerId, Client, Connection, Credentials, EstablishConnectionError, UserStore,
};
use collections::{HashMap, HashSet};
use fs::FakeFs;
use futures::{channel::oneshot, StreamExt as _};
use gpui::{
executor::Deterministic, test::EmptyView, ModelHandle, Task, TestAppContext, ViewHandle,
};
use gpui::{executor::Deterministic, test::EmptyView, ModelHandle, TestAppContext, ViewHandle};
use language::LanguageRegistry;
use parking_lot::Mutex;
use project::{Project, WorktreeId};
@ -31,6 +28,7 @@ use std::{
},
};
use theme::ThemeRegistry;
use util::http::FakeHttpClient;
use workspace::Workspace;
mod integration_tests;
@ -105,11 +103,7 @@ impl TestServer {
});
let http = FakeHttpClient::with_404_response();
let user_id = if let Ok(Some(user)) = self
.app_state
.db
.get_user_by_github_account(name, None)
.await
let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await
{
user.id
} else {
@ -193,12 +187,13 @@ impl TestServer {
let app_state = Arc::new(workspace::AppState {
client: client.clone(),
user_store: user_store.clone(),
languages: Arc::new(LanguageRegistry::new(Task::ready(()))),
languages: Arc::new(LanguageRegistry::test()),
themes: ThemeRegistry::new((), cx.font_cache()),
fs: fs.clone(),
build_window_options: |_, _, _| Default::default(),
initialize_workspace: |_, _, _| unimplemented!(),
dock_default_item_factory: |_, _| unimplemented!(),
dock_default_item_factory: |_, _| None,
background_actions: || &[],
});
Project::init(&client);
@ -468,15 +463,7 @@ impl TestClient {
cx: &mut TestAppContext,
) -> ViewHandle<Workspace> {
let (_, root_view) = cx.add_window(|_| EmptyView);
cx.add_view(&root_view, |cx| {
Workspace::new(
Default::default(),
0,
project.clone(),
|_, _| unimplemented!(),
cx,
)
})
cx.add_view(&root_view, |cx| Workspace::test_new(project.clone(), cx))
}
}

View file

@ -274,10 +274,14 @@ async fn test_basic_calls(
}
// User A leaves the room.
active_call_a.update(cx_a, |call, cx| {
call.hang_up(cx).unwrap();
assert!(call.room().is_none());
});
active_call_a
.update(cx_a, |call, cx| {
let hang_up = call.hang_up(cx);
assert!(call.room().is_none());
hang_up
})
.await
.unwrap();
deterministic.run_until_parked();
assert_eq!(
room_participants(&room_a, cx_a),
@ -557,6 +561,7 @@ async fn test_room_uniqueness(
// Client C can successfully call client B after client B leaves the room.
active_call_b
.update(cx_b, |call, cx| call.hang_up(cx))
.await
.unwrap();
deterministic.run_until_parked();
active_call_c
@ -733,6 +738,14 @@ async fn test_server_restarts(
deterministic.forbid_parking();
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
client_a
.fs
.insert_tree("/a", json!({ "a.txt": "a-contents" }))
.await;
// Invite client B to collaborate on a project
let (project_a, _) = client_a.build_local_project("/a", cx_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;
@ -753,19 +766,19 @@ async fn test_server_restarts(
// User A calls users B, C, and D.
active_call_a
.update(cx_a, |call, cx| {
call.invite(client_b.user_id().unwrap(), None, cx)
call.invite(client_b.user_id().unwrap(), Some(project_a.clone()), cx)
})
.await
.unwrap();
active_call_a
.update(cx_a, |call, cx| {
call.invite(client_c.user_id().unwrap(), None, cx)
call.invite(client_c.user_id().unwrap(), Some(project_a.clone()), cx)
})
.await
.unwrap();
active_call_a
.update(cx_a, |call, cx| {
call.invite(client_d.user_id().unwrap(), None, cx)
call.invite(client_d.user_id().unwrap(), Some(project_a.clone()), cx)
})
.await
.unwrap();
@ -821,7 +834,7 @@ async fn test_server_restarts(
// Users A and B reconnect to the call. User C has troubles reconnecting, so it leaves the room.
client_c.override_establish_connection(|_, cx| cx.spawn(|_| future::pending()));
deterministic.advance_clock(RECEIVE_TIMEOUT);
deterministic.advance_clock(RECONNECT_TIMEOUT);
assert_eq!(
room_participants(&room_a, cx_a),
RoomParticipants {
@ -928,6 +941,7 @@ async fn test_server_restarts(
// User D hangs up.
active_call_d
.update(cx_d, |call, cx| call.hang_up(cx))
.await
.unwrap();
deterministic.run_until_parked();
assert_eq!(
@ -993,7 +1007,7 @@ async fn test_server_restarts(
client_a.override_establish_connection(|_, cx| cx.spawn(|_| future::pending()));
client_b.override_establish_connection(|_, cx| cx.spawn(|_| future::pending()));
client_c.override_establish_connection(|_, cx| cx.spawn(|_| future::pending()));
deterministic.advance_clock(RECEIVE_TIMEOUT);
deterministic.advance_clock(RECONNECT_TIMEOUT);
assert_eq!(
room_participants(&room_a, cx_a),
RoomParticipants {
@ -1083,7 +1097,7 @@ async fn test_calls_on_multiple_connections(
assert!(incoming_call_b2.next().await.unwrap().is_none());
// User B disconnects the client that is not on the call. Everything should be fine.
client_b1.disconnect(&cx_b1.to_async()).unwrap();
client_b1.disconnect(&cx_b1.to_async());
deterministic.advance_clock(RECEIVE_TIMEOUT);
client_b1
.authenticate_and_connect(false, &cx_b1.to_async())
@ -1091,7 +1105,10 @@ async fn test_calls_on_multiple_connections(
.unwrap();
// User B hangs up, and user A calls them again.
active_call_b2.update(cx_b2, |call, cx| call.hang_up(cx).unwrap());
active_call_b2
.update(cx_b2, |call, cx| call.hang_up(cx))
.await
.unwrap();
deterministic.run_until_parked();
active_call_a
.update(cx_a, |call, cx| {
@ -1126,7 +1143,10 @@ async fn test_calls_on_multiple_connections(
assert!(incoming_call_b2.next().await.unwrap().is_some());
// User A hangs up, causing both connections to stop ringing.
active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap());
active_call_a
.update(cx_a, |call, cx| call.hang_up(cx))
.await
.unwrap();
deterministic.run_until_parked();
assert!(incoming_call_b1.next().await.unwrap().is_none());
assert!(incoming_call_b2.next().await.unwrap().is_none());
@ -1363,7 +1383,10 @@ async fn test_unshare_project(
.unwrap();
// When client B leaves the room, the project becomes read-only.
active_call_b.update(cx_b, |call, cx| call.hang_up(cx).unwrap());
active_call_b
.update(cx_b, |call, cx| call.hang_up(cx))
.await
.unwrap();
deterministic.run_until_parked();
assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
@ -1392,7 +1415,10 @@ async fn test_unshare_project(
.unwrap();
// When client A (the host) leaves the room, the project gets unshared and guests are notified.
active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap());
active_call_a
.update(cx_a, |call, cx| call.hang_up(cx))
.await
.unwrap();
deterministic.run_until_parked();
project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
project_c2.read_with(cx_c, |project, _| {
@ -1441,15 +1467,7 @@ async fn test_host_disconnect(
deterministic.run_until_parked();
assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
let (_, workspace_b) = cx_b.add_window(|cx| {
Workspace::new(
Default::default(),
0,
project_b.clone(),
|_, _| unimplemented!(),
cx,
)
});
let (_, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
workspace.open_path((worktree_id, "b.txt"), None, true, cx)
@ -1726,10 +1744,6 @@ async fn test_project_reconnect(
vec![
"a.txt",
"b.txt",
"subdir1",
"subdir1/c.txt",
"subdir1/d.txt",
"subdir1/e.txt",
"subdir2",
"subdir2/f.txt",
"subdir2/g.txt",
@ -1762,10 +1776,6 @@ async fn test_project_reconnect(
vec![
"a.txt",
"b.txt",
"subdir1",
"subdir1/c.txt",
"subdir1/d.txt",
"subdir1/e.txt",
"subdir2",
"subdir2/f.txt",
"subdir2/g.txt",
@ -1857,10 +1867,6 @@ async fn test_project_reconnect(
vec![
"a.txt",
"b.txt",
"subdir1",
"subdir1/c.txt",
"subdir1/d.txt",
"subdir1/e.txt",
"subdir2",
"subdir2/f.txt",
"subdir2/g.txt",
@ -2244,7 +2250,9 @@ async fn test_propagate_saves_and_fs_changes(
});
// Edit the buffer as the host and concurrently save as guest B.
let save_b = project_b.update(cx_b, |project, cx| project.save_buffer(buffer_b.clone(), cx));
let save_b = project_b.update(cx_b, |project, cx| {
project.save_buffer(buffer_b.clone(), cx)
});
buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], None, cx));
save_b.await.unwrap();
assert_eq!(
@ -2917,7 +2925,10 @@ async fn test_buffer_conflict_after_save(
assert!(!buf.has_conflict());
});
project_b.update(cx_b, |project, cx| project.save_buffer(buffer_b.clone(), cx))
project_b
.update(cx_b, |project, cx| {
project.save_buffer(buffer_b.clone(), cx)
})
.await
.unwrap();
cx_a.foreground().forbid_parking();
@ -3222,7 +3233,7 @@ async fn test_leaving_project(
buffer_b2.read_with(cx_b, |buffer, _| assert_eq!(buffer.text(), "a-contents"));
// Drop client B's connection and ensure client A and client C observe client B leaving.
client_b.disconnect(&cx_b.to_async()).unwrap();
client_b.disconnect(&cx_b.to_async());
deterministic.advance_clock(RECONNECT_TIMEOUT);
project_a.read_with(cx_a, |project, _| {
assert_eq!(project.collaborators().len(), 1);
@ -3879,9 +3890,11 @@ async fn test_formatting_buffer(
})
.await
.unwrap();
// The edits from the LSP are applied, and a final newline is added.
assert_eq!(
buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
"let honey = \"two\""
"let honey = \"two\"\n"
);
// Ensure buffer can be formatted using an external command. Notice how the
@ -4691,15 +4704,7 @@ async fn test_collaborating_with_code_actions(
// Join the project as client B.
let project_b = client_b.build_remote_project(project_id, cx_b).await;
let (_window_b, workspace_b) = cx_b.add_window(|cx| {
Workspace::new(
Default::default(),
0,
project_b.clone(),
|_, _| unimplemented!(),
cx,
)
});
let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
@ -4922,15 +4927,7 @@ async fn test_collaborating_with_renames(
.unwrap();
let project_b = client_b.build_remote_project(project_id, cx_b).await;
let (_window_b, workspace_b) = cx_b.add_window(|cx| {
Workspace::new(
Default::default(),
0,
project_b.clone(),
|_, _| unimplemented!(),
cx,
)
});
let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
workspace.open_path((worktree_id, "one.rs"), None, true, cx)
@ -5464,7 +5461,10 @@ async fn test_contacts(
[("user_b".to_string(), "online", "busy")]
);
active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap());
active_call_a
.update(cx_a, |call, cx| call.hang_up(cx))
.await
.unwrap();
deterministic.run_until_parked();
assert_eq!(
contacts(&client_a, cx_a),
@ -5767,7 +5767,7 @@ async fn test_contact_requests(
.is_empty());
async fn disconnect_and_reconnect(client: &TestClient, cx: &mut TestAppContext) {
client.disconnect(&cx.to_async()).unwrap();
client.disconnect(&cx.to_async());
client.clear_contacts(cx).await;
client
.authenticate_and_connect(false, &cx.to_async())
@ -5777,10 +5777,12 @@ async fn test_contact_requests(
}
#[gpui::test(iterations = 10)]
async fn test_following(
async fn test_basic_following(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
cx_d: &mut TestAppContext,
) {
deterministic.forbid_parking();
cx_a.update(editor::init);
@ -5789,8 +5791,15 @@ async fn test_following(
let mut server = TestServer::start(&deterministic).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)])
.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);
@ -5822,8 +5831,10 @@ async fn test_following(
.await
.unwrap();
// Client A opens some editors.
let workspace_a = client_a.build_workspace(&project_a, cx_a);
let workspace_b = client_b.build_workspace(&project_b, cx_b);
// Client A opens some editors.
let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
let editor_a1 = workspace_a
.update(cx_a, |workspace, cx| {
@ -5843,7 +5854,6 @@ async fn test_following(
.unwrap();
// Client B opens an editor.
let workspace_b = client_b.build_workspace(&project_b, cx_b);
let editor_b1 = workspace_b
.update(cx_b, |workspace, cx| {
workspace.open_path((worktree_id, "1.txt"), None, true, cx)
@ -5853,29 +5863,184 @@ async fn test_following(
.downcast::<Editor>()
.unwrap();
let client_a_id = project_b.read_with(cx_b, |project, _| {
project.collaborators().values().next().unwrap().peer_id
});
let client_b_id = project_a.read_with(cx_a, |project, _| {
project.collaborators().values().next().unwrap().peer_id
});
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();
// When client B starts following client A, all visible view states are replicated to client B.
// Client A updates their selections in those editors
editor_a1.update(cx_a, |editor, cx| {
editor.change_selections(None, cx, |s| s.select_ranges([0..1]))
});
editor_a2.update(cx_a, |editor, cx| {
editor.change_selections(None, cx, |s| s.select_ranges([2..3]))
});
// When client B starts following client A, all visible view states are replicated to client B.
workspace_b
.update(cx_b, |workspace, cx| {
workspace
.toggle_follow(&ToggleFollow(client_a_id), cx)
.toggle_follow(&ToggleFollow(peer_id_a), cx)
.unwrap()
})
.await
.unwrap();
cx_c.foreground().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 = 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();
drop(project_c);
// Client C also follows client A.
workspace_c
.update(cx_c, |workspace, cx| {
workspace
.toggle_follow(&ToggleFollow(peer_id_a), cx)
.unwrap()
})
.await
.unwrap();
cx_d.foreground().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);
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.foreground().run_until_parked();
for (name, active_call, cx) in [
("A", &active_call_a, &cx_a),
("B", &active_call_b, &cx_b),
("C", &active_call_c, &cx_c),
("D", &active_call_d, &cx_d),
] {
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_a, project_id),
&[peer_id_b, peer_id_c],
"checking followers for A as {name}"
);
});
}
// Client C unfollows client A.
workspace_c.update(cx_c, |workspace, cx| {
workspace.toggle_follow(&ToggleFollow(peer_id_a), cx);
});
// All clients see that clients B is following client A.
cx_c.foreground().run_until_parked();
for (name, active_call, cx) in [
("A", &active_call_a, &cx_a),
("B", &active_call_b, &cx_b),
("C", &active_call_c, &cx_c),
("D", &active_call_d, &cx_d),
] {
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_a, project_id),
&[peer_id_b],
"checking followers for A as {name}"
);
});
}
// Client C re-follows client A.
workspace_c.update(cx_c, |workspace, cx| {
workspace.toggle_follow(&ToggleFollow(peer_id_a), cx);
});
// All clients see that clients B and C are following client A.
cx_c.foreground().run_until_parked();
for (name, active_call, cx) in [
("A", &active_call_a, &cx_a),
("B", &active_call_b, &cx_b),
("C", &active_call_c, &cx_c),
("D", &active_call_d, &cx_d),
] {
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_a, project_id),
&[peer_id_b, peer_id_c],
"checking followers for A as {name}"
);
});
}
// Client D follows client C.
workspace_d
.update(cx_d, |workspace, cx| {
workspace
.toggle_follow(&ToggleFollow(peer_id_c), cx)
.unwrap()
})
.await
.unwrap();
// All clients see that D is following C
cx_d.foreground().run_until_parked();
for (name, active_call, cx) in [
("A", &active_call_a, &cx_a),
("B", &active_call_b, &cx_b),
("C", &active_call_c, &cx_c),
("D", &active_call_d, &cx_d),
] {
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_c, project_id),
&[peer_id_d],
"checking followers for C as {name}"
);
});
}
// Client C closes the project.
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.foreground().run_until_parked();
for (name, active_call, cx) in [("A", &active_call_a, &cx_a), ("B", &active_call_b, &cx_b)] {
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_a, project_id),
&[peer_id_b],
"checking followers for A as {name}"
);
});
}
// All clients see that no-one is following C
for (name, active_call, cx) in [
("A", &active_call_a, &cx_a),
("B", &active_call_b, &cx_b),
("C", &active_call_c, &cx_c),
("D", &active_call_d, &cx_d),
] {
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_c, project_id),
&[],
"checking followers for C as {name}"
);
});
}
let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| {
workspace
.active_item(cx)
@ -6028,14 +6193,14 @@ async fn test_following(
workspace_a
.update(cx_a, |workspace, cx| {
workspace
.toggle_follow(&ToggleFollow(client_b_id), cx)
.toggle_follow(&ToggleFollow(peer_id_b), cx)
.unwrap()
})
.await
.unwrap();
assert_eq!(
workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
Some(client_b_id)
Some(peer_id_b)
);
assert_eq!(
workspace_a.read_with(cx_a, |workspace, cx| workspace
@ -6107,7 +6272,7 @@ async fn test_following(
);
// Following interrupts when client B disconnects.
client_b.disconnect(&cx_b.to_async()).unwrap();
client_b.disconnect(&cx_b.to_async());
deterministic.advance_clock(RECONNECT_TIMEOUT);
assert_eq!(
workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)),
@ -6115,6 +6280,99 @@ async fn test_following(
);
}
#[gpui::test(iterations = 10)]
async fn test_join_call_after_screen_was_shared(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
deterministic.forbid_parking();
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.make_contacts(&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);
// Call users B and C from client A.
active_call_a
.update(cx_a, |call, cx| {
call.invite(client_b.user_id().unwrap(), None, cx)
})
.await
.unwrap();
let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
deterministic.run_until_parked();
assert_eq!(
room_participants(&room_a, cx_a),
RoomParticipants {
remote: Default::default(),
pending: vec!["user_b".to_string()]
}
);
// User B receives the call.
let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
let call_b = incoming_call_b.next().await.unwrap().unwrap();
assert_eq!(call_b.calling_user.github_login, "user_a");
// User A shares their screen
let display = MacOSDisplay::new();
active_call_a
.update(cx_a, |call, cx| {
call.room().unwrap().update(cx, |room, cx| {
room.set_display_sources(vec![display.clone()]);
room.share_screen(cx)
})
})
.await
.unwrap();
client_b.user_store.update(cx_b, |user_store, _| {
user_store.clear_cache();
});
// User B joins the room
active_call_b
.update(cx_b, |call, cx| call.accept_incoming(cx))
.await
.unwrap();
let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
assert!(incoming_call_b.next().await.unwrap().is_none());
deterministic.run_until_parked();
assert_eq!(
room_participants(&room_a, cx_a),
RoomParticipants {
remote: vec!["user_b".to_string()],
pending: vec![],
}
);
assert_eq!(
room_participants(&room_b, cx_b),
RoomParticipants {
remote: vec!["user_a".to_string()],
pending: vec![],
}
);
// Ensure User B sees User A's screenshare.
room_b.read_with(cx_b, |room, _| {
assert_eq!(
room.remote_participants()
.get(&client_a.user_id().unwrap())
.unwrap()
.tracks
.len(),
1
);
});
}
#[gpui::test]
async fn test_following_tab_order(
deterministic: Arc<Deterministic>,

View file

@ -554,7 +554,7 @@ async fn apply_client_operation(
}
log::info!("{}: hanging up", client.username);
active_call.update(cx, |call, cx| call.hang_up(cx))?;
active_call.update(cx, |call, cx| call.hang_up(cx)).await?;
}
ClientOperation::InviteContactToCall { user_id } => {

View file

@ -27,7 +27,9 @@ call = { path = "../call" }
client = { path = "../client" }
clock = { path = "../clock" }
collections = { path = "../collections" }
context_menu = { path = "../context_menu" }
editor = { path = "../editor" }
feedback = { path = "../feedback" }
fuzzy = { path = "../fuzzy" }
gpui = { path = "../gpui" }
menu = { path = "../menu" }
@ -40,8 +42,9 @@ workspace = { path = "../workspace" }
anyhow = "1.0"
futures = "0.3"
log = "0.4"
postage = { version = "0.4.1", features = ["futures-traits"] }
serde = { version = "1.0", features = ["derive", "rc"] }
postage = { workspace = true }
serde = { workspace = true }
serde_derive = { workspace = true }
[dev-dependencies]
call = { path = "../call", features = ["test-support"] }

View file

@ -1,33 +1,60 @@
use crate::{contact_notification::ContactNotification, contacts_popover, ToggleScreenSharing};
use call::{ActiveCall, ParticipantLocation};
use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore};
use crate::{
collaborator_list_popover, collaborator_list_popover::CollaboratorListPopover,
contact_notification::ContactNotification, contacts_popover, face_pile::FacePile,
ToggleScreenSharing,
};
use call::{ActiveCall, ParticipantLocation, Room};
use client::{proto::PeerId, ContactEventKind, SignIn, SignOut, User, UserStore};
use clock::ReplicaId;
use contacts_popover::ContactsPopover;
use context_menu::{ContextMenu, ContextMenuItem};
use gpui::{
actions,
color::Color,
elements::*,
geometry::{rect::RectF, vector::vec2f, PathBuilder},
impl_internal_actions,
json::{self, ToJson},
Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
CursorStyle, Entity, ImageData, ModelHandle, MouseButton, MutableAppContext, RenderContext,
Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
};
use settings::Settings;
use std::ops::Range;
use theme::Theme;
use std::{ops::Range, sync::Arc};
use theme::{AvatarStyle, Theme};
use util::ResultExt;
use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace};
actions!(collab, [ToggleCollaborationMenu, ShareProject]);
actions!(
collab,
[
ToggleCollaboratorList,
ToggleContactsMenu,
ToggleUserMenu,
ShareProject,
UnshareProject,
]
);
impl_internal_actions!(collab, [LeaveCall]);
#[derive(Copy, Clone, PartialEq)]
pub(crate) struct LeaveCall;
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(CollabTitlebarItem::toggle_collaborator_list_popover);
cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
cx.add_action(CollabTitlebarItem::share_project);
cx.add_action(CollabTitlebarItem::unshare_project);
cx.add_action(CollabTitlebarItem::leave_call);
cx.add_action(CollabTitlebarItem::toggle_user_menu);
}
pub struct CollabTitlebarItem {
workspace: WeakViewHandle<Workspace>,
user_store: ModelHandle<UserStore>,
contacts_popover: Option<ViewHandle<ContactsPopover>>,
user_menu: ViewHandle<ContextMenu>,
collaborator_list_popover: Option<ViewHandle<CollaboratorListPopover>>,
_subscriptions: Vec<Subscription>,
}
@ -47,27 +74,62 @@ impl View for CollabTitlebarItem {
return Empty::new().boxed();
};
let project = workspace.read(cx).project().read(cx);
let mut project_title = String::new();
for (i, name) in project.worktree_root_names(cx).enumerate() {
if i > 0 {
project_title.push_str(", ");
}
project_title.push_str(name);
}
if project_title.is_empty() {
project_title = "empty project".to_owned();
}
let theme = cx.global::<Settings>().theme.clone();
let mut container = Flex::row();
let mut left_container = Flex::row();
let mut right_container = Flex::row().align_children_center();
container.add_children(self.render_toggle_screen_sharing_button(&theme, cx));
left_container.add_child(
Label::new(project_title, theme.workspace.titlebar.title.clone())
.contained()
.with_margin_right(theme.workspace.titlebar.item_spacing)
.aligned()
.left()
.boxed(),
);
if workspace.read(cx).client().status().borrow().is_connected() {
let project = workspace.read(cx).project().read(cx);
if project.is_shared()
|| project.is_remote()
|| ActiveCall::global(cx).read(cx).room().is_none()
{
container.add_child(self.render_toggle_contacts_button(&theme, cx));
} else {
container.add_child(self.render_share_button(&theme, cx));
}
let user = workspace.read(cx).user_store().read(cx).current_user();
let peer_id = workspace.read(cx).client().peer_id();
if let Some(((user, peer_id), room)) = user
.zip(peer_id)
.zip(ActiveCall::global(cx).read(cx).room().cloned())
{
left_container
.add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
right_container.add_children(self.render_collaborators(&workspace, &theme, &room, cx));
right_container
.add_child(self.render_current_user(&workspace, &theme, &user, peer_id, cx));
right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
}
container.add_children(self.render_collaborators(&workspace, &theme, cx));
container.add_children(self.render_current_user(&workspace, &theme, cx));
container.add_children(self.render_connection_status(&workspace, cx));
container.boxed()
let status = workspace.read(cx).client().status();
let status = &*status.borrow();
if matches!(status, client::Status::Connected { .. }) {
right_container.add_child(self.render_toggle_contacts_button(&theme, cx));
right_container.add_child(self.render_user_menu_button(&theme, cx));
} else {
right_container.add_children(self.render_connection_status(status, cx));
right_container.add_child(self.render_sign_in_button(&theme, cx));
}
Stack::new()
.with_child(left_container.boxed())
.with_child(right_container.aligned().right().boxed())
.boxed()
}
}
@ -80,7 +142,7 @@ impl CollabTitlebarItem {
let active_call = ActiveCall::global(cx);
let mut subscriptions = Vec::new();
subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify()));
subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify()));
subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
subscriptions.push(cx.observe_window_activation(|this, active, cx| {
this.window_activation_changed(active, cx)
}));
@ -112,6 +174,12 @@ impl CollabTitlebarItem {
workspace: workspace.downgrade(),
user_store: user_store.clone(),
contacts_popover: None,
user_menu: cx.add_view(|cx| {
let mut menu = ContextMenu::new(cx);
menu.set_position_mode(OverlayPositionMode::Local);
menu
}),
collaborator_list_popover: None,
_subscriptions: subscriptions,
}
}
@ -129,6 +197,13 @@ impl CollabTitlebarItem {
}
}
fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
if ActiveCall::global(cx).read(cx).room().is_none() {
self.contacts_popover = None;
}
cx.notify();
}
fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
if let Some(workspace) = self.workspace.upgrade(cx) {
let active_call = ActiveCall::global(cx);
@ -139,41 +214,120 @@ impl CollabTitlebarItem {
}
}
pub fn toggle_contacts_popover(
fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext<Self>) {
if let Some(workspace) = self.workspace.upgrade(cx) {
let active_call = ActiveCall::global(cx);
let project = workspace.read(cx).project().clone();
active_call
.update(cx, |call, cx| call.unshare_project(project, cx))
.log_err();
}
}
pub fn toggle_collaborator_list_popover(
&mut self,
_: &ToggleCollaborationMenu,
_: &ToggleCollaboratorList,
cx: &mut ViewContext<Self>,
) {
match self.contacts_popover.take() {
match self.collaborator_list_popover.take() {
Some(_) => {}
None => {
if let Some(workspace) = self.workspace.upgrade(cx) {
let project = workspace.read(cx).project().clone();
let user_store = workspace.read(cx).user_store().clone();
let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
let view = cx.add_view(|cx| CollaboratorListPopover::new(user_store, cx));
cx.subscribe(&view, |this, _, event, cx| {
match event {
contacts_popover::Event::Dismissed => {
this.contacts_popover = None;
collaborator_list_popover::Event::Dismissed => {
this.collaborator_list_popover = None;
}
}
cx.notify();
})
.detach();
self.contacts_popover = Some(view);
self.collaborator_list_popover = Some(view);
}
}
}
cx.notify();
}
pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext<Self>) {
if self.contacts_popover.take().is_none() {
if let Some(workspace) = self.workspace.upgrade(cx) {
let project = workspace.read(cx).project().clone();
let user_store = workspace.read(cx).user_store().clone();
let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx));
cx.subscribe(&view, |this, _, event, cx| {
match event {
contacts_popover::Event::Dismissed => {
this.contacts_popover = None;
}
}
cx.notify();
})
.detach();
self.contacts_popover = Some(view);
}
}
cx.notify();
}
pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
let theme = cx.global::<Settings>().theme.clone();
let avatar_style = theme.workspace.titlebar.leader_avatar.clone();
let item_style = theme.context_menu.item.disabled_style().clone();
self.user_menu.update(cx, |user_menu, cx| {
let items = if let Some(user) = self.user_store.read(cx).current_user() {
vec![
ContextMenuItem::Static(Box::new(move |_| {
Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Self::render_face(
avatar,
avatar_style.clone(),
Color::transparent_black(),
)
}))
.with_child(
Label::new(user.github_login.clone(), item_style.label.clone())
.boxed(),
)
.contained()
.with_style(item_style.container)
.boxed()
})),
ContextMenuItem::item("Sign out", SignOut),
ContextMenuItem::item("Send Feedback", feedback::feedback_editor::GiveFeedback),
]
} else {
vec![
ContextMenuItem::item("Sign in", SignIn),
ContextMenuItem::item("Send Feedback", feedback::feedback_editor::GiveFeedback),
]
};
user_menu.show(Default::default(), AnchorCorner::TopRight, items, cx);
});
}
fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
ActiveCall::global(cx)
.update(cx, |call, cx| call.hang_up(cx))
.detach_and_log_err(cx);
}
fn render_toggle_contacts_button(
&self,
theme: &Theme,
cx: &mut RenderContext<Self>,
) -> ElementBox {
let titlebar = &theme.workspace.titlebar;
let badge = if self
.user_store
.read(cx)
@ -194,13 +348,14 @@ impl CollabTitlebarItem {
.boxed(),
)
};
Stack::new()
.with_child(
MouseEventHandler::<ToggleCollaborationMenu>::new(0, cx, |state, _| {
MouseEventHandler::<ToggleContactsMenu>::new(0, cx, |state, _| {
let style = titlebar
.toggle_contacts_button
.style_for(state, self.contacts_popover.is_some());
Svg::new("icons/plus_8.svg")
Svg::new("icons/user_plus_16.svg")
.with_color(style.color)
.constrained()
.with_width(style.icon_width)
@ -214,39 +369,30 @@ impl CollabTitlebarItem {
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(ToggleCollaborationMenu);
cx.dispatch_action(ToggleContactsMenu);
})
.aligned()
.with_tooltip::<ToggleContactsMenu, _>(
0,
"Show contacts menu".into(),
Some(Box::new(ToggleContactsMenu)),
theme.tooltip.clone(),
cx,
)
.boxed(),
)
.with_children(badge)
.with_children(self.contacts_popover.as_ref().map(|popover| {
Overlay::new(
ChildView::new(popover, cx)
.contained()
.with_margin_top(titlebar.height)
.with_margin_left(titlebar.toggle_contacts_button.default.button_width)
.with_margin_right(-titlebar.toggle_contacts_button.default.button_width)
.boxed(),
)
.with_fit_mode(OverlayFitMode::SwitchAnchor)
.with_anchor_corner(AnchorCorner::BottomLeft)
.with_z_index(999)
.boxed()
}))
.with_children(self.render_contacts_popover_host(titlebar, cx))
.boxed()
}
fn render_toggle_screen_sharing_button(
&self,
theme: &Theme,
room: &ModelHandle<Room>,
cx: &mut RenderContext<Self>,
) -> Option<ElementBox> {
let active_call = ActiveCall::global(cx);
let room = active_call.read(cx).room().cloned()?;
) -> ElementBox {
let icon;
let tooltip;
if room.read(cx).is_screen_sharing() {
icon = "icons/disable_screen_sharing_12.svg";
tooltip = "Stop Sharing Screen"
@ -256,226 +402,383 @@ impl CollabTitlebarItem {
}
let titlebar = &theme.workspace.titlebar;
Some(
MouseEventHandler::<ToggleScreenSharing>::new(0, cx, |state, _| {
let style = titlebar.call_control.style_for(state, false);
Svg::new(icon)
.with_color(style.color)
.constrained()
.with_width(style.icon_width)
.aligned()
.constrained()
.with_width(style.button_width)
.with_height(style.button_width)
.contained()
.with_style(style.container)
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(ToggleScreenSharing);
})
.with_tooltip::<ToggleScreenSharing, _>(
0,
tooltip.into(),
Some(Box::new(ToggleScreenSharing)),
theme.tooltip.clone(),
cx,
)
.aligned()
.boxed(),
)
}
fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
enum Share {}
let titlebar = &theme.workspace.titlebar;
MouseEventHandler::<Share>::new(0, cx, |state, _| {
let style = titlebar.share_button.style_for(state, false);
Label::new("Share".into(), style.text.clone())
MouseEventHandler::<ToggleScreenSharing>::new(0, cx, |state, _| {
let style = titlebar.call_control.style_for(state, false);
Svg::new(icon)
.with_color(style.color)
.constrained()
.with_width(style.icon_width)
.aligned()
.constrained()
.with_width(style.button_width)
.with_height(style.button_width)
.contained()
.with_style(style.container)
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
.with_tooltip::<Share, _>(
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(ToggleScreenSharing);
})
.with_tooltip::<ToggleScreenSharing, _>(
0,
"Share project with call participants".into(),
None,
tooltip.into(),
Some(Box::new(ToggleScreenSharing)),
theme.tooltip.clone(),
cx,
)
.aligned()
.contained()
.with_margin_left(theme.workspace.titlebar.avatar_margin)
.boxed()
}
fn render_in_call_share_unshare_button(
&self,
workspace: &ViewHandle<Workspace>,
theme: &Theme,
cx: &mut RenderContext<Self>,
) -> Option<ElementBox> {
let project = workspace.read(cx).project();
if project.read(cx).is_remote() {
return None;
}
let is_shared = project.read(cx).is_shared();
let label = if is_shared { "Unshare" } else { "Share" };
let tooltip = if is_shared {
"Unshare project from call participants"
} else {
"Share project with call participants"
};
let titlebar = &theme.workspace.titlebar;
enum ShareUnshare {}
Some(
Stack::new()
.with_child(
MouseEventHandler::<ShareUnshare>::new(0, cx, |state, _| {
//TODO: Ensure this button has consistant width for both text variations
let style = titlebar
.share_button
.style_for(state, self.contacts_popover.is_some());
Label::new(label, style.text.clone())
.contained()
.with_style(style.container)
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
if is_shared {
cx.dispatch_action(UnshareProject);
} else {
cx.dispatch_action(ShareProject);
}
})
.with_tooltip::<ShareUnshare, _>(
0,
tooltip.to_owned(),
None,
theme.tooltip.clone(),
cx,
)
.boxed(),
)
.aligned()
.contained()
.with_margin_left(theme.workspace.titlebar.item_spacing)
.boxed(),
)
}
fn render_user_menu_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
let titlebar = &theme.workspace.titlebar;
Stack::new()
.with_child(
MouseEventHandler::<ToggleUserMenu>::new(0, cx, |state, _| {
let style = titlebar.call_control.style_for(state, false);
Svg::new("icons/ellipsis_14.svg")
.with_color(style.color)
.constrained()
.with_width(style.icon_width)
.aligned()
.constrained()
.with_width(style.button_width)
.with_height(style.button_width)
.contained()
.with_style(style.container)
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(ToggleUserMenu);
})
.with_tooltip::<ToggleUserMenu, _>(
0,
"Toggle user menu".to_owned(),
Some(Box::new(ToggleUserMenu)),
theme.tooltip.clone(),
cx,
)
.contained()
.with_margin_left(theme.workspace.titlebar.item_spacing)
.boxed(),
)
.with_child(
ChildView::new(&self.user_menu, cx)
.aligned()
.bottom()
.right()
.boxed(),
)
.boxed()
}
fn render_sign_in_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
let titlebar = &theme.workspace.titlebar;
MouseEventHandler::<SignIn>::new(0, cx, |state, _| {
let style = titlebar.sign_in_prompt.style_for(state, false);
Label::new("Sign In", style.text.clone())
.contained()
.with_style(style.container)
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(SignIn);
})
.boxed()
}
fn render_contacts_popover_host<'a>(
&'a self,
_theme: &'a theme::Titlebar,
cx: &'a RenderContext<Self>,
) -> Option<ElementBox> {
self.contacts_popover.as_ref().map(|popover| {
Overlay::new(ChildView::new(popover, cx).boxed())
.with_fit_mode(OverlayFitMode::SwitchAnchor)
.with_anchor_corner(AnchorCorner::TopRight)
.with_z_index(999)
.aligned()
.bottom()
.right()
.boxed()
})
}
fn render_collaborators(
&self,
workspace: &ViewHandle<Workspace>,
theme: &Theme,
room: &ModelHandle<Room>,
cx: &mut RenderContext<Self>,
) -> Vec<ElementBox> {
let active_call = ActiveCall::global(cx);
if let Some(room) = active_call.read(cx).room().cloned() {
let project = workspace.read(cx).project().read(cx);
let mut participants = room
.read(cx)
.remote_participants()
.values()
.cloned()
.collect::<Vec<_>>();
participants.sort_by_key(|p| Some(project.collaborators().get(&p.peer_id)?.replica_id));
participants
.into_iter()
.filter_map(|participant| {
let project = workspace.read(cx).project().read(cx);
let replica_id = project
.collaborators()
.get(&participant.peer_id)
.map(|collaborator| collaborator.replica_id);
let user = participant.user.clone();
Some(self.render_avatar(
let mut participants = room
.read(cx)
.remote_participants()
.values()
.cloned()
.collect::<Vec<_>>();
participants.sort_by_cached_key(|p| p.user.github_login.clone());
participants
.into_iter()
.filter_map(|participant| {
let project = workspace.read(cx).project().read(cx);
let replica_id = project
.collaborators()
.get(&participant.peer_id)
.map(|collaborator| collaborator.replica_id);
let user = participant.user.clone();
Some(
Container::new(self.render_face_pile(
&user,
replica_id,
Some((
participant.peer_id,
&user.github_login,
participant.location,
)),
participant.peer_id,
Some(participant.location),
workspace,
theme,
cx,
))
})
.collect()
} else {
Default::default()
}
.with_margin_right(theme.workspace.titlebar.face_pile_spacing)
.boxed(),
)
})
.collect()
}
fn render_current_user(
&self,
workspace: &ViewHandle<Workspace>,
theme: &Theme,
user: &Arc<User>,
peer_id: PeerId,
cx: &mut RenderContext<Self>,
) -> Option<ElementBox> {
let user = workspace.read(cx).user_store().read(cx).current_user();
) -> ElementBox {
let replica_id = workspace.read(cx).project().read(cx).replica_id();
let status = *workspace.read(cx).client().status().borrow();
if let Some(user) = user {
Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx))
} else if matches!(status, client::Status::UpgradeRequired) {
None
} else {
Some(
MouseEventHandler::<Authenticate>::new(0, cx, |state, _| {
let style = theme
.workspace
.titlebar
.sign_in_prompt
.style_for(state, false);
Label::new("Sign in".to_string(), style.text.clone())
.contained()
.with_style(style.container)
.boxed()
})
.on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate))
.with_cursor_style(CursorStyle::PointingHand)
.aligned()
.boxed(),
)
}
Container::new(self.render_face_pile(
user,
Some(replica_id),
peer_id,
None,
workspace,
theme,
cx,
))
.with_margin_right(theme.workspace.titlebar.item_spacing)
.boxed()
}
fn render_avatar(
fn render_face_pile(
&self,
user: &User,
replica_id: Option<ReplicaId>,
peer: Option<(PeerId, &str, ParticipantLocation)>,
peer_id: PeerId,
location: Option<ParticipantLocation>,
workspace: &ViewHandle<Workspace>,
theme: &Theme,
cx: &mut RenderContext<Self>,
) -> ElementBox {
let is_followed = peer.map_or(false, |(peer_id, _, _)| {
workspace.read(cx).is_following(peer_id)
});
let project_id = workspace.read(cx).project().read(cx).remote_id();
let room = ActiveCall::global(cx).read(cx).room();
let is_being_followed = workspace.read(cx).is_being_followed(peer_id);
let followed_by_self = room
.and_then(|room| {
Some(
is_being_followed
&& room
.read(cx)
.followers_for(peer_id, project_id?)
.iter()
.any(|&follower| {
Some(follower) == workspace.read(cx).client().peer_id()
}),
)
})
.unwrap_or(false);
let mut avatar_style;
if let Some((_, _, location)) = peer.as_ref() {
if let ParticipantLocation::SharedProject { project_id } = *location {
if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() {
avatar_style = theme.workspace.titlebar.avatar;
} else {
avatar_style = theme.workspace.titlebar.inactive_avatar;
}
} else {
avatar_style = theme.workspace.titlebar.inactive_avatar;
}
} else {
avatar_style = theme.workspace.titlebar.avatar;
}
let leader_style = theme.workspace.titlebar.leader_avatar;
let follower_style = theme.workspace.titlebar.follower_avatar;
let mut replica_color = None;
let mut background_color = theme
.workspace
.titlebar
.container
.background_color
.unwrap_or_default();
if let Some(replica_id) = replica_id {
let color = theme.editor.replica_selection_style(replica_id).cursor;
replica_color = Some(color);
if is_followed {
avatar_style.border = Border::all(1.0, color);
if followed_by_self {
let selection = theme.editor.replica_selection_style(replica_id).selection;
background_color = Color::blend(selection, background_color);
background_color.a = 255;
}
}
let content = Stack::new()
let mut content = Stack::new()
.with_children(user.avatar.as_ref().map(|avatar| {
Image::new(avatar.clone())
.with_style(avatar_style)
.constrained()
.with_width(theme.workspace.titlebar.avatar_width)
.aligned()
.boxed()
let face_pile = FacePile::new(theme.workspace.titlebar.follower_avatar_overlap)
.with_child(Self::render_face(
avatar.clone(),
Self::location_style(workspace, location, leader_style, cx),
background_color,
))
.with_children(
(|| {
let project_id = project_id?;
let room = room?.read(cx);
let followers = room.followers_for(peer_id, project_id);
Some(followers.into_iter().flat_map(|&follower| {
let remote_participant =
room.remote_participant_for_peer_id(follower);
let avatar = remote_participant
.and_then(|p| p.user.avatar.clone())
.or_else(|| {
if follower == workspace.read(cx).client().peer_id()? {
workspace
.read(cx)
.user_store()
.read(cx)
.current_user()?
.avatar
.clone()
} else {
None
}
})?;
let location = remote_participant.map(|p| p.location);
Some(Self::render_face(
avatar.clone(),
Self::location_style(workspace, location, follower_style, cx),
background_color,
))
}))
})()
.into_iter()
.flatten(),
);
let mut container = face_pile
.contained()
.with_style(theme.workspace.titlebar.leader_selection);
if let Some(replica_id) = replica_id {
if followed_by_self {
let color = theme.editor.replica_selection_style(replica_id).selection;
container = container.with_background_color(color);
}
}
container.boxed()
}))
.with_children(replica_color.map(|replica_color| {
AvatarRibbon::new(replica_color)
.constrained()
.with_width(theme.workspace.titlebar.avatar_ribbon.width)
.with_height(theme.workspace.titlebar.avatar_ribbon.height)
.aligned()
.bottom()
.boxed()
}))
.constrained()
.with_width(theme.workspace.titlebar.avatar_width)
.contained()
.with_margin_left(theme.workspace.titlebar.avatar_margin)
.with_children((|| {
let replica_id = replica_id?;
let color = theme.editor.replica_selection_style(replica_id).cursor;
Some(
AvatarRibbon::new(color)
.constrained()
.with_width(theme.workspace.titlebar.avatar_ribbon.width)
.with_height(theme.workspace.titlebar.avatar_ribbon.height)
.aligned()
.bottom()
.boxed(),
)
})())
.boxed();
if let Some((peer_id, peer_github_login, location)) = peer {
if let Some(location) = location {
if let Some(replica_id) = replica_id {
MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| content)
content =
MouseEventHandler::<ToggleFollow>::new(replica_id.into(), cx, move |_, _| {
content
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(ToggleFollow(peer_id))
})
.with_tooltip::<ToggleFollow, _>(
peer_id.as_u64() as usize,
if is_followed {
format!("Unfollow {}", peer_github_login)
if is_being_followed {
format!("Unfollow {}", user.github_login)
} else {
format!("Follow {}", peer_github_login)
format!("Follow {}", user.github_login)
},
Some(Box::new(FollowNextCollaborator)),
theme.tooltip.clone(),
cx,
)
.boxed()
.boxed();
} else if let ParticipantLocation::SharedProject { project_id } = location {
let user_id = user.id;
MouseEventHandler::<JoinProject>::new(peer_id.as_u64() as usize, cx, move |_, _| {
content
})
content = MouseEventHandler::<JoinProject>::new(
peer_id.as_u64() as usize,
cx,
move |_, _| content,
)
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(JoinProject {
@ -485,29 +788,63 @@ impl CollabTitlebarItem {
})
.with_tooltip::<JoinProject, _>(
peer_id.as_u64() as usize,
format!("Follow {} into external project", peer_github_login),
format!("Follow {} into external project", user.github_login),
Some(Box::new(FollowNextCollaborator)),
theme.tooltip.clone(),
cx,
)
.boxed()
} else {
content
.boxed();
}
} else {
content
}
content
}
fn location_style(
workspace: &ViewHandle<Workspace>,
location: Option<ParticipantLocation>,
mut style: AvatarStyle,
cx: &RenderContext<Self>,
) -> AvatarStyle {
if let Some(location) = location {
if let ParticipantLocation::SharedProject { project_id } = location {
if Some(project_id) != workspace.read(cx).project().read(cx).remote_id() {
style.image.grayscale = true;
}
} else {
style.image.grayscale = true;
}
}
style
}
fn render_face(
avatar: Arc<ImageData>,
avatar_style: AvatarStyle,
background_color: Color,
) -> ElementBox {
Image::from_data(avatar)
.with_style(avatar_style.image)
.aligned()
.contained()
.with_background_color(background_color)
.with_corner_radius(avatar_style.outer_corner_radius)
.constrained()
.with_width(avatar_style.outer_width)
.with_height(avatar_style.outer_width)
.aligned()
.boxed()
}
fn render_connection_status(
&self,
workspace: &ViewHandle<Workspace>,
status: &client::Status,
cx: &mut RenderContext<Self>,
) -> Option<ElementBox> {
enum ConnectionStatusButton {}
let theme = &cx.global::<Settings>().theme.clone();
match &*workspace.read(cx).client().status().borrow() {
match status {
client::Status::ConnectionError
| client::Status::ConnectionLost
| client::Status::Reauthenticating { .. }
@ -531,7 +868,7 @@ impl CollabTitlebarItem {
client::Status::UpgradeRequired => Some(
MouseEventHandler::<ConnectionStatusButton>::new(0, cx, |_, _| {
Label::new(
"Please update Zed to collaborate".to_string(),
"Please update Zed to collaborate",
theme.workspace.titlebar.outdated_warning.text.clone(),
)
.contained()

View file

@ -1,8 +1,10 @@
mod collab_titlebar_item;
mod collaborator_list_popover;
mod contact_finder;
mod contact_list;
mod contact_notification;
mod contacts_popover;
mod face_pile;
mod incoming_call_notification;
mod notifications;
mod project_shared_notification;
@ -10,7 +12,7 @@ mod sharing_status_indicator;
use anyhow::anyhow;
use call::ActiveCall;
pub use collab_titlebar_item::{CollabTitlebarItem, ToggleCollaborationMenu};
pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu};
use gpui::{actions, MutableAppContext, Task};
use std::sync::Arc;
use workspace::{AppState, JoinProject, ToggleFollow, Workspace};
@ -84,6 +86,7 @@ fn join_project(action: &JoinProject, app_state: Arc<AppState>, cx: &mut Mutable
0,
project,
app_state.dock_default_item_factory,
app_state.background_actions,
cx,
);
(app_state.initialize_workspace)(&mut workspace, &app_state, cx);
@ -116,7 +119,7 @@ fn join_project(action: &JoinProject, app_state: Arc<AppState>, cx: &mut Mutable
});
if let Some(follow_peer_id) = follow_peer_id {
if !workspace.is_following(follow_peer_id) {
if !workspace.is_being_followed(follow_peer_id) {
workspace
.toggle_follow(&ToggleFollow(follow_peer_id), cx)
.map(|follow| follow.detach_and_log_err(cx));

View file

@ -0,0 +1,165 @@
use call::ActiveCall;
use client::UserStore;
use gpui::Action;
use gpui::{
actions, elements::*, Entity, ModelHandle, MouseButton, RenderContext, View, ViewContext,
};
use settings::Settings;
use crate::collab_titlebar_item::ToggleCollaboratorList;
pub(crate) enum Event {
Dismissed,
}
enum Collaborator {
SelfUser { username: String },
RemoteUser { username: String },
}
actions!(collaborator_list_popover, [NoOp]);
pub(crate) struct CollaboratorListPopover {
list_state: ListState,
}
impl Entity for CollaboratorListPopover {
type Event = Event;
}
impl View for CollaboratorListPopover {
fn ui_name() -> &'static str {
"CollaboratorListPopover"
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
let theme = cx.global::<Settings>().theme.clone();
MouseEventHandler::<Self>::new(0, cx, |_, _| {
List::new(self.list_state.clone())
.contained()
.with_style(theme.contacts_popover.container) //TODO: Change the name of this theme key
.constrained()
.with_width(theme.contacts_popover.width)
.with_height(theme.contacts_popover.height)
.boxed()
})
.on_down_out(MouseButton::Left, move |_, cx| {
cx.dispatch_action(ToggleCollaboratorList);
})
.boxed()
}
fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
cx.emit(Event::Dismissed);
}
}
impl CollaboratorListPopover {
pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
let active_call = ActiveCall::global(cx);
let mut collaborators = user_store
.read(cx)
.current_user()
.map(|u| Collaborator::SelfUser {
username: u.github_login.clone(),
})
.into_iter()
.collect::<Vec<_>>();
//TODO: What should the canonical sort here look like, consult contacts list implementation
if let Some(room) = active_call.read(cx).room() {
for participant in room.read(cx).remote_participants() {
collaborators.push(Collaborator::RemoteUser {
username: participant.1.user.github_login.clone(),
});
}
}
Self {
list_state: ListState::new(
collaborators.len(),
Orientation::Top,
0.,
cx,
move |_, index, cx| match &collaborators[index] {
Collaborator::SelfUser { username } => render_collaborator_list_entry(
index,
username,
None::<NoOp>,
None,
Svg::new("icons/chevron_right_12.svg"),
NoOp,
"Leave call".to_owned(),
cx,
),
Collaborator::RemoteUser { username } => render_collaborator_list_entry(
index,
username,
Some(NoOp),
Some(format!("Follow {username}")),
Svg::new("icons/x_mark_12.svg"),
NoOp,
format!("Remove {username} from call"),
cx,
),
},
),
}
}
}
fn render_collaborator_list_entry<UA: Action + Clone, IA: Action + Clone>(
index: usize,
username: &str,
username_action: Option<UA>,
username_tooltip: Option<String>,
icon: Svg,
icon_action: IA,
icon_tooltip: String,
cx: &mut RenderContext<CollaboratorListPopover>,
) -> ElementBox {
enum Username {}
enum UsernameTooltip {}
enum Icon {}
enum IconTooltip {}
let theme = &cx.global::<Settings>().theme;
let username_theme = theme.contact_list.contact_username.text.clone();
let tooltip_theme = theme.tooltip.clone();
let username = MouseEventHandler::<Username>::new(index, cx, |_, _| {
Label::new(username.to_owned(), username_theme.clone()).boxed()
})
.on_click(MouseButton::Left, move |_, cx| {
if let Some(username_action) = username_action.clone() {
cx.dispatch_action(username_action);
}
});
Flex::row()
.with_child(if let Some(username_tooltip) = username_tooltip {
username
.with_tooltip::<UsernameTooltip, _>(
index,
username_tooltip,
None,
tooltip_theme.clone(),
cx,
)
.boxed()
} else {
username.boxed()
})
.with_child(
MouseEventHandler::<Icon>::new(index, cx, |_, _| icon.boxed())
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(icon_action.clone())
})
.with_tooltip::<IconTooltip, _>(index, icon_tooltip, None, tooltip_theme, cx)
.boxed(),
)
.boxed()
}

View file

@ -1,7 +1,7 @@
use client::{ContactRequestStatus, User, UserStore};
use gpui::{
elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext, RenderContext,
Task, View, ViewContext, ViewHandle,
elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MouseState, MutableAppContext,
RenderContext, Task, View, ViewContext, ViewHandle,
};
use picker::{Picker, PickerDelegate};
use settings::Settings;
@ -33,7 +33,7 @@ impl View for ContactFinder {
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
ChildView::new(self.picker.clone(), cx).boxed()
ChildView::new(&self.picker, cx).boxed()
}
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@ -68,7 +68,7 @@ impl PickerDelegate for ContactFinder {
this.potential_contacts = potential_contacts.into();
cx.notify();
});
Ok(())
anyhow::Ok(())
}
.log_err()
.await;
@ -128,7 +128,7 @@ impl PickerDelegate for ContactFinder {
.style_for(mouse_state, selected);
Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::new(avatar)
Image::from_data(avatar)
.with_style(theme.contact_finder.contact_avatar)
.aligned()
.left()
@ -178,4 +178,14 @@ impl ContactFinder {
selected_index: 0,
}
}
pub fn editor_text(&self, cx: &AppContext) -> String {
self.picker.read(cx).query(cx)
}
pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext<Self>) -> Self {
self.picker
.update(cx, |picker, cx| picker.set_query(editor_text, cx));
self
}
}

View file

@ -1,3 +1,4 @@
use super::collab_titlebar_item::LeaveCall;
use crate::contacts_popover;
use call::ActiveCall;
use client::{proto::PeerId, Contact, User, UserStore};
@ -18,22 +19,20 @@ use serde::Deserialize;
use settings::Settings;
use std::{mem, sync::Arc};
use theme::IconButton;
use util::ResultExt;
use workspace::{JoinProject, OpenSharedScreen};
impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]);
impl_internal_actions!(contact_list, [ToggleExpanded, Call]);
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(ContactList::remove_contact);
cx.add_action(ContactList::respond_to_contact_request);
cx.add_action(ContactList::clear_filter);
cx.add_action(ContactList::cancel);
cx.add_action(ContactList::select_next);
cx.add_action(ContactList::select_prev);
cx.add_action(ContactList::confirm);
cx.add_action(ContactList::toggle_expanded);
cx.add_action(ContactList::call);
cx.add_action(ContactList::leave_call);
}
#[derive(Clone, PartialEq)]
@ -45,9 +44,6 @@ struct Call {
initial_project: Option<ModelHandle<Project>>,
}
#[derive(Copy, Clone, PartialEq)]
struct LeaveCall;
#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
enum Section {
ActiveCall,
@ -145,7 +141,10 @@ impl PartialEq for ContactEntry {
pub struct RequestContact(pub u64);
#[derive(Clone, Deserialize, PartialEq)]
pub struct RemoveContact(pub u64);
pub struct RemoveContact {
user_id: u64,
github_login: String,
}
#[derive(Clone, Deserialize, PartialEq)]
pub struct RespondToContactRequest {
@ -298,17 +297,39 @@ impl ContactList {
this
}
pub fn editor_text(&self, cx: &AppContext) -> String {
self.filter_editor.read(cx).text(cx)
}
pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext<Self>) -> Self {
self.filter_editor
.update(cx, |picker, cx| picker.set_text(editor_text, cx));
self
}
fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
let user_id = request.0;
let user_id = request.user_id;
let github_login = &request.github_login;
let user_store = self.user_store.clone();
let prompt_message = "Are you sure you want to remove this contact?";
let mut answer = cx.prompt(PromptLevel::Warning, prompt_message, &["Remove", "Cancel"]);
let prompt_message = format!(
"Are you sure you want to remove \"{}\" from your contacts?",
github_login
);
let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
let window_id = cx.window_id();
cx.spawn(|_, mut cx| async move {
if answer.next().await == Some(0) {
user_store
if let Err(e) = user_store
.update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
.await
.unwrap();
{
cx.prompt(
window_id,
PromptLevel::Info,
&format!("Failed to remove contact: {}", e),
&["Ok"],
);
}
}
})
.detach();
@ -326,7 +347,7 @@ impl ContactList {
.detach();
}
fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
let did_clear = self.filter_editor.update(cx, |editor, cx| {
if editor.buffer().read(cx).len(cx) > 0 {
editor.set_text("", cx);
@ -335,6 +356,7 @@ impl ContactList {
false
}
});
if !did_clear {
cx.emit(Event::Dismissed);
}
@ -729,7 +751,7 @@ impl ContactList {
) -> ElementBox {
Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::new(avatar)
Image::from_data(avatar)
.with_style(theme.contact_avatar)
.aligned()
.left()
@ -749,7 +771,7 @@ impl ContactList {
)
.with_children(if is_pending {
Some(
Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
Label::new("Calling", theme.calling_indicator.text.clone())
.contained()
.with_style(theme.calling_indicator.container)
.aligned()
@ -950,7 +972,7 @@ impl ContactList {
.boxed(),
)
.with_child(
Label::new("Screen".into(), row.name.text.clone())
Label::new("Screen", row.name.text.clone())
.aligned()
.left()
.contained()
@ -980,6 +1002,7 @@ impl ContactList {
cx: &mut RenderContext<Self>,
) -> ElementBox {
enum Header {}
enum LeaveCallContactList {}
let header_style = theme
.header_row
@ -992,9 +1015,9 @@ impl ContactList {
};
let leave_call = if section == Section::ActiveCall {
Some(
MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
MouseEventHandler::<LeaveCallContactList>::new(0, cx, |state, _| {
let style = theme.leave_call.style_for(state, false);
Label::new("Leave Session".into(), style.text.clone())
Label::new("Leave Call", style.text.clone())
.contained()
.with_style(style.container)
.boxed()
@ -1026,7 +1049,7 @@ impl ContactList {
.boxed(),
)
.with_child(
Label::new(text.to_string(), header_style.text.clone())
Label::new(text, header_style.text.clone())
.aligned()
.left()
.contained()
@ -1059,6 +1082,7 @@ impl ContactList {
let online = contact.online;
let busy = contact.busy || calling;
let user_id = contact.user.id;
let github_login = contact.user.github_login.clone();
let initial_project = project.clone();
let mut element =
MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, cx| {
@ -1082,7 +1106,7 @@ impl ContactList {
};
Stack::new()
.with_child(
Image::new(avatar)
Image::from_data(avatar)
.with_style(theme.contact_avatar)
.aligned()
.left()
@ -1119,14 +1143,17 @@ impl ContactList {
.with_padding(Padding::uniform(2.))
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(RemoveContact(user_id))
cx.dispatch_action(RemoveContact {
user_id,
github_login: github_login.clone(),
})
})
.flex_float()
.boxed(),
)
.with_children(if calling {
Some(
Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
Label::new("Calling", theme.calling_indicator.text.clone())
.contained()
.with_style(theme.calling_indicator.container)
.aligned()
@ -1175,7 +1202,7 @@ impl ContactList {
let mut row = Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::new(avatar)
Image::from_data(avatar)
.with_style(theme.contact_avatar)
.aligned()
.left()
@ -1195,6 +1222,7 @@ impl ContactList {
);
let user_id = user.id;
let github_login = user.github_login.clone();
let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
let button_spacing = theme.contact_button_spacing;
@ -1256,7 +1284,10 @@ impl ContactList {
.with_padding(Padding::uniform(2.))
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(RemoveContact(user_id))
cx.dispatch_action(RemoveContact {
user_id,
github_login: github_login.clone(),
})
})
.flex_float()
.boxed(),
@ -1283,12 +1314,6 @@ impl ContactList {
})
.detach_and_log_err(cx);
}
fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
ActiveCall::global(cx)
.update(cx, |call, cx| call.hang_up(cx))
.log_err();
}
}
impl Entity for ContactList {
@ -1302,7 +1327,7 @@ impl View for ContactList {
fn keymap_context(&self, _: &AppContext) -> KeymapContext {
let mut cx = Self::default_keymap_context();
cx.set.insert("menu".into());
cx.add_identifier("menu");
cx
}
@ -1314,7 +1339,7 @@ impl View for ContactList {
.with_child(
Flex::row()
.with_child(
ChildView::new(self.filter_editor.clone(), cx)
ChildView::new(&self.filter_editor, cx)
.contained()
.with_style(theme.contact_list.user_query_editor.container)
.flex(1., true)
@ -1334,7 +1359,7 @@ impl View for ContactList {
})
.with_tooltip::<AddContact, _>(
0,
"Add contact".into(),
"Search for new contact".into(),
None,
theme.tooltip.clone(),
cx,

View file

@ -1,8 +1,8 @@
use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleCollaborationMenu};
use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleContactsMenu};
use client::UserStore;
use gpui::{
actions, elements::*, ClipboardItem, CursorStyle, Entity, ModelHandle, MouseButton,
MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
actions, elements::*, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, View,
ViewContext, ViewHandle,
};
use project::Project;
use settings::Settings;
@ -43,19 +43,23 @@ impl ContactsPopover {
user_store,
_subscription: None,
};
this.show_contact_list(cx);
this.show_contact_list(String::new(), cx);
this
}
fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext<Self>) {
match &self.child {
Child::ContactList(_) => self.show_contact_finder(cx),
Child::ContactFinder(_) => self.show_contact_list(cx),
Child::ContactList(list) => self.show_contact_finder(list.read(cx).editor_text(cx), cx),
Child::ContactFinder(finder) => {
self.show_contact_list(finder.read(cx).editor_text(cx), cx)
}
}
}
fn show_contact_finder(&mut self, cx: &mut ViewContext<ContactsPopover>) {
let child = cx.add_view(|cx| ContactFinder::new(self.user_store.clone(), cx));
fn show_contact_finder(&mut self, editor_text: String, cx: &mut ViewContext<ContactsPopover>) {
let child = cx.add_view(|cx| {
ContactFinder::new(self.user_store.clone(), cx).with_editor_text(editor_text, cx)
});
cx.focus(&child);
self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
crate::contact_finder::Event::Dismissed => cx.emit(Event::Dismissed),
@ -64,9 +68,11 @@ impl ContactsPopover {
cx.notify();
}
fn show_contact_list(&mut self, cx: &mut ViewContext<ContactsPopover>) {
let child =
cx.add_view(|cx| ContactList::new(self.project.clone(), self.user_store.clone(), cx));
fn show_contact_list(&mut self, editor_text: String, cx: &mut ViewContext<ContactsPopover>) {
let child = cx.add_view(|cx| {
ContactList::new(self.project.clone(), self.user_store.clone(), cx)
.with_editor_text(editor_text, cx)
});
cx.focus(&child);
self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed),
@ -92,61 +98,9 @@ impl View for ContactsPopover {
Child::ContactFinder(child) => ChildView::new(child, cx),
};
MouseEventHandler::<ContactsPopover>::new(0, cx, |_, cx| {
MouseEventHandler::<ContactsPopover>::new(0, cx, |_, _| {
Flex::column()
.with_child(child.flex(1., true).boxed())
.with_children(
self.user_store
.read(cx)
.invite_info()
.cloned()
.and_then(|info| {
enum InviteLink {}
if info.count > 0 {
Some(
MouseEventHandler::<InviteLink>::new(0, cx, |state, cx| {
let style = theme
.contacts_popover
.invite_row
.style_for(state, false)
.clone();
let copied =
cx.read_from_clipboard().map_or(false, |item| {
item.text().as_str() == info.url.as_ref()
});
Label::new(
format!(
"{} invite link ({} left)",
if copied { "Copied" } else { "Copy" },
info.count
),
style.label.clone(),
)
.aligned()
.left()
.constrained()
.with_height(theme.contacts_popover.invite_row_height)
.contained()
.with_style(style.container)
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new(
info.url.to_string(),
));
cx.notify();
})
.boxed(),
)
} else {
None
}
}),
)
.contained()
.with_style(theme.contacts_popover.container)
.constrained()
@ -155,7 +109,7 @@ impl View for ContactsPopover {
.boxed()
})
.on_down_out(MouseButton::Left, move |_, cx| {
cx.dispatch_action(ToggleCollaborationMenu);
cx.dispatch_action(ToggleContactsMenu);
})
.boxed()
}

View file

@ -0,0 +1,101 @@
use std::ops::Range;
use gpui::{
geometry::{
rect::RectF,
vector::{vec2f, Vector2F},
},
json::ToJson,
serde_json::{self, json},
Axis, DebugContext, Element, ElementBox, MeasurementContext, PaintContext,
};
pub(crate) struct FacePile {
overlap: f32,
faces: Vec<ElementBox>,
}
impl FacePile {
pub fn new(overlap: f32) -> FacePile {
FacePile {
overlap,
faces: Vec::new(),
}
}
}
impl Element for FacePile {
type LayoutState = ();
type PaintState = ();
fn layout(
&mut self,
constraint: gpui::SizeConstraint,
cx: &mut gpui::LayoutContext,
) -> (Vector2F, Self::LayoutState) {
debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY);
let mut width = 0.;
for face in &mut self.faces {
width += face.layout(constraint, cx).x();
}
width -= self.overlap * self.faces.len().saturating_sub(1) as f32;
(Vector2F::new(width, constraint.max.y()), ())
}
fn paint(
&mut self,
bounds: RectF,
visible_bounds: RectF,
_layout: &mut Self::LayoutState,
cx: &mut PaintContext,
) -> Self::PaintState {
let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
let origin_y = bounds.upper_right().y();
let mut origin_x = bounds.upper_right().x();
for face in self.faces.iter_mut().rev() {
let size = face.size();
origin_x -= size.x();
cx.paint_layer(None, |cx| {
face.paint(vec2f(origin_x, origin_y), visible_bounds, cx);
});
origin_x += self.overlap;
}
()
}
fn rect_for_text_range(
&self,
_: Range<usize>,
_: RectF,
_: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
_: &MeasurementContext,
) -> Option<RectF> {
None
}
fn debug(
&self,
bounds: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
_: &DebugContext,
) -> serde_json::Value {
json!({
"type": "FacePile",
"bounds": bounds.to_json()
})
}
}
impl Extend<ElementBox> for FacePile {
fn extend<T: IntoIterator<Item = ElementBox>>(&mut self, children: T) {
self.faces.extend(children);
}
}

View file

@ -108,7 +108,7 @@ impl IncomingCallNotification {
.unwrap_or(&default_project);
Flex::row()
.with_children(self.call.calling_user.avatar.clone().map(|avatar| {
Image::new(avatar)
Image::from_data(avatar)
.with_style(theme.caller_avatar)
.aligned()
.boxed()
@ -172,7 +172,7 @@ impl IncomingCallNotification {
.with_child(
MouseEventHandler::<Accept>::new(0, cx, |_, cx| {
let theme = &cx.global::<Settings>().theme.incoming_call_notification;
Label::new("Accept".to_string(), theme.accept_button.text.clone())
Label::new("Accept", theme.accept_button.text.clone())
.aligned()
.contained()
.with_style(theme.accept_button.container)
@ -188,7 +188,7 @@ impl IncomingCallNotification {
.with_child(
MouseEventHandler::<Decline>::new(0, cx, |_, cx| {
let theme = &cx.global::<Settings>().theme.incoming_call_notification;
Label::new("Decline".to_string(), theme.decline_button.text.clone())
Label::new("Decline", theme.decline_button.text.clone())
.aligned()
.contained()
.with_style(theme.decline_button.container)

View file

@ -11,8 +11,8 @@ enum Button {}
pub fn render_user_notification<V: View, A: Action + Clone>(
user: Arc<User>,
title: &str,
body: Option<&str>,
title: &'static str,
body: Option<&'static str>,
dismiss_action: A,
buttons: Vec<(&'static str, Box<dyn Action>)>,
cx: &mut RenderContext<V>,
@ -24,7 +24,7 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
.with_child(
Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::new(avatar)
Image::from_data(avatar)
.with_style(theme.header_avatar)
.aligned()
.constrained()
@ -83,7 +83,7 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
.named("contact notification header"),
)
.with_children(body.map(|body| {
Label::new(body.to_string(), theme.body_message.text.clone())
Label::new(body, theme.body_message.text.clone())
.contained()
.with_style(theme.body_message.container)
.boxed()
@ -97,7 +97,7 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
|(ix, (message, action))| {
MouseEventHandler::<Button>::new(ix, cx, |state, _| {
let button = theme.button.style_for(state, false);
Label::new(message.to_string(), button.text.clone())
Label::new(message, button.text.clone())
.contained()
.with_style(button.container)
.boxed()

View file

@ -108,7 +108,7 @@ impl ProjectSharedNotification {
let theme = &cx.global::<Settings>().theme.project_shared_notification;
Flex::row()
.with_children(self.owner.avatar.clone().map(|avatar| {
Image::new(avatar)
Image::from_data(avatar)
.with_style(theme.owner_avatar)
.aligned()
.boxed()
@ -175,7 +175,7 @@ impl ProjectSharedNotification {
.with_child(
MouseEventHandler::<Open>::new(0, cx, |_, cx| {
let theme = &cx.global::<Settings>().theme.project_shared_notification;
Label::new("Open".to_string(), theme.open_button.text.clone())
Label::new("Open", theme.open_button.text.clone())
.aligned()
.contained()
.with_style(theme.open_button.container)
@ -194,7 +194,7 @@ impl ProjectSharedNotification {
.with_child(
MouseEventHandler::<Dismiss>::new(0, cx, |_, cx| {
let theme = &cx.global::<Settings>().theme.project_shared_notification;
Label::new("Dismiss".to_string(), theme.dismiss_button.text.clone())
Label::new("Dismiss", theme.dismiss_button.text.clone())
.aligned()
.contained()
.with_style(theme.dismiss_button.container)

View file

@ -21,6 +21,8 @@ pub fn init(cx: &mut MutableAppContext) {
} else if let Some((window_id, _)) = status_indicator.take() {
cx.remove_status_bar_item(window_id);
}
} else if let Some((window_id, _)) = status_indicator.take() {
cx.remove_status_bar_item(window_id);
}
})
.detach();

View file

@ -24,3 +24,10 @@ pub type HashMap<K, V> = std::collections::HashMap<K, V>;
pub type HashSet<T> = std::collections::HashSet<T>;
pub use std::collections::*;
// NEW TYPES
#[derive(Default)]
pub struct CommandPaletteFilter {
pub filtered_namespaces: HashSet<&'static str>,
}

View file

@ -24,7 +24,7 @@ workspace = { path = "../workspace" }
gpui = { path = "../gpui", features = ["test-support"] }
editor = { path = "../editor", features = ["test-support"] }
project = { path = "../project", features = ["test-support"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_json = { workspace = true }
workspace = { path = "../workspace", features = ["test-support"] }
ctor = "0.1"
env_logger = "0.9"

View file

@ -1,4 +1,4 @@
use collections::HashSet;
use collections::CommandPaletteFilter;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
actions,
@ -12,11 +12,6 @@ use settings::Settings;
use std::cmp;
use workspace::Workspace;
#[derive(Default)]
pub struct CommandPaletteFilter {
pub filtered_namespaces: HashSet<&'static str>,
}
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(CommandPalette::toggle);
Picker::<CommandPalette>::init(cx);
@ -90,7 +85,7 @@ impl CommandPalette {
.unwrap_or_else(|| workspace.id());
cx.as_mut().defer(move |cx| {
let this = cx.add_view(workspace.clone(), |cx| Self::new(focused_view_id, cx));
let this = cx.add_view(&workspace, |cx| Self::new(focused_view_id, cx));
workspace.update(cx, |workspace, cx| {
workspace.toggle_modal(cx, |_, cx| {
cx.subscribe(&this, Self::on_event).detach();
@ -134,7 +129,7 @@ impl View for CommandPalette {
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
ChildView::new(self.picker.clone(), cx).boxed()
ChildView::new(&self.picker, cx).boxed()
}
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@ -257,7 +252,7 @@ impl PickerDelegate for CommandPalette {
.filter_map(|(modifier, label)| {
if modifier {
Some(
Label::new(label.into(), key_style.label.clone())
Label::new(label, key_style.label.clone())
.contained()
.with_style(key_style.container)
.boxed(),
@ -352,9 +347,7 @@ mod tests {
});
let project = Project::test(app_state.fs.clone(), [], cx).await;
let (_, workspace) = cx.add_window(|cx| {
Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
});
let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
let editor = cx.add_view(&workspace, |cx| {
let mut editor = Editor::single_line(None, cx);
editor.set_text("abc", cx);
@ -362,7 +355,7 @@ mod tests {
});
workspace.update(cx, |workspace, cx| {
cx.focus(editor.clone());
cx.focus(&editor);
workspace.add_item(Box::new(editor.clone()), cx)
});

View file

@ -1,11 +1,13 @@
use gpui::{
elements::*, geometry::vector::Vector2F, impl_internal_actions, keymap_matcher::KeymapContext,
platform::CursorStyle, Action, AnyViewHandle, AppContext, Axis, Entity, MouseButton,
MutableAppContext, RenderContext, SizeConstraint, Subscription, View, ViewContext,
MouseState, MutableAppContext, RenderContext, SizeConstraint, Subscription, View, ViewContext,
};
use menu::*;
use settings::Settings;
use std::{any::TypeId, time::Duration};
use std::{any::TypeId, borrow::Cow, time::Duration};
pub type StaticItem = Box<dyn Fn(&mut MutableAppContext) -> ElementBox>;
#[derive(Copy, Clone, PartialEq)]
struct Clicked;
@ -22,19 +24,71 @@ pub fn init(cx: &mut MutableAppContext) {
cx.add_action(ContextMenu::cancel);
}
pub enum ContextMenuItem {
Item {
label: String,
type ContextMenuItemBuilder = Box<dyn Fn(&mut MouseState, &theme::ContextMenuItem) -> ElementBox>;
pub enum ContextMenuItemLabel {
String(Cow<'static, str>),
Element(ContextMenuItemBuilder),
}
pub enum ContextMenuAction {
ParentAction {
action: Box<dyn Action>,
},
ViewAction {
action: Box<dyn Action>,
for_view: usize,
},
}
impl ContextMenuAction {
fn id(&self) -> TypeId {
match self {
ContextMenuAction::ParentAction { action } => action.id(),
ContextMenuAction::ViewAction { action, .. } => action.id(),
}
}
}
pub enum ContextMenuItem {
Item {
label: ContextMenuItemLabel,
action: ContextMenuAction,
},
Static(StaticItem),
Separator,
}
impl ContextMenuItem {
pub fn item(label: impl ToString, action: impl 'static + Action) -> Self {
pub fn element_item(label: ContextMenuItemBuilder, action: impl 'static + Action) -> Self {
Self::Item {
label: label.to_string(),
action: Box::new(action),
label: ContextMenuItemLabel::Element(label),
action: ContextMenuAction::ParentAction {
action: Box::new(action),
},
}
}
pub fn item(label: impl Into<Cow<'static, str>>, action: impl 'static + Action) -> Self {
Self::Item {
label: ContextMenuItemLabel::String(label.into()),
action: ContextMenuAction::ParentAction {
action: Box::new(action),
},
}
}
pub fn item_for_view(
label: impl Into<Cow<'static, str>>,
view_id: usize,
action: impl 'static + Action,
) -> Self {
Self::Item {
label: ContextMenuItemLabel::String(label.into()),
action: ContextMenuAction::ViewAction {
action: Box::new(action),
for_view: view_id,
},
}
}
@ -42,14 +96,14 @@ impl ContextMenuItem {
Self::Separator
}
fn is_separator(&self) -> bool {
matches!(self, Self::Separator)
fn is_action(&self) -> bool {
matches!(self, Self::Item { .. })
}
fn action_id(&self) -> Option<TypeId> {
match self {
ContextMenuItem::Item { action, .. } => Some(action.id()),
ContextMenuItem::Separator => None,
ContextMenuItem::Static(..) | ContextMenuItem::Separator => None,
}
}
}
@ -58,6 +112,7 @@ pub struct ContextMenu {
show_count: usize,
anchor_position: Vector2F,
anchor_corner: AnchorCorner,
position_mode: OverlayPositionMode,
items: Vec<ContextMenuItem>,
selected_index: Option<usize>,
visible: bool,
@ -78,7 +133,7 @@ impl View for ContextMenu {
fn keymap_context(&self, _: &AppContext) -> KeymapContext {
let mut cx = Self::default_keymap_context();
cx.set.insert("menu".into());
cx.add_identifier("menu");
cx
}
@ -105,6 +160,7 @@ impl View for ContextMenu {
.with_fit_mode(OverlayFitMode::SnapToWindow)
.with_anchor_position(self.anchor_position)
.with_anchor_corner(self.anchor_corner)
.with_position_mode(self.position_mode)
.boxed()
}
@ -121,6 +177,7 @@ impl ContextMenu {
show_count: 0,
anchor_position: Default::default(),
anchor_corner: AnchorCorner::TopLeft,
position_mode: OverlayPositionMode::Window,
items: Default::default(),
selected_index: Default::default(),
visible: Default::default(),
@ -162,7 +219,15 @@ impl ContextMenu {
fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
if let Some(ix) = self.selected_index {
if let Some(ContextMenuItem::Item { action, .. }) = self.items.get(ix) {
cx.dispatch_any_action(action.boxed_clone());
match action {
ContextMenuAction::ParentAction { action } => {
cx.dispatch_any_action(action.boxed_clone())
}
ContextMenuAction::ViewAction { action, for_view } => {
let window_id = cx.window_id();
cx.dispatch_any_action_at(window_id, *for_view, action.boxed_clone())
}
};
self.reset(cx);
}
}
@ -188,13 +253,13 @@ impl ContextMenu {
}
fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
self.selected_index = self.items.iter().position(|item| !item.is_separator());
self.selected_index = self.items.iter().position(|item| item.is_action());
cx.notify();
}
fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
for (ix, item) in self.items.iter().enumerate().rev() {
if !item.is_separator() {
if item.is_action() {
self.selected_index = Some(ix);
cx.notify();
break;
@ -205,7 +270,7 @@ impl ContextMenu {
fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
if let Some(ix) = self.selected_index {
for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
if !item.is_separator() {
if item.is_action() {
self.selected_index = Some(ix);
cx.notify();
break;
@ -219,7 +284,7 @@ impl ContextMenu {
fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
if let Some(ix) = self.selected_index {
for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
if !item.is_separator() {
if item.is_action() {
self.selected_index = Some(ix);
cx.notify();
break;
@ -234,7 +299,7 @@ impl ContextMenu {
&mut self,
anchor_position: Vector2F,
anchor_corner: AnchorCorner,
items: impl IntoIterator<Item = ContextMenuItem>,
items: Vec<ContextMenuItem>,
cx: &mut ViewContext<Self>,
) {
let mut items = items.into_iter().peekable();
@ -254,6 +319,10 @@ impl ContextMenu {
cx.notify();
}
pub fn set_position_mode(&mut self, mode: OverlayPositionMode) {
self.position_mode = mode;
}
fn render_menu_for_measurement(&self, cx: &mut RenderContext<Self>) -> impl Element {
let window_id = cx.window_id();
let style = cx.global::<Settings>().theme.context_menu.clone();
@ -268,11 +337,21 @@ impl ContextMenu {
Some(ix) == self.selected_index,
);
Label::new(label.to_string(), style.label.clone())
.contained()
.with_style(style.container)
.boxed()
match label {
ContextMenuItemLabel::String(label) => {
Label::new(label.to_string(), style.label.clone())
.contained()
.with_style(style.container)
.boxed()
}
ContextMenuItemLabel::Element(element) => {
element(&mut Default::default(), style)
}
}
}
ContextMenuItem::Static(f) => f(cx),
ContextMenuItem::Separator => Empty::new()
.collapsed()
.contained()
@ -293,15 +372,27 @@ impl ContextMenu {
&mut Default::default(),
Some(ix) == self.selected_index,
);
let (action, view_id) = match action {
ContextMenuAction::ParentAction { action } => {
(action.boxed_clone(), self.parent_view_id)
}
ContextMenuAction::ViewAction { action, for_view } => {
(action.boxed_clone(), *for_view)
}
};
KeystrokeLabel::new(
window_id,
self.parent_view_id,
view_id,
action.boxed_clone(),
style.keystroke.container,
style.keystroke.text.clone(),
)
.boxed()
}
ContextMenuItem::Static(_) => Empty::new().boxed(),
ContextMenuItem::Separator => Empty::new()
.collapsed()
.constrained()
@ -331,22 +422,34 @@ impl ContextMenu {
.with_children(self.items.iter().enumerate().map(|(ix, item)| {
match item {
ContextMenuItem::Item { label, action } => {
let action = action.boxed_clone();
let (action, view_id) = match action {
ContextMenuAction::ParentAction { action } => {
(action.boxed_clone(), self.parent_view_id)
}
ContextMenuAction::ViewAction { action, for_view } => {
(action.boxed_clone(), *for_view)
}
};
MouseEventHandler::<MenuItem>::new(ix, cx, |state, _| {
let style =
style.item.style_for(state, Some(ix) == self.selected_index);
Flex::row()
.with_child(
Label::new(label.to_string(), style.label.clone())
.contained()
.boxed(),
)
.with_child(match label {
ContextMenuItemLabel::String(label) => {
Label::new(label.clone(), style.label.clone())
.contained()
.boxed()
}
ContextMenuItemLabel::Element(element) => {
element(state, style)
}
})
.with_child({
KeystrokeLabel::new(
window_id,
self.parent_view_id,
view_id,
action.boxed_clone(),
style.keystroke.container,
style.keystroke.text.clone(),
@ -359,13 +462,19 @@ impl ContextMenu {
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_up(MouseButton::Left, |_, _| {}) // Capture these events
.on_down(MouseButton::Left, |_, _| {}) // Capture these events
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(Clicked);
cx.dispatch_any_action(action.boxed_clone());
let window_id = cx.window_id();
cx.dispatch_any_action_at(window_id, view_id, action.boxed_clone());
})
.on_drag(MouseButton::Left, |_, _| {})
.boxed()
}
ContextMenuItem::Static(f) => f(cx),
ContextMenuItem::Separator => Empty::new()
.constrained()
.with_height(1.)

38
crates/copilot/Cargo.toml Normal file
View file

@ -0,0 +1,38 @@
[package]
name = "copilot"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
path = "src/copilot.rs"
doctest = false
[dependencies]
collections = { path = "../collections" }
context_menu = { path = "../context_menu" }
gpui = { path = "../gpui" }
language = { path = "../language" }
settings = { path = "../settings" }
theme = { path = "../theme" }
lsp = { path = "../lsp" }
node_runtime = { path = "../node_runtime"}
util = { path = "../util" }
client = { path = "../client" }
async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] }
async-tar = "0.4.2"
anyhow = "1.0"
log = "0.4"
serde = { workspace = true }
serde_derive = { workspace = true }
smol = "1.2.5"
futures = "0.3"
[dev-dependencies]
gpui = { path = "../gpui", features = ["test-support"] }
language = { path = "../language", features = ["test-support"] }
settings = { path = "../settings", features = ["test-support"] }
lsp = { path = "../lsp", features = ["test-support"] }
util = { path = "../util", features = ["test-support"] }
client = { path = "../client", features = ["test-support"] }
workspace = { path = "../workspace", features = ["test-support"] }

View file

@ -0,0 +1,695 @@
mod request;
mod sign_in;
use anyhow::{anyhow, Context, Result};
use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive;
use client::Client;
use collections::HashMap;
use futures::{future::Shared, Future, FutureExt, TryFutureExt};
use gpui::{
actions, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext,
Task,
};
use language::{point_from_lsp, point_to_lsp, Anchor, Bias, Buffer, Language, ToPointUtf16};
use log::{debug, error};
use lsp::LanguageServer;
use node_runtime::NodeRuntime;
use request::{LogMessage, StatusNotification};
use settings::Settings;
use smol::{fs, io::BufReader, stream::StreamExt};
use std::{
ffi::OsString,
ops::Range,
path::{Path, PathBuf},
sync::Arc,
};
use util::{
fs::remove_matching, github::latest_github_release, http::HttpClient, paths, ResultExt,
};
const COPILOT_AUTH_NAMESPACE: &'static str = "copilot_auth";
actions!(copilot_auth, [SignIn, SignOut]);
const COPILOT_NAMESPACE: &'static str = "copilot";
actions!(
copilot,
[NextSuggestion, PreviousSuggestion, Toggle, Reinstall]
);
pub fn init(client: Arc<Client>, node_runtime: Arc<NodeRuntime>, cx: &mut MutableAppContext) {
let copilot = cx.add_model(|cx| Copilot::start(client.http_client(), node_runtime, cx));
cx.set_global(copilot.clone());
cx.add_global_action(|_: &SignIn, cx| {
let copilot = Copilot::global(cx).unwrap();
copilot
.update(cx, |copilot, cx| copilot.sign_in(cx))
.detach_and_log_err(cx);
});
cx.add_global_action(|_: &SignOut, cx| {
let copilot = Copilot::global(cx).unwrap();
copilot
.update(cx, |copilot, cx| copilot.sign_out(cx))
.detach_and_log_err(cx);
});
cx.add_global_action(|_: &Reinstall, cx| {
let copilot = Copilot::global(cx).unwrap();
copilot
.update(cx, |copilot, cx| copilot.reinstall(cx))
.detach();
});
cx.observe(&copilot, |handle, cx| {
let status = handle.read(cx).status();
cx.update_global::<collections::CommandPaletteFilter, _, _>(
move |filter, _cx| match status {
Status::Disabled => {
filter.filtered_namespaces.insert(COPILOT_NAMESPACE);
filter.filtered_namespaces.insert(COPILOT_AUTH_NAMESPACE);
}
Status::Authorized => {
filter.filtered_namespaces.remove(COPILOT_NAMESPACE);
filter.filtered_namespaces.remove(COPILOT_AUTH_NAMESPACE);
}
_ => {
filter.filtered_namespaces.insert(COPILOT_NAMESPACE);
filter.filtered_namespaces.remove(COPILOT_AUTH_NAMESPACE);
}
},
);
})
.detach();
sign_in::init(cx);
}
enum CopilotServer {
Disabled,
Starting {
task: Shared<Task<()>>,
},
Error(Arc<str>),
Started {
server: Arc<LanguageServer>,
status: SignInStatus,
subscriptions_by_buffer_id: HashMap<usize, gpui::Subscription>,
},
}
#[derive(Clone, Debug)]
enum SignInStatus {
Authorized {
_user: String,
},
Unauthorized {
_user: String,
},
SigningIn {
prompt: Option<request::PromptUserDeviceFlow>,
task: Shared<Task<Result<(), Arc<anyhow::Error>>>>,
},
SignedOut,
}
#[derive(Debug, Clone)]
pub enum Status {
Starting {
task: Shared<Task<()>>,
},
Error(Arc<str>),
Disabled,
SignedOut,
SigningIn {
prompt: Option<request::PromptUserDeviceFlow>,
},
Unauthorized,
Authorized,
}
impl Status {
pub fn is_authorized(&self) -> bool {
matches!(self, Status::Authorized)
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Completion {
pub range: Range<Anchor>,
pub text: String,
}
pub struct Copilot {
http: Arc<dyn HttpClient>,
node_runtime: Arc<NodeRuntime>,
server: CopilotServer,
}
impl Entity for Copilot {
type Event = ();
}
impl Copilot {
pub fn starting_task(&self) -> Option<Shared<Task<()>>> {
match self.server {
CopilotServer::Starting { ref task } => Some(task.clone()),
_ => None,
}
}
pub fn global(cx: &AppContext) -> Option<ModelHandle<Self>> {
if cx.has_global::<ModelHandle<Self>>() {
Some(cx.global::<ModelHandle<Self>>().clone())
} else {
None
}
}
fn start(
http: Arc<dyn HttpClient>,
node_runtime: Arc<NodeRuntime>,
cx: &mut ModelContext<Self>,
) -> Self {
cx.observe_global::<Settings, _>({
let http = http.clone();
let node_runtime = node_runtime.clone();
move |this, cx| {
if cx.global::<Settings>().enable_copilot_integration {
if matches!(this.server, CopilotServer::Disabled) {
let start_task = cx
.spawn({
let http = http.clone();
let node_runtime = node_runtime.clone();
move |this, cx| {
Self::start_language_server(http, node_runtime, this, cx)
}
})
.shared();
this.server = CopilotServer::Starting { task: start_task };
cx.notify();
}
} else {
this.server = CopilotServer::Disabled;
cx.notify();
}
}
})
.detach();
if cx.global::<Settings>().enable_copilot_integration {
let start_task = cx
.spawn({
let http = http.clone();
let node_runtime = node_runtime.clone();
move |this, cx| Self::start_language_server(http, node_runtime, this, cx)
})
.shared();
Self {
http,
node_runtime,
server: CopilotServer::Starting { task: start_task },
}
} else {
Self {
http,
node_runtime,
server: CopilotServer::Disabled,
}
}
}
fn start_language_server(
http: Arc<dyn HttpClient>,
node_runtime: Arc<NodeRuntime>,
this: ModelHandle<Self>,
mut cx: AsyncAppContext,
) -> impl Future<Output = ()> {
async move {
let start_language_server = async {
let server_path = get_copilot_lsp(http).await?;
let node_path = node_runtime.binary_path().await?;
let arguments: &[OsString] = &[server_path.into(), "--stdio".into()];
let server = LanguageServer::new(
0,
&node_path,
arguments,
Path::new("/"),
None,
cx.clone(),
)?;
let server = server.initialize(Default::default()).await?;
let status = server
.request::<request::CheckStatus>(request::CheckStatusParams {
local_checks_only: false,
})
.await?;
server
.on_notification::<LogMessage, _>(|params, _cx| {
match params.level {
// Copilot is pretty agressive about logging
0 => debug!("copilot: {}", params.message),
1 => debug!("copilot: {}", params.message),
_ => error!("copilot: {}", params.message),
}
debug!("copilot metadata: {}", params.metadata_str);
debug!("copilot extra: {:?}", params.extra);
})
.detach();
server
.on_notification::<StatusNotification, _>(
|_, _| { /* Silence the notification */ },
)
.detach();
anyhow::Ok((server, status))
};
let server = start_language_server.await;
this.update(&mut cx, |this, cx| {
cx.notify();
match server {
Ok((server, status)) => {
this.server = CopilotServer::Started {
server,
status: SignInStatus::SignedOut,
subscriptions_by_buffer_id: Default::default(),
};
this.update_sign_in_status(status, cx);
}
Err(error) => {
this.server = CopilotServer::Error(error.to_string().into());
cx.notify()
}
}
})
}
}
fn sign_in(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
if let CopilotServer::Started { server, status, .. } = &mut self.server {
let task = match status {
SignInStatus::Authorized { .. } | SignInStatus::Unauthorized { .. } => {
Task::ready(Ok(())).shared()
}
SignInStatus::SigningIn { task, .. } => {
cx.notify();
task.clone()
}
SignInStatus::SignedOut => {
let server = server.clone();
let task = cx
.spawn(|this, mut cx| async move {
let sign_in = async {
let sign_in = server
.request::<request::SignInInitiate>(
request::SignInInitiateParams {},
)
.await?;
match sign_in {
request::SignInInitiateResult::AlreadySignedIn { user } => {
Ok(request::SignInStatus::Ok { user })
}
request::SignInInitiateResult::PromptUserDeviceFlow(flow) => {
this.update(&mut cx, |this, cx| {
if let CopilotServer::Started { status, .. } =
&mut this.server
{
if let SignInStatus::SigningIn {
prompt: prompt_flow,
..
} = status
{
*prompt_flow = Some(flow.clone());
cx.notify();
}
}
});
let response = server
.request::<request::SignInConfirm>(
request::SignInConfirmParams {
user_code: flow.user_code,
},
)
.await?;
Ok(response)
}
}
};
let sign_in = sign_in.await;
this.update(&mut cx, |this, cx| match sign_in {
Ok(status) => {
this.update_sign_in_status(status, cx);
Ok(())
}
Err(error) => {
this.update_sign_in_status(
request::SignInStatus::NotSignedIn,
cx,
);
Err(Arc::new(error))
}
})
})
.shared();
*status = SignInStatus::SigningIn {
prompt: None,
task: task.clone(),
};
cx.notify();
task
}
};
cx.foreground()
.spawn(task.map_err(|err| anyhow!("{:?}", err)))
} else {
// If we're downloading, wait until download is finished
// If we're in a stuck state, display to the user
Task::ready(Err(anyhow!("copilot hasn't started yet")))
}
}
fn sign_out(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
if let CopilotServer::Started { server, status, .. } = &mut self.server {
*status = SignInStatus::SignedOut;
cx.notify();
let server = server.clone();
cx.background().spawn(async move {
server
.request::<request::SignOut>(request::SignOutParams {})
.await?;
anyhow::Ok(())
})
} else {
Task::ready(Err(anyhow!("copilot hasn't started yet")))
}
}
fn reinstall(&mut self, cx: &mut ModelContext<Self>) -> Task<()> {
let start_task = cx
.spawn({
let http = self.http.clone();
let node_runtime = self.node_runtime.clone();
move |this, cx| async move {
clear_copilot_dir().await;
Self::start_language_server(http, node_runtime, this, cx).await
}
})
.shared();
self.server = CopilotServer::Starting {
task: start_task.clone(),
};
cx.notify();
cx.foreground().spawn(start_task)
}
pub fn completions<T>(
&mut self,
buffer: &ModelHandle<Buffer>,
position: T,
cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<Completion>>>
where
T: ToPointUtf16,
{
self.request_completions::<request::GetCompletions, _>(buffer, position, cx)
}
pub fn completions_cycling<T>(
&mut self,
buffer: &ModelHandle<Buffer>,
position: T,
cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<Completion>>>
where
T: ToPointUtf16,
{
self.request_completions::<request::GetCompletionsCycling, _>(buffer, position, cx)
}
fn request_completions<R, T>(
&mut self,
buffer: &ModelHandle<Buffer>,
position: T,
cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<Completion>>>
where
R: lsp::request::Request<
Params = request::GetCompletionsParams,
Result = request::GetCompletionsResult,
>,
T: ToPointUtf16,
{
let buffer_id = buffer.id();
let uri: lsp::Url = format!("buffer://{}", buffer_id).parse().unwrap();
let snapshot = buffer.read(cx).snapshot();
let server = match &mut self.server {
CopilotServer::Starting { .. } => {
return Task::ready(Err(anyhow!("copilot is still starting")))
}
CopilotServer::Disabled => return Task::ready(Err(anyhow!("copilot is disabled"))),
CopilotServer::Error(error) => {
return Task::ready(Err(anyhow!(
"copilot was not started because of an error: {}",
error
)))
}
CopilotServer::Started {
server,
status,
subscriptions_by_buffer_id,
} => {
if matches!(status, SignInStatus::Authorized { .. }) {
subscriptions_by_buffer_id
.entry(buffer_id)
.or_insert_with(|| {
server
.notify::<lsp::notification::DidOpenTextDocument>(
lsp::DidOpenTextDocumentParams {
text_document: lsp::TextDocumentItem {
uri: uri.clone(),
language_id: id_for_language(
buffer.read(cx).language(),
),
version: 0,
text: snapshot.text(),
},
},
)
.log_err();
let uri = uri.clone();
cx.observe_release(buffer, move |this, _, _| {
if let CopilotServer::Started {
server,
subscriptions_by_buffer_id,
..
} = &mut this.server
{
server
.notify::<lsp::notification::DidCloseTextDocument>(
lsp::DidCloseTextDocumentParams {
text_document: lsp::TextDocumentIdentifier::new(
uri.clone(),
),
},
)
.log_err();
subscriptions_by_buffer_id.remove(&buffer_id);
}
})
});
server.clone()
} else {
return Task::ready(Err(anyhow!("must sign in before using copilot")));
}
}
};
let settings = cx.global::<Settings>();
let position = position.to_point_utf16(&snapshot);
let language = snapshot.language_at(position);
let language_name = language.map(|language| language.name());
let language_name = language_name.as_deref();
let tab_size = settings.tab_size(language_name);
let hard_tabs = settings.hard_tabs(language_name);
let language_id = id_for_language(language);
let path;
let relative_path;
if let Some(file) = snapshot.file() {
if let Some(file) = file.as_local() {
path = file.abs_path(cx);
} else {
path = file.full_path(cx);
}
relative_path = file.path().to_path_buf();
} else {
path = PathBuf::new();
relative_path = PathBuf::new();
}
cx.background().spawn(async move {
let result = server
.request::<R>(request::GetCompletionsParams {
doc: request::GetCompletionsDocument {
source: snapshot.text(),
tab_size: tab_size.into(),
indent_size: 1,
insert_spaces: !hard_tabs,
uri,
path: path.to_string_lossy().into(),
relative_path: relative_path.to_string_lossy().into(),
language_id,
position: point_to_lsp(position),
version: 0,
},
})
.await?;
let completions = result
.completions
.into_iter()
.map(|completion| {
let start = snapshot
.clip_point_utf16(point_from_lsp(completion.range.start), Bias::Left);
let end =
snapshot.clip_point_utf16(point_from_lsp(completion.range.end), Bias::Left);
Completion {
range: snapshot.anchor_before(start)..snapshot.anchor_after(end),
text: completion.text,
}
})
.collect();
anyhow::Ok(completions)
})
}
pub fn status(&self) -> Status {
match &self.server {
CopilotServer::Starting { task } => Status::Starting { task: task.clone() },
CopilotServer::Disabled => Status::Disabled,
CopilotServer::Error(error) => Status::Error(error.clone()),
CopilotServer::Started { status, .. } => match status {
SignInStatus::Authorized { .. } => Status::Authorized,
SignInStatus::Unauthorized { .. } => Status::Unauthorized,
SignInStatus::SigningIn { prompt, .. } => Status::SigningIn {
prompt: prompt.clone(),
},
SignInStatus::SignedOut => Status::SignedOut,
},
}
}
fn update_sign_in_status(
&mut self,
lsp_status: request::SignInStatus,
cx: &mut ModelContext<Self>,
) {
if let CopilotServer::Started { status, .. } = &mut self.server {
*status = match lsp_status {
request::SignInStatus::Ok { user }
| request::SignInStatus::MaybeOk { user }
| request::SignInStatus::AlreadySignedIn { user } => {
SignInStatus::Authorized { _user: user }
}
request::SignInStatus::NotAuthorized { user } => {
SignInStatus::Unauthorized { _user: user }
}
request::SignInStatus::NotSignedIn => SignInStatus::SignedOut,
};
cx.notify();
}
}
}
fn id_for_language(language: Option<&Arc<Language>>) -> String {
let language_name = language.map(|language| language.name());
match language_name.as_deref() {
Some("Plain Text") => "plaintext".to_string(),
Some(language_name) => language_name.to_lowercase(),
None => "plaintext".to_string(),
}
}
async fn clear_copilot_dir() {
remove_matching(&paths::COPILOT_DIR, |_| true).await
}
async fn get_copilot_lsp(http: Arc<dyn HttpClient>) -> anyhow::Result<PathBuf> {
const SERVER_PATH: &'static str = "dist/agent.js";
///Check for the latest copilot language server and download it if we haven't already
async fn fetch_latest(http: Arc<dyn HttpClient>) -> anyhow::Result<PathBuf> {
let release = latest_github_release("zed-industries/copilot", http.clone()).await?;
let version_dir = &*paths::COPILOT_DIR.join(format!("copilot-{}", release.name));
fs::create_dir_all(version_dir).await?;
let server_path = version_dir.join(SERVER_PATH);
if fs::metadata(&server_path).await.is_err() {
// Copilot LSP looks for this dist dir specifcially, so lets add it in.
let dist_dir = version_dir.join("dist");
fs::create_dir_all(dist_dir.as_path()).await?;
let url = &release
.assets
.get(0)
.context("Github release for copilot contained no assets")?
.browser_download_url;
let mut response = http
.get(&url, Default::default(), true)
.await
.map_err(|err| anyhow!("error downloading copilot release: {}", err))?;
let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
let archive = Archive::new(decompressed_bytes);
archive.unpack(dist_dir).await?;
remove_matching(&paths::COPILOT_DIR, |entry| entry != version_dir).await;
}
Ok(server_path)
}
match fetch_latest(http).await {
ok @ Result::Ok(..) => ok,
e @ Err(..) => {
e.log_err();
// Fetch a cached binary, if it exists
(|| async move {
let mut last_version_dir = None;
let mut entries = fs::read_dir(paths::COPILOT_DIR.as_path()).await?;
while let Some(entry) = entries.next().await {
let entry = entry?;
if entry.file_type().await?.is_dir() {
last_version_dir = Some(entry.path());
}
}
let last_version_dir =
last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?;
let server_path = last_version_dir.join(SERVER_PATH);
if server_path.exists() {
Ok(server_path)
} else {
Err(anyhow!(
"missing executable in directory {:?}",
last_version_dir
))
}
})()
.await
}
}
}

View file

@ -0,0 +1,171 @@
use serde::{Deserialize, Serialize};
pub enum CheckStatus {}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckStatusParams {
pub local_checks_only: bool,
}
impl lsp::request::Request for CheckStatus {
type Params = CheckStatusParams;
type Result = SignInStatus;
const METHOD: &'static str = "checkStatus";
}
pub enum SignInInitiate {}
#[derive(Debug, Serialize, Deserialize)]
pub struct SignInInitiateParams {}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum SignInInitiateResult {
AlreadySignedIn { user: String },
PromptUserDeviceFlow(PromptUserDeviceFlow),
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptUserDeviceFlow {
pub user_code: String,
pub verification_uri: String,
}
impl lsp::request::Request for SignInInitiate {
type Params = SignInInitiateParams;
type Result = SignInInitiateResult;
const METHOD: &'static str = "signInInitiate";
}
pub enum SignInConfirm {}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignInConfirmParams {
pub user_code: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum SignInStatus {
#[serde(rename = "OK")]
Ok {
user: String,
},
MaybeOk {
user: String,
},
AlreadySignedIn {
user: String,
},
NotAuthorized {
user: String,
},
NotSignedIn,
}
impl lsp::request::Request for SignInConfirm {
type Params = SignInConfirmParams;
type Result = SignInStatus;
const METHOD: &'static str = "signInConfirm";
}
pub enum SignOut {}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignOutParams {}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignOutResult {}
impl lsp::request::Request for SignOut {
type Params = SignOutParams;
type Result = SignOutResult;
const METHOD: &'static str = "signOut";
}
pub enum GetCompletions {}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetCompletionsParams {
pub doc: GetCompletionsDocument,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetCompletionsDocument {
pub source: String,
pub tab_size: u32,
pub indent_size: u32,
pub insert_spaces: bool,
pub uri: lsp::Url,
pub path: String,
pub relative_path: String,
pub language_id: String,
pub position: lsp::Position,
pub version: usize,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetCompletionsResult {
pub completions: Vec<Completion>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Completion {
pub text: String,
pub position: lsp::Position,
pub uuid: String,
pub range: lsp::Range,
pub display_text: String,
}
impl lsp::request::Request for GetCompletions {
type Params = GetCompletionsParams;
type Result = GetCompletionsResult;
const METHOD: &'static str = "getCompletions";
}
pub enum GetCompletionsCycling {}
impl lsp::request::Request for GetCompletionsCycling {
type Params = GetCompletionsParams;
type Result = GetCompletionsResult;
const METHOD: &'static str = "getCompletionsCycling";
}
pub enum LogMessage {}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogMessageParams {
pub message: String,
pub level: u8,
pub metadata_str: String,
pub extra: Vec<String>,
}
impl lsp::notification::Notification for LogMessage {
type Params = LogMessageParams;
const METHOD: &'static str = "LogMessage";
}
pub enum StatusNotification {}
#[derive(Debug, Serialize, Deserialize)]
pub struct StatusNotificationParams {
pub message: String,
pub status: String, // One of Normal/InProgress
}
impl lsp::notification::Notification for StatusNotification {
type Params = StatusNotificationParams;
const METHOD: &'static str = "statusNotification";
}

View file

@ -0,0 +1,344 @@
use crate::{request::PromptUserDeviceFlow, Copilot, Status};
use gpui::{
elements::*, geometry::rect::RectF, ClipboardItem, Element, Entity, MutableAppContext, View,
ViewContext, ViewHandle, WindowKind, WindowOptions,
};
use settings::Settings;
use theme::ui::modal;
#[derive(PartialEq, Eq, Debug, Clone)]
struct CopyUserCode;
#[derive(PartialEq, Eq, Debug, Clone)]
struct OpenGithub;
const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot";
pub fn init(cx: &mut MutableAppContext) {
let copilot = Copilot::global(cx).unwrap();
let mut code_verification: Option<ViewHandle<CopilotCodeVerification>> = None;
cx.observe(&copilot, move |copilot, cx| {
let status = copilot.read(cx).status();
match &status {
crate::Status::SigningIn { prompt } => {
if let Some(code_verification) = code_verification.as_ref() {
code_verification.update(cx, |code_verification, cx| {
code_verification.set_status(status, cx)
});
cx.activate_window(code_verification.window_id());
} else if let Some(_prompt) = prompt {
let window_size = cx.global::<Settings>().theme.copilot.modal.dimensions();
let window_options = WindowOptions {
bounds: gpui::WindowBounds::Fixed(RectF::new(
Default::default(),
window_size,
)),
titlebar: None,
center: true,
focus: true,
kind: WindowKind::Normal,
is_movable: true,
screen: None,
};
let (_, view) =
cx.add_window(window_options, |_cx| CopilotCodeVerification::new(status));
code_verification = Some(view);
}
}
Status::Authorized | Status::Unauthorized => {
if let Some(code_verification) = code_verification.as_ref() {
code_verification.update(cx, |code_verification, cx| {
code_verification.set_status(status, cx)
});
cx.platform().activate(true);
cx.activate_window(code_verification.window_id());
}
}
_ => {
if let Some(code_verification) = code_verification.take() {
cx.remove_window(code_verification.window_id());
}
}
}
})
.detach();
}
pub struct CopilotCodeVerification {
status: Status,
}
impl CopilotCodeVerification {
pub fn new(status: Status) -> Self {
Self { status }
}
pub fn set_status(&mut self, status: Status, cx: &mut ViewContext<Self>) {
self.status = status;
cx.notify();
}
fn render_device_code(
data: &PromptUserDeviceFlow,
style: &theme::Copilot,
cx: &mut gpui::RenderContext<Self>,
) -> ElementBox {
let copied = cx
.read_from_clipboard()
.map(|item| item.text() == &data.user_code)
.unwrap_or(false);
let device_code_style = &style.auth.prompting.device_code;
MouseEventHandler::<Self>::new(0, cx, |state, _cx| {
Flex::row()
.with_children([
Label::new(data.user_code.clone(), device_code_style.text.clone())
.aligned()
.contained()
.with_style(device_code_style.left_container)
.constrained()
.with_width(device_code_style.left)
.boxed(),
Label::new(
if copied { "Copied!" } else { "Copy" },
device_code_style.cta.style_for(state, false).text.clone(),
)
.aligned()
.contained()
.with_style(*device_code_style.right_container.style_for(state, false))
.constrained()
.with_width(device_code_style.right)
.boxed(),
])
.contained()
.with_style(device_code_style.cta.style_for(state, false).container)
.boxed()
})
.on_click(gpui::MouseButton::Left, {
let user_code = data.user_code.clone();
move |_, cx| {
cx.platform()
.write_to_clipboard(ClipboardItem::new(user_code.clone()));
cx.notify();
}
})
.with_cursor_style(gpui::CursorStyle::PointingHand)
.boxed()
}
fn render_prompting_modal(
data: &PromptUserDeviceFlow,
style: &theme::Copilot,
cx: &mut gpui::RenderContext<Self>,
) -> ElementBox {
Flex::column()
.with_children([
Flex::column()
.with_children([
Label::new(
"Enable Copilot by connecting",
style.auth.prompting.subheading.text.clone(),
)
.aligned()
.boxed(),
Label::new(
"your existing license.",
style.auth.prompting.subheading.text.clone(),
)
.aligned()
.boxed(),
])
.align_children_center()
.contained()
.with_style(style.auth.prompting.subheading.container)
.boxed(),
Self::render_device_code(data, &style, cx),
Flex::column()
.with_children([
Label::new(
"Paste this code into GitHub after",
style.auth.prompting.hint.text.clone(),
)
.aligned()
.boxed(),
Label::new(
"clicking the button below.",
style.auth.prompting.hint.text.clone(),
)
.aligned()
.boxed(),
])
.align_children_center()
.contained()
.with_style(style.auth.prompting.hint.container.clone())
.boxed(),
theme::ui::cta_button_with_click(
"Connect to GitHub",
style.auth.content_width,
&style.auth.cta_button,
cx,
{
let verification_uri = data.verification_uri.clone();
move |_, cx| cx.platform().open_url(&verification_uri)
},
)
.boxed(),
])
.align_children_center()
.boxed()
}
fn render_enabled_modal(
style: &theme::Copilot,
cx: &mut gpui::RenderContext<Self>,
) -> ElementBox {
let enabled_style = &style.auth.authorized;
Flex::column()
.with_children([
Label::new("Copilot Enabled!", enabled_style.subheading.text.clone())
.contained()
.with_style(enabled_style.subheading.container)
.aligned()
.boxed(),
Flex::column()
.with_children([
Label::new(
"You can update your settings or",
enabled_style.hint.text.clone(),
)
.aligned()
.boxed(),
Label::new(
"sign out from the Copilot menu in",
enabled_style.hint.text.clone(),
)
.aligned()
.boxed(),
Label::new("the status bar.", enabled_style.hint.text.clone())
.aligned()
.boxed(),
])
.align_children_center()
.contained()
.with_style(enabled_style.hint.container)
.boxed(),
theme::ui::cta_button_with_click(
"Done",
style.auth.content_width,
&style.auth.cta_button,
cx,
|_, cx| {
let window_id = cx.window_id();
cx.remove_window(window_id)
},
)
.boxed(),
])
.align_children_center()
.boxed()
}
fn render_unauthorized_modal(
style: &theme::Copilot,
cx: &mut gpui::RenderContext<Self>,
) -> ElementBox {
let unauthorized_style = &style.auth.not_authorized;
Flex::column()
.with_children([
Flex::column()
.with_children([
Label::new(
"Enable Copilot by connecting",
unauthorized_style.subheading.text.clone(),
)
.aligned()
.boxed(),
Label::new(
"your existing license.",
unauthorized_style.subheading.text.clone(),
)
.aligned()
.boxed(),
])
.align_children_center()
.contained()
.with_style(unauthorized_style.subheading.container)
.boxed(),
Flex::column()
.with_children([
Label::new(
"You must have an active copilot",
unauthorized_style.warning.text.clone(),
)
.aligned()
.boxed(),
Label::new(
"license to use it in Zed.",
unauthorized_style.warning.text.clone(),
)
.aligned()
.boxed(),
])
.align_children_center()
.contained()
.with_style(unauthorized_style.warning.container)
.boxed(),
theme::ui::cta_button_with_click(
"Subscribe on GitHub",
style.auth.content_width,
&style.auth.cta_button,
cx,
|_, cx| {
let window_id = cx.window_id();
cx.remove_window(window_id);
cx.platform().open_url(COPILOT_SIGN_UP_URL)
},
)
.boxed(),
])
.align_children_center()
.boxed()
}
}
impl Entity for CopilotCodeVerification {
type Event = ();
}
impl View for CopilotCodeVerification {
fn ui_name() -> &'static str {
"CopilotCodeVerification"
}
fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut gpui::ViewContext<Self>) {
cx.notify()
}
fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut gpui::ViewContext<Self>) {
cx.notify()
}
fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
let style = cx.global::<Settings>().theme.clone();
modal("Connect Copilot to Zed", &style.copilot.modal, cx, |cx| {
Flex::column()
.with_children([
theme::ui::icon(&style.copilot.auth.header).boxed(),
match &self.status {
Status::SigningIn {
prompt: Some(prompt),
} => Self::render_prompting_modal(&prompt, &style.copilot, cx),
Status::Unauthorized => Self::render_unauthorized_modal(&style.copilot, cx),
Status::Authorized => Self::render_enabled_modal(&style.copilot, cx),
_ => Empty::new().boxed(),
},
])
.align_children_center()
.boxed()
})
}
}

View file

@ -0,0 +1,22 @@
[package]
name = "copilot_button"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
path = "src/copilot_button.rs"
doctest = false
[dependencies]
copilot = { path = "../copilot" }
editor = { path = "../editor" }
context_menu = { path = "../context_menu" }
gpui = { path = "../gpui" }
settings = { path = "../settings" }
theme = { path = "../theme" }
util = { path = "../util" }
workspace = { path = "../workspace" }
anyhow = "1.0"
smol = "1.2.5"
futures = "0.3"

View file

@ -0,0 +1,360 @@
use std::sync::Arc;
use context_menu::{ContextMenu, ContextMenuItem};
use editor::Editor;
use gpui::{
elements::*, impl_internal_actions, CursorStyle, Element, ElementBox, Entity, MouseButton,
MouseState, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
};
use settings::{settings_file::SettingsFile, Settings};
use workspace::{
item::ItemHandle, notifications::simple_message_notification::OsOpen, DismissToast,
StatusItemView,
};
use copilot::{Copilot, Reinstall, SignIn, SignOut, Status};
const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
const COPILOT_STARTING_TOAST_ID: usize = 1337;
const COPILOT_ERROR_TOAST_ID: usize = 1338;
#[derive(Clone, PartialEq)]
pub struct DeployCopilotMenu;
#[derive(Clone, PartialEq)]
pub struct ToggleCopilotForLanguage {
language: Arc<str>,
}
#[derive(Clone, PartialEq)]
pub struct ToggleCopilotGlobally;
// TODO: Make the other code path use `get_or_insert` logic for this modal
#[derive(Clone, PartialEq)]
pub struct DeployCopilotModal;
impl_internal_actions!(
copilot,
[
DeployCopilotMenu,
DeployCopilotModal,
ToggleCopilotForLanguage,
ToggleCopilotGlobally,
]
);
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(CopilotButton::deploy_copilot_menu);
cx.add_action(
|_: &mut CopilotButton, action: &ToggleCopilotForLanguage, cx| {
let language = action.language.to_owned();
let current_langauge = cx.global::<Settings>().copilot_on(Some(&language));
SettingsFile::update(cx, move |file_contents| {
file_contents.languages.insert(
language.to_owned(),
settings::EditorSettings {
copilot: Some((!current_langauge).into()),
..Default::default()
},
);
})
},
);
cx.add_action(|_: &mut CopilotButton, _: &ToggleCopilotGlobally, cx| {
let copilot_on = cx.global::<Settings>().copilot_on(None);
SettingsFile::update(cx, move |file_contents| {
file_contents.editor.copilot = Some((!copilot_on).into())
})
});
}
pub struct CopilotButton {
popup_menu: ViewHandle<ContextMenu>,
editor_subscription: Option<(Subscription, usize)>,
editor_enabled: Option<bool>,
language: Option<Arc<str>>,
}
impl Entity for CopilotButton {
type Event = ();
}
impl View for CopilotButton {
fn ui_name() -> &'static str {
"CopilotButton"
}
fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
let settings = cx.global::<Settings>();
if !settings.enable_copilot_integration {
return Empty::new().boxed();
}
let theme = settings.theme.clone();
let active = self.popup_menu.read(cx).visible();
let Some(copilot) = Copilot::global(cx) else {
return Empty::new().boxed();
};
let status = copilot.read(cx).status();
let enabled = self.editor_enabled.unwrap_or(settings.copilot_on(None));
let view_id = cx.view_id();
Stack::new()
.with_child(
MouseEventHandler::<Self>::new(0, cx, {
let theme = theme.clone();
let status = status.clone();
move |state, _cx| {
let style = theme
.workspace
.status_bar
.sidebar_buttons
.item
.style_for(state, active);
Flex::row()
.with_child(
Svg::new({
match status {
Status::Error(_) => "icons/copilot_error_16.svg",
Status::Authorized => {
if enabled {
"icons/copilot_16.svg"
} else {
"icons/copilot_disabled_16.svg"
}
}
_ => "icons/copilot_init_16.svg",
}
})
.with_color(style.icon_color)
.constrained()
.with_width(style.icon_size)
.aligned()
.named("copilot-icon"),
)
.constrained()
.with_height(style.icon_size)
.contained()
.with_style(style.container)
.boxed()
}
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, {
let status = status.clone();
move |_, cx| match status {
Status::Authorized => cx.dispatch_action(DeployCopilotMenu),
Status::Starting { ref task } => {
cx.dispatch_action(workspace::Toast::new(
COPILOT_STARTING_TOAST_ID,
"Copilot is starting...",
));
let window_id = cx.window_id();
let task = task.to_owned();
cx.spawn(|mut cx| async move {
task.await;
cx.update(|cx| {
if let Some(copilot) = Copilot::global(cx) {
let status = copilot.read(cx).status();
match status {
Status::Authorized => cx.dispatch_action_at(
window_id,
view_id,
workspace::Toast::new(
COPILOT_STARTING_TOAST_ID,
"Copilot has started!",
),
),
_ => {
cx.dispatch_action_at(
window_id,
view_id,
DismissToast::new(COPILOT_STARTING_TOAST_ID),
);
cx.dispatch_global_action(SignIn)
}
}
}
})
})
.detach();
}
Status::Error(ref e) => cx.dispatch_action(workspace::Toast::new_action(
COPILOT_ERROR_TOAST_ID,
format!("Copilot can't be started: {}", e),
"Reinstall Copilot",
Reinstall,
)),
_ => cx.dispatch_action(SignIn),
}
})
.with_tooltip::<Self, _>(
0,
"GitHub Copilot".into(),
None,
theme.tooltip.clone(),
cx,
)
.boxed(),
)
.with_child(
ChildView::new(&self.popup_menu, cx)
.aligned()
.top()
.right()
.boxed(),
)
.boxed()
}
}
impl CopilotButton {
pub fn new(cx: &mut ViewContext<Self>) -> Self {
let menu = cx.add_view(|cx| {
let mut menu = ContextMenu::new(cx);
menu.set_position_mode(OverlayPositionMode::Local);
menu
});
cx.observe(&menu, |_, _, cx| cx.notify()).detach();
Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
let this_handle = cx.handle().downgrade();
cx.observe_global::<Settings, _>(move |cx| {
if let Some(handle) = this_handle.upgrade(cx) {
handle.update(cx, |_, cx| cx.notify())
}
})
.detach();
Self {
popup_menu: menu,
editor_subscription: None,
editor_enabled: None,
language: None,
}
}
pub fn deploy_copilot_menu(&mut self, _: &DeployCopilotMenu, cx: &mut ViewContext<Self>) {
let settings = cx.global::<Settings>();
let mut menu_options = Vec::with_capacity(6);
if let Some((_, view_id)) = self.editor_subscription.as_ref() {
let locally_enabled = self.editor_enabled.unwrap_or(settings.copilot_on(None));
menu_options.push(ContextMenuItem::item_for_view(
if locally_enabled {
"Pause Copilot for this file"
} else {
"Resume Copilot for this file"
},
*view_id,
copilot::Toggle,
));
}
if let Some(language) = &self.language {
let language_enabled = settings.copilot_on(Some(language.as_ref()));
menu_options.push(ContextMenuItem::item(
format!(
"{} Copilot for {}",
if language_enabled {
"Disable"
} else {
"Enable"
},
language
),
ToggleCopilotForLanguage {
language: language.to_owned(),
},
));
}
let globally_enabled = cx.global::<Settings>().copilot_on(None);
menu_options.push(ContextMenuItem::item(
if globally_enabled {
"Disable Copilot Globally"
} else {
"Enable Copilot Globally"
},
ToggleCopilotGlobally,
));
menu_options.push(ContextMenuItem::Separator);
let icon_style = settings.theme.copilot.out_link_icon.clone();
menu_options.push(ContextMenuItem::element_item(
Box::new(
move |state: &mut MouseState, style: &theme::ContextMenuItem| {
Flex::row()
.with_children([
Label::new("Copilot Settings", style.label.clone()).boxed(),
theme::ui::icon(icon_style.style_for(state, false)).boxed(),
])
.align_children_center()
.boxed()
},
),
OsOpen::new(COPILOT_SETTINGS_URL),
));
menu_options.push(ContextMenuItem::item("Sign Out", SignOut));
self.popup_menu.update(cx, |menu, cx| {
menu.show(
Default::default(),
AnchorCorner::BottomRight,
menu_options,
cx,
);
});
}
pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
let editor = editor.read(cx);
let snapshot = editor.buffer().read(cx).snapshot(cx);
let settings = cx.global::<Settings>();
let suggestion_anchor = editor.selections.newest_anchor().start;
let language_name = snapshot
.language_at(suggestion_anchor)
.map(|language| language.name());
self.language = language_name.clone();
if let Some(enabled) = editor.copilot_state.user_enabled {
self.editor_enabled = Some(enabled);
} else {
self.editor_enabled = Some(settings.copilot_on(language_name.as_deref()));
}
cx.notify()
}
}
impl StatusItemView for CopilotButton {
fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
if let Some(editor) = item.map(|item| item.act_as::<Editor>(cx)).flatten() {
self.editor_subscription =
Some((cx.observe(&editor, Self::update_enabled), editor.id()));
self.update_enabled(editor, cx);
} else {
self.language = None;
self.editor_subscription = None;
self.editor_enabled = None;
}
cx.notify();
}
}

View file

@ -23,7 +23,8 @@ async-trait = "0.1"
lazy_static = "1.4.0"
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
parking_lot = "0.11.1"
serde = { version = "1.0", features = ["derive"] }
serde = { workspace = true }
serde_derive = { workspace = true }
smol = "1.2"
[dev-dependencies]

View file

@ -4,6 +4,7 @@ pub mod query;
// Re-export
pub use anyhow;
use anyhow::Context;
use gpui::MutableAppContext;
pub use indoc::indoc;
pub use lazy_static;
use parking_lot::{Mutex, RwLock};
@ -17,6 +18,7 @@ use sqlez::domain::Migrator;
use sqlez::thread_safe_connection::ThreadSafeConnection;
use sqlez_macros::sql;
use std::fs::create_dir_all;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@ -39,6 +41,7 @@ const FALLBACK_DB_NAME: &'static str = "FALLBACK_MEMORY_DB";
const DB_FILE_NAME: &'static str = "db.sqlite";
lazy_static::lazy_static! {
// !!!!!!! CHANGE BACK TO DEFAULT FALSE BEFORE SHIPPING
static ref ZED_STATELESS: bool = std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty());
static ref DB_FILE_OPERATIONS: Mutex<()> = Mutex::new(());
pub static ref BACKUP_DB_PATH: RwLock<Option<PathBuf>> = RwLock::new(None);
@ -63,11 +66,11 @@ pub async fn open_db<M: Migrator + 'static>(
let connection = async_iife!({
// Note: This still has a race condition where 1 set of migrations succeeds
// (e.g. (Workspace, Editor)) and another fails (e.g. (Workspace, Terminal))
// This will cause the first connection to have the database taken out
// This will cause the first connection to have the database taken out
// from under it. This *should* be fine though. The second dabatase failure will
// cause errors in the log and so should be observed by developers while writing
// soon-to-be good migrations. If user databases are corrupted, we toss them out
// and try again from a blank. As long as running all migrations from start to end
// and try again from a blank. As long as running all migrations from start to end
// on a blank database is ok, this race condition will never be triggered.
//
// Basically: Don't ever push invalid migrations to stable or everyone will have
@ -85,7 +88,7 @@ pub async fn open_db<M: Migrator + 'static>(
};
}
// Take a lock in the failure case so that we move the db once per process instead
// Take a lock in the failure case so that we move the db once per process instead
// of potentially multiple times from different threads. This shouldn't happen in the
// normal path
let _lock = DB_FILE_OPERATIONS.lock();
@ -236,6 +239,15 @@ macro_rules! define_connection {
};
}
pub fn write_and_log<F>(cx: &mut MutableAppContext, db_write: impl FnOnce() -> F + Send + 'static)
where
F: Future<Output = anyhow::Result<()>> + Send,
{
cx.background()
.spawn(async move { db_write().await.log_err() })
.detach()
}
#[cfg(test)]
mod tests {
use std::{fs, thread};

View file

@ -20,7 +20,7 @@ settings = { path = "../settings" }
theme = { path = "../theme" }
util = { path = "../util" }
workspace = { path = "../workspace" }
postage = { version = "0.4", features = ["futures-traits"] }
postage = { workspace = true }
[dev-dependencies]
unindent = "0.1"
@ -29,4 +29,4 @@ editor = { path = "../editor", features = ["test-support"] }
language = { path = "../language", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] }
workspace = { path = "../workspace", features = ["test-support"] }
serde_json = { version = "1", features = ["preserve_order"] }
serde_json = { workspace = true }

View file

@ -90,14 +90,11 @@ impl View for ProjectDiagnosticsEditor {
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
if self.path_states.is_empty() {
let theme = &cx.global::<Settings>().theme.project_diagnostics;
Label::new(
"No problems in workspace".to_string(),
theme.empty_message.clone(),
)
.aligned()
.contained()
.with_style(theme.container)
.boxed()
Label::new("No problems in workspace", theme.empty_message.clone())
.aligned()
.contained()
.with_style(theme.container)
.boxed()
} else {
ChildView::new(&self.editor, cx).boxed()
}
@ -605,16 +602,16 @@ impl Item for ProjectDiagnosticsEditor {
))
}
fn act_as_type(
&self,
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &ViewHandle<Self>,
_: &AppContext,
) -> Option<AnyViewHandle> {
self_handle: &'a ViewHandle<Self>,
_: &'a AppContext,
) -> Option<&AnyViewHandle> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.into())
Some(self_handle)
} else if type_id == TypeId::of::<Editor>() {
Some((&self.editor).into())
Some(&self.editor)
} else {
None
}
@ -697,7 +694,7 @@ pub(crate) fn render_summary(
theme: &theme::ProjectDiagnostics,
) -> ElementBox {
if summary.error_count == 0 && summary.warning_count == 0 {
Label::new("No problems".to_string(), text_style.clone()).boxed()
Label::new("No problems", text_style.clone()).boxed()
} else {
let icon_width = theme.tab_icon_width;
let icon_spacing = theme.tab_icon_spacing;
@ -808,15 +805,7 @@ mod tests {
.await;
let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
let (_, workspace) = cx.add_window(|cx| {
Workspace::new(
Default::default(),
0,
project.clone(),
|_, _| unimplemented!(),
cx,
)
});
let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
// Create some diagnostics
project.update(cx, |project, cx| {

View file

@ -178,14 +178,11 @@ impl View for DiagnosticIndicator {
if in_progress {
element.add_child(
Label::new(
"Checking…".into(),
style.diagnostic_message.default.text.clone(),
)
.aligned()
.contained()
.with_margin_left(item_spacing)
.boxed(),
Label::new("Checking…", style.diagnostic_message.default.text.clone())
.aligned()
.contained()
.with_margin_left(item_spacing)
.boxed(),
);
} else if let Some(diagnostic) = &self.current_diagnostic {
let message_style = style.diagnostic_message.clone();

View file

@ -22,10 +22,10 @@ test-support = [
]
[dependencies]
drag_and_drop = { path = "../drag_and_drop" }
text = { path = "../text" }
clock = { path = "../clock" }
copilot = { path = "../copilot" }
db = { path = "../db" }
drag_and_drop = { path = "../drag_and_drop" }
collections = { path = "../collections" }
context_menu = { path = "../context_menu" }
fuzzy = { path = "../fuzzy" }
@ -38,10 +38,12 @@ rpc = { path = "../rpc" }
settings = { path = "../settings" }
snippet = { path = "../snippet" }
sum_tree = { path = "../sum_tree" }
text = { path = "../text" }
theme = { path = "../theme" }
util = { path = "../util" }
sqlez = { path = "../sqlez" }
workspace = { path = "../workspace" }
aho-corasick = "0.7"
anyhow = "1.0"
futures = "0.3"
@ -51,9 +53,10 @@ lazy_static = "1.4"
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
ordered-float = "2.1.1"
parking_lot = "0.11"
postage = { version = "0.4", features = ["futures-traits"] }
postage = { workspace = true }
rand = { version = "0.8.3", optional = true }
serde = { workspace = true }
serde_derive = { workspace = true }
smallvec = { version = "1.6", features = ["union"] }
smol = "1.2"
tree-sitter-rust = { version = "*", optional = true }

View file

@ -1,19 +1,23 @@
mod block_map;
mod fold_map;
mod suggestion_map;
mod tab_map;
mod wrap_map;
use crate::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint};
use block_map::{BlockMap, BlockPoint};
pub use block_map::{BlockMap, BlockPoint};
use collections::{HashMap, HashSet};
use fold_map::FoldMap;
use gpui::{
color::Color,
fonts::{FontId, HighlightStyle},
Entity, ModelContext, ModelHandle,
};
use language::{OffsetUtf16, Point, Subscription as BufferSubscription};
use settings::Settings;
use std::{any::TypeId, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc};
pub use suggestion_map::Suggestion;
use suggestion_map::SuggestionMap;
use sum_tree::{Bias, TreeMap};
use tab_map::TabMap;
use wrap_map::WrapMap;
@ -23,6 +27,12 @@ pub use block_map::{
BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock, TransformBlock,
};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FoldStatus {
Folded,
Foldable,
}
pub trait ToDisplayPoint {
fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint;
}
@ -33,6 +43,7 @@ pub struct DisplayMap {
buffer: ModelHandle<MultiBuffer>,
buffer_subscription: BufferSubscription,
fold_map: FoldMap,
suggestion_map: SuggestionMap,
tab_map: TabMap,
wrap_map: ModelHandle<WrapMap>,
block_map: BlockMap,
@ -58,6 +69,7 @@ impl DisplayMap {
let tab_size = Self::tab_size(&buffer, cx);
let (fold_map, snapshot) = FoldMap::new(buffer.read(cx).snapshot(cx));
let (suggestion_map, snapshot) = SuggestionMap::new(snapshot);
let (tab_map, snapshot) = TabMap::new(snapshot, tab_size);
let (wrap_map, snapshot) = WrapMap::new(snapshot, font_id, font_size, wrap_width, cx);
let block_map = BlockMap::new(snapshot, buffer_header_height, excerpt_header_height);
@ -66,6 +78,7 @@ impl DisplayMap {
buffer,
buffer_subscription,
fold_map,
suggestion_map,
tab_map,
wrap_map,
block_map,
@ -77,21 +90,25 @@ impl DisplayMap {
pub fn snapshot(&self, cx: &mut ModelContext<Self>) -> DisplaySnapshot {
let buffer_snapshot = self.buffer.read(cx).snapshot(cx);
let edits = self.buffer_subscription.consume().into_inner();
let (folds_snapshot, edits) = self.fold_map.read(buffer_snapshot, edits);
let (fold_snapshot, edits) = self.fold_map.read(buffer_snapshot, edits);
let (suggestion_snapshot, edits) = self.suggestion_map.sync(fold_snapshot.clone(), edits);
let tab_size = Self::tab_size(&self.buffer, cx);
let (tabs_snapshot, edits) = self.tab_map.sync(folds_snapshot.clone(), edits, tab_size);
let (wraps_snapshot, edits) = self
let (tab_snapshot, edits) = self
.tab_map
.sync(suggestion_snapshot.clone(), edits, tab_size);
let (wrap_snapshot, edits) = self
.wrap_map
.update(cx, |map, cx| map.sync(tabs_snapshot.clone(), edits, cx));
let blocks_snapshot = self.block_map.read(wraps_snapshot.clone(), edits);
.update(cx, |map, cx| map.sync(tab_snapshot.clone(), edits, cx));
let block_snapshot = self.block_map.read(wrap_snapshot.clone(), edits);
DisplaySnapshot {
buffer_snapshot: self.buffer.read(cx).snapshot(cx),
folds_snapshot,
tabs_snapshot,
wraps_snapshot,
blocks_snapshot,
fold_snapshot,
suggestion_snapshot,
tab_snapshot,
wrap_snapshot,
block_snapshot,
text_highlights: self.text_highlights.clone(),
clip_at_line_ends: self.clip_at_line_ends,
}
@ -115,12 +132,14 @@ impl DisplayMap {
let edits = self.buffer_subscription.consume().into_inner();
let tab_size = Self::tab_size(&self.buffer, cx);
let (mut fold_map, snapshot, edits) = self.fold_map.write(snapshot, edits);
let (snapshot, edits) = self.suggestion_map.sync(snapshot, edits);
let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
let (snapshot, edits) = self
.wrap_map
.update(cx, |map, cx| map.sync(snapshot, edits, cx));
self.block_map.read(snapshot, edits);
let (snapshot, edits) = fold_map.fold(ranges);
let (snapshot, edits) = self.suggestion_map.sync(snapshot, edits);
let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
let (snapshot, edits) = self
.wrap_map
@ -138,12 +157,14 @@ impl DisplayMap {
let edits = self.buffer_subscription.consume().into_inner();
let tab_size = Self::tab_size(&self.buffer, cx);
let (mut fold_map, snapshot, edits) = self.fold_map.write(snapshot, edits);
let (snapshot, edits) = self.suggestion_map.sync(snapshot, edits);
let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
let (snapshot, edits) = self
.wrap_map
.update(cx, |map, cx| map.sync(snapshot, edits, cx));
self.block_map.read(snapshot, edits);
let (snapshot, edits) = fold_map.unfold(ranges, inclusive);
let (snapshot, edits) = self.suggestion_map.sync(snapshot, edits);
let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
let (snapshot, edits) = self
.wrap_map
@ -160,6 +181,7 @@ impl DisplayMap {
let edits = self.buffer_subscription.consume().into_inner();
let tab_size = Self::tab_size(&self.buffer, cx);
let (snapshot, edits) = self.fold_map.read(snapshot, edits);
let (snapshot, edits) = self.suggestion_map.sync(snapshot, edits);
let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
let (snapshot, edits) = self
.wrap_map
@ -177,6 +199,7 @@ impl DisplayMap {
let edits = self.buffer_subscription.consume().into_inner();
let tab_size = Self::tab_size(&self.buffer, cx);
let (snapshot, edits) = self.fold_map.read(snapshot, edits);
let (snapshot, edits) = self.suggestion_map.sync(snapshot, edits);
let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
let (snapshot, edits) = self
.wrap_map
@ -207,11 +230,38 @@ impl DisplayMap {
self.text_highlights.remove(&Some(type_id))
}
pub fn has_suggestion(&self) -> bool {
self.suggestion_map.has_suggestion()
}
pub fn replace_suggestion<T>(
&self,
new_suggestion: Option<Suggestion<T>>,
cx: &mut ModelContext<Self>,
) where
T: ToPoint,
{
let snapshot = self.buffer.read(cx).snapshot(cx);
let edits = self.buffer_subscription.consume().into_inner();
let tab_size = Self::tab_size(&self.buffer, cx);
let (snapshot, edits) = self.fold_map.read(snapshot, edits);
let (snapshot, edits) = self.suggestion_map.replace(new_suggestion, snapshot, edits);
let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size);
let (snapshot, edits) = self
.wrap_map
.update(cx, |map, cx| map.sync(snapshot, edits, cx));
self.block_map.read(snapshot, edits);
}
pub fn set_font(&self, font_id: FontId, font_size: f32, cx: &mut ModelContext<Self>) -> bool {
self.wrap_map
.update(cx, |map, cx| map.set_font(font_id, font_size, cx))
}
pub fn set_fold_ellipses_color(&mut self, color: Color) -> bool {
self.fold_map.set_ellipses_color(color)
}
pub fn set_wrap_width(&self, width: Option<f32>, cx: &mut ModelContext<Self>) -> bool {
self.wrap_map
.update(cx, |map, cx| map.set_wrap_width(width, cx))
@ -235,10 +285,11 @@ impl DisplayMap {
pub struct DisplaySnapshot {
pub buffer_snapshot: MultiBufferSnapshot,
folds_snapshot: fold_map::FoldSnapshot,
tabs_snapshot: tab_map::TabSnapshot,
wraps_snapshot: wrap_map::WrapSnapshot,
blocks_snapshot: block_map::BlockSnapshot,
fold_snapshot: fold_map::FoldSnapshot,
suggestion_snapshot: suggestion_map::SuggestionSnapshot,
tab_snapshot: tab_map::TabSnapshot,
wrap_snapshot: wrap_map::WrapSnapshot,
block_snapshot: block_map::BlockSnapshot,
text_highlights: TextHighlights,
clip_at_line_ends: bool,
}
@ -246,7 +297,7 @@ pub struct DisplaySnapshot {
impl DisplaySnapshot {
#[cfg(test)]
pub fn fold_count(&self) -> usize {
self.folds_snapshot.fold_count()
self.fold_snapshot.fold_count()
}
pub fn is_empty(&self) -> bool {
@ -254,7 +305,7 @@ impl DisplaySnapshot {
}
pub fn buffer_rows(&self, start_row: u32) -> DisplayBufferRows {
self.blocks_snapshot.buffer_rows(start_row)
self.block_snapshot.buffer_rows(start_row)
}
pub fn max_buffer_row(&self) -> u32 {
@ -263,9 +314,9 @@ impl DisplaySnapshot {
pub fn prev_line_boundary(&self, mut point: Point) -> (Point, DisplayPoint) {
loop {
let mut fold_point = self.folds_snapshot.to_fold_point(point, Bias::Left);
let mut fold_point = self.fold_snapshot.to_fold_point(point, Bias::Left);
*fold_point.column_mut() = 0;
point = fold_point.to_buffer_point(&self.folds_snapshot);
point = fold_point.to_buffer_point(&self.fold_snapshot);
let mut display_point = self.point_to_display_point(point, Bias::Left);
*display_point.column_mut() = 0;
@ -279,9 +330,9 @@ impl DisplaySnapshot {
pub fn next_line_boundary(&self, mut point: Point) -> (Point, DisplayPoint) {
loop {
let mut fold_point = self.folds_snapshot.to_fold_point(point, Bias::Right);
*fold_point.column_mut() = self.folds_snapshot.line_len(fold_point.row());
point = fold_point.to_buffer_point(&self.folds_snapshot);
let mut fold_point = self.fold_snapshot.to_fold_point(point, Bias::Right);
*fold_point.column_mut() = self.fold_snapshot.line_len(fold_point.row());
point = fold_point.to_buffer_point(&self.fold_snapshot);
let mut display_point = self.point_to_display_point(point, Bias::Right);
*display_point.column_mut() = self.line_len(display_point.row());
@ -311,37 +362,39 @@ impl DisplaySnapshot {
}
fn point_to_display_point(&self, point: Point, bias: Bias) -> DisplayPoint {
let fold_point = self.folds_snapshot.to_fold_point(point, bias);
let tab_point = self.tabs_snapshot.to_tab_point(fold_point);
let wrap_point = self.wraps_snapshot.tab_point_to_wrap_point(tab_point);
let block_point = self.blocks_snapshot.to_block_point(wrap_point);
let fold_point = self.fold_snapshot.to_fold_point(point, bias);
let suggestion_point = self.suggestion_snapshot.to_suggestion_point(fold_point);
let tab_point = self.tab_snapshot.to_tab_point(suggestion_point);
let wrap_point = self.wrap_snapshot.tab_point_to_wrap_point(tab_point);
let block_point = self.block_snapshot.to_block_point(wrap_point);
DisplayPoint(block_point)
}
fn display_point_to_point(&self, point: DisplayPoint, bias: Bias) -> Point {
let block_point = point.0;
let wrap_point = self.blocks_snapshot.to_wrap_point(block_point);
let tab_point = self.wraps_snapshot.to_tab_point(wrap_point);
let fold_point = self.tabs_snapshot.to_fold_point(tab_point, bias).0;
fold_point.to_buffer_point(&self.folds_snapshot)
let wrap_point = self.block_snapshot.to_wrap_point(block_point);
let tab_point = self.wrap_snapshot.to_tab_point(wrap_point);
let suggestion_point = self.tab_snapshot.to_suggestion_point(tab_point, bias).0;
let fold_point = self.suggestion_snapshot.to_fold_point(suggestion_point);
fold_point.to_buffer_point(&self.fold_snapshot)
}
pub fn max_point(&self) -> DisplayPoint {
DisplayPoint(self.blocks_snapshot.max_point())
DisplayPoint(self.block_snapshot.max_point())
}
/// Returns text chunks starting at the given display row until the end of the file
pub fn text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
self.blocks_snapshot
.chunks(display_row..self.max_point().row() + 1, false, None)
self.block_snapshot
.chunks(display_row..self.max_point().row() + 1, false, None, None)
.map(|h| h.text)
}
/// Returns text chunks starting at the end of the given display row in reverse until the start of the file
pub fn reverse_text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
(0..=display_row).into_iter().rev().flat_map(|row| {
self.blocks_snapshot
.chunks(row..row + 1, false, None)
self.block_snapshot
.chunks(row..row + 1, false, None, None)
.map(|h| h.text)
.collect::<Vec<_>>()
.into_iter()
@ -349,16 +402,25 @@ impl DisplaySnapshot {
})
}
pub fn chunks(&self, display_rows: Range<u32>, language_aware: bool) -> DisplayChunks<'_> {
self.blocks_snapshot
.chunks(display_rows, language_aware, Some(&self.text_highlights))
pub fn chunks(
&self,
display_rows: Range<u32>,
language_aware: bool,
suggestion_highlight: Option<HighlightStyle>,
) -> DisplayChunks<'_> {
self.block_snapshot.chunks(
display_rows,
language_aware,
Some(&self.text_highlights),
suggestion_highlight,
)
}
pub fn chars_at(
&self,
mut point: DisplayPoint,
) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
point = DisplayPoint(self.block_snapshot.clip_point(point.0, Bias::Left));
self.text_chunks(point.row())
.flat_map(str::chars)
.skip_while({
@ -385,7 +447,7 @@ impl DisplaySnapshot {
&self,
mut point: DisplayPoint,
) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
point = DisplayPoint(self.block_snapshot.clip_point(point.0, Bias::Left));
self.reverse_text_chunks(point.row())
.flat_map(|chunk| chunk.chars().rev())
.skip_while({
@ -499,7 +561,7 @@ impl DisplaySnapshot {
}
pub fn clip_point(&self, point: DisplayPoint, bias: Bias) -> DisplayPoint {
let mut clipped = self.blocks_snapshot.clip_point(point.0, bias);
let mut clipped = self.block_snapshot.clip_point(point.0, bias);
if self.clip_at_line_ends {
clipped = self.clip_at_line_end(DisplayPoint(clipped)).0
}
@ -510,7 +572,7 @@ impl DisplaySnapshot {
let mut point = point.0;
if point.column == self.line_len(point.row) {
point.column = point.column.saturating_sub(1);
point = self.blocks_snapshot.clip_point(point, Bias::Left);
point = self.block_snapshot.clip_point(point, Bias::Left);
}
DisplayPoint(point)
}
@ -519,37 +581,34 @@ impl DisplaySnapshot {
where
T: ToOffset,
{
self.folds_snapshot.folds_in_range(range)
self.fold_snapshot.folds_in_range(range)
}
pub fn blocks_in_range(
&self,
rows: Range<u32>,
) -> impl Iterator<Item = (u32, &TransformBlock)> {
self.blocks_snapshot.blocks_in_range(rows)
self.block_snapshot.blocks_in_range(rows)
}
pub fn intersects_fold<T: ToOffset>(&self, offset: T) -> bool {
self.folds_snapshot.intersects_fold(offset)
self.fold_snapshot.intersects_fold(offset)
}
pub fn is_line_folded(&self, display_row: u32) -> bool {
let block_point = BlockPoint(Point::new(display_row, 0));
let wrap_point = self.blocks_snapshot.to_wrap_point(block_point);
let tab_point = self.wraps_snapshot.to_tab_point(wrap_point);
self.folds_snapshot.is_line_folded(tab_point.row())
pub fn is_line_folded(&self, buffer_row: u32) -> bool {
self.fold_snapshot.is_line_folded(buffer_row)
}
pub fn is_block_line(&self, display_row: u32) -> bool {
self.blocks_snapshot.is_block_line(display_row)
self.block_snapshot.is_block_line(display_row)
}
pub fn soft_wrap_indent(&self, display_row: u32) -> Option<u32> {
let wrap_row = self
.blocks_snapshot
.block_snapshot
.to_wrap_point(BlockPoint::new(display_row, 0))
.row();
self.wraps_snapshot.soft_wrap_indent(wrap_row)
self.wrap_snapshot.soft_wrap_indent(wrap_row)
}
pub fn text(&self) -> String {
@ -583,12 +642,92 @@ impl DisplaySnapshot {
(indent, is_blank)
}
pub fn line_indent_for_buffer_row(&self, buffer_row: u32) -> (u32, bool) {
let (buffer, range) = self
.buffer_snapshot
.buffer_line_for_row(buffer_row)
.unwrap();
let mut indent_size = 0;
let mut is_blank = false;
for c in buffer.chars_at(Point::new(range.start.row, 0)) {
if c == ' ' || c == '\t' {
indent_size += 1;
} else {
if c == '\n' {
is_blank = true;
}
break;
}
}
(indent_size, is_blank)
}
pub fn line_len(&self, row: u32) -> u32 {
self.blocks_snapshot.line_len(row)
self.block_snapshot.line_len(row)
}
pub fn longest_row(&self) -> u32 {
self.blocks_snapshot.longest_row()
self.block_snapshot.longest_row()
}
pub fn fold_for_line(self: &Self, buffer_row: u32) -> Option<FoldStatus> {
if self.is_line_folded(buffer_row) {
Some(FoldStatus::Folded)
} else if self.is_foldable(buffer_row) {
Some(FoldStatus::Foldable)
} else {
None
}
}
pub fn is_foldable(self: &Self, buffer_row: u32) -> bool {
let max_row = self.buffer_snapshot.max_buffer_row();
if buffer_row >= max_row {
return false;
}
let (indent_size, is_blank) = self.line_indent_for_buffer_row(buffer_row);
if is_blank {
return false;
}
for next_row in (buffer_row + 1)..=max_row {
let (next_indent_size, next_line_is_blank) = self.line_indent_for_buffer_row(next_row);
if next_indent_size > indent_size {
return true;
} else if !next_line_is_blank {
break;
}
}
false
}
pub fn foldable_range(self: &Self, buffer_row: u32) -> Option<Range<Point>> {
let start = Point::new(buffer_row, self.buffer_snapshot.line_len(buffer_row));
if self.is_foldable(start.row) && !self.is_line_folded(start.row) {
let (start_indent, _) = self.line_indent_for_buffer_row(buffer_row);
let max_point = self.buffer_snapshot.max_point();
let mut end = None;
for row in (buffer_row + 1)..=max_point.row {
let (indent, is_blank) = self.line_indent_for_buffer_row(row);
if !is_blank && indent <= start_indent {
let prev_row = row - 1;
end = Some(Point::new(
prev_row,
self.buffer_snapshot.line_len(prev_row),
));
break;
}
}
let end = end.unwrap_or(max_point);
Some(start..end)
} else {
None
}
}
#[cfg(any(test, feature = "test-support"))]
@ -647,10 +786,11 @@ impl DisplayPoint {
}
pub fn to_offset(self, map: &DisplaySnapshot, bias: Bias) -> usize {
let unblocked_point = map.blocks_snapshot.to_wrap_point(self.0);
let unwrapped_point = map.wraps_snapshot.to_tab_point(unblocked_point);
let unexpanded_point = map.tabs_snapshot.to_fold_point(unwrapped_point, bias).0;
unexpanded_point.to_buffer_offset(&map.folds_snapshot)
let wrap_point = map.block_snapshot.to_wrap_point(self.0);
let tab_point = map.wrap_snapshot.to_tab_point(wrap_point);
let suggestion_point = map.tab_snapshot.to_suggestion_point(tab_point, bias).0;
let fold_point = map.suggestion_snapshot.to_fold_point(suggestion_point);
fold_point.to_buffer_offset(&map.fold_snapshot)
}
}
@ -678,6 +818,24 @@ impl ToDisplayPoint for Anchor {
}
}
pub fn next_rows(display_row: u32, display_map: &DisplaySnapshot) -> impl Iterator<Item = u32> {
let max_row = display_map.max_point().row();
let start_row = display_row + 1;
let mut current = None;
std::iter::from_fn(move || {
if current == None {
current = Some(start_row);
} else {
current = Some(current.unwrap() + 1)
}
if current.unwrap() > max_row {
None
} else {
current
}
})
}
#[cfg(test)]
pub mod tests {
use super::*;
@ -703,7 +861,9 @@ pub mod tests {
let mut tab_size = rng.gen_range(1..=4);
let buffer_start_excerpt_header_height = rng.gen_range(1..=5);
let excerpt_header_height = rng.gen_range(1..=5);
let family_id = font_cache.load_family(&["Helvetica"]).unwrap();
let family_id = font_cache
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = font_cache
.select_font(family_id, &Default::default())
.unwrap();
@ -753,10 +913,10 @@ pub mod tests {
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
log::info!("buffer text: {:?}", snapshot.buffer_snapshot.text());
log::info!("fold text: {:?}", snapshot.folds_snapshot.text());
log::info!("tab text: {:?}", snapshot.tabs_snapshot.text());
log::info!("wrap text: {:?}", snapshot.wraps_snapshot.text());
log::info!("block text: {:?}", snapshot.blocks_snapshot.text());
log::info!("fold text: {:?}", snapshot.fold_snapshot.text());
log::info!("tab text: {:?}", snapshot.tab_snapshot.text());
log::info!("wrap text: {:?}", snapshot.wrap_snapshot.text());
log::info!("block text: {:?}", snapshot.block_snapshot.text());
log::info!("display text: {:?}", snapshot.text());
for _i in 0..operations {
@ -861,10 +1021,10 @@ pub mod tests {
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
fold_count = snapshot.fold_count();
log::info!("buffer text: {:?}", snapshot.buffer_snapshot.text());
log::info!("fold text: {:?}", snapshot.folds_snapshot.text());
log::info!("tab text: {:?}", snapshot.tabs_snapshot.text());
log::info!("wrap text: {:?}", snapshot.wraps_snapshot.text());
log::info!("block text: {:?}", snapshot.blocks_snapshot.text());
log::info!("fold text: {:?}", snapshot.fold_snapshot.text());
log::info!("tab text: {:?}", snapshot.tab_snapshot.text());
log::info!("wrap text: {:?}", snapshot.wrap_snapshot.text());
log::info!("block text: {:?}", snapshot.block_snapshot.text());
log::info!("display text: {:?}", snapshot.text());
// Line boundaries
@ -960,7 +1120,9 @@ pub mod tests {
let font_cache = cx.font_cache();
let family_id = font_cache.load_family(&["Helvetica"]).unwrap();
let family_id = font_cache
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = font_cache
.select_font(family_id, &Default::default())
.unwrap();
@ -1049,7 +1211,10 @@ pub mod tests {
cx.set_global(Settings::test(cx));
let text = sample_text(6, 6, 'a');
let buffer = MultiBuffer::build_simple(&text, cx);
let family_id = cx.font_cache().load_family(&["Helvetica"]).unwrap();
let family_id = cx
.font_cache()
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = cx
.font_cache()
.select_font(family_id, &Default::default())
@ -1132,7 +1297,9 @@ pub mod tests {
let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
let font_cache = cx.font_cache();
let family_id = font_cache.load_family(&["Helvetica"]).unwrap();
let family_id = font_cache
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = font_cache
.select_font(family_id, &Default::default())
.unwrap();
@ -1167,7 +1334,7 @@ pub mod tests {
vec![
("fn ".to_string(), None),
("out".to_string(), Some(Color::blue())),
("".to_string(), None),
("".to_string(), None),
(" fn ".to_string(), Some(Color::red())),
("inner".to_string(), Some(Color::blue())),
("() {}\n}".to_string(), Some(Color::red())),
@ -1220,7 +1387,9 @@ pub mod tests {
let font_cache = cx.font_cache();
let family_id = font_cache.load_family(&["Courier"]).unwrap();
let family_id = font_cache
.load_family(&["Courier"], &Default::default())
.unwrap();
let font_id = font_cache
.select_font(family_id, &Default::default())
.unwrap();
@ -1248,7 +1417,7 @@ pub mod tests {
cx.update(|cx| syntax_chunks(1..4, &map, &theme, cx)),
[
("out".to_string(), Some(Color::blue())),
("\n".to_string(), None),
("\n".to_string(), None),
(" \nfn ".to_string(), Some(Color::red())),
("i\n".to_string(), Some(Color::blue()))
]
@ -1292,7 +1461,9 @@ pub mod tests {
let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
let font_cache = cx.font_cache();
let family_id = font_cache.load_family(&["Courier"]).unwrap();
let family_id = font_cache
.load_family(&["Courier"], &Default::default())
.unwrap();
let font_id = font_cache
.select_font(family_id, &Default::default())
.unwrap();
@ -1408,7 +1579,9 @@ pub mod tests {
let text = "\t\tα\nβ\t\n🏀β\t\tγ";
let buffer = MultiBuffer::build_simple(text, cx);
let font_cache = cx.font_cache();
let family_id = font_cache.load_family(&["Helvetica"]).unwrap();
let family_id = font_cache
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = font_cache
.select_font(family_id, &Default::default())
.unwrap();
@ -1466,7 +1639,9 @@ pub mod tests {
cx.set_global(Settings::test(cx));
let buffer = MultiBuffer::build_simple("aaa\n\t\tbbb", cx);
let font_cache = cx.font_cache();
let family_id = font_cache.load_family(&["Helvetica"]).unwrap();
let family_id = font_cache
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = font_cache
.select_font(family_id, &Default::default())
.unwrap();
@ -1525,7 +1700,7 @@ pub mod tests {
) -> Vec<(String, Option<Color>, Option<Color>)> {
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
let mut chunks: Vec<(String, Option<Color>, Option<Color>)> = Vec::new();
for chunk in snapshot.chunks(rows, true) {
for chunk in snapshot.chunks(rows, true, None) {
let syntax_color = chunk
.syntax_highlight_id
.and_then(|id| id.style(theme)?.color);

View file

@ -4,7 +4,7 @@ use super::{
};
use crate::{Anchor, ExcerptId, ExcerptRange, ToPoint as _};
use collections::{Bound, HashMap, HashSet};
use gpui::{ElementBox, RenderContext};
use gpui::{fonts::HighlightStyle, ElementBox, RenderContext};
use language::{BufferSnapshot, Chunk, Patch, Point};
use parking_lot::Mutex;
use std::{
@ -572,7 +572,7 @@ impl<'a> BlockMapWriter<'a> {
impl BlockSnapshot {
#[cfg(test)]
pub fn text(&self) -> String {
self.chunks(0..self.transforms.summary().output_rows, false, None)
self.chunks(0..self.transforms.summary().output_rows, false, None, None)
.map(|chunk| chunk.text)
.collect()
}
@ -582,6 +582,7 @@ impl BlockSnapshot {
rows: Range<u32>,
language_aware: bool,
text_highlights: Option<&'a TextHighlights>,
suggestion_highlight: Option<HighlightStyle>,
) -> BlockChunks<'a> {
let max_output_row = cmp::min(rows.end, self.transforms.summary().output_rows);
let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>();
@ -614,6 +615,7 @@ impl BlockSnapshot {
input_start..input_end,
language_aware,
text_highlights,
suggestion_highlight,
),
input_chunk: Default::default(),
transforms: cursor,
@ -989,6 +991,7 @@ fn offset_for_row(s: &str, target: u32) -> (u32, usize) {
#[cfg(test)]
mod tests {
use super::*;
use crate::display_map::suggestion_map::SuggestionMap;
use crate::display_map::{fold_map::FoldMap, tab_map::TabMap, wrap_map::WrapMap};
use crate::multi_buffer::MultiBuffer;
use gpui::{elements::Empty, Element};
@ -1015,7 +1018,10 @@ mod tests {
fn test_basic_blocks(cx: &mut gpui::MutableAppContext) {
cx.set_global(Settings::test(cx));
let family_id = cx.font_cache().load_family(&["Helvetica"]).unwrap();
let family_id = cx
.font_cache()
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = cx
.font_cache()
.select_font(family_id, &Default::default())
@ -1026,9 +1032,10 @@ mod tests {
let buffer = MultiBuffer::build_simple(text, cx);
let buffer_snapshot = buffer.read(cx).snapshot(cx);
let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
let (fold_map, folds_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (tab_map, tabs_snapshot) = TabMap::new(folds_snapshot, 1.try_into().unwrap());
let (wrap_map, wraps_snapshot) = WrapMap::new(tabs_snapshot, font_id, 14.0, None, cx);
let (fold_map, fold_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (suggestion_map, suggestion_snapshot) = SuggestionMap::new(fold_snapshot);
let (tab_map, tab_snapshot) = TabMap::new(suggestion_snapshot, 1.try_into().unwrap());
let (wrap_map, wraps_snapshot) = WrapMap::new(tab_snapshot, font_id, 14.0, None, cx);
let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1);
let mut writer = block_map.write(wraps_snapshot.clone(), Default::default());
@ -1170,12 +1177,14 @@ mod tests {
buffer.snapshot(cx)
});
let (folds_snapshot, fold_edits) =
let (fold_snapshot, fold_edits) =
fold_map.read(buffer_snapshot, subscription.consume().into_inner());
let (tabs_snapshot, tab_edits) =
tab_map.sync(folds_snapshot, fold_edits, 4.try_into().unwrap());
let (suggestion_snapshot, suggestion_edits) =
suggestion_map.sync(fold_snapshot, fold_edits);
let (tab_snapshot, tab_edits) =
tab_map.sync(suggestion_snapshot, suggestion_edits, 4.try_into().unwrap());
let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| {
wrap_map.sync(tabs_snapshot, tab_edits, cx)
wrap_map.sync(tab_snapshot, tab_edits, cx)
});
let snapshot = block_map.read(wraps_snapshot, wrap_edits);
assert_eq!(snapshot.text(), "aaa\n\nb!!!\n\n\nbb\nccc\nddd\n\n\n");
@ -1185,7 +1194,10 @@ mod tests {
fn test_blocks_on_wrapped_lines(cx: &mut gpui::MutableAppContext) {
cx.set_global(Settings::test(cx));
let family_id = cx.font_cache().load_family(&["Helvetica"]).unwrap();
let family_id = cx
.font_cache()
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = cx
.font_cache()
.select_font(family_id, &Default::default())
@ -1195,9 +1207,10 @@ mod tests {
let buffer = MultiBuffer::build_simple(text, cx);
let buffer_snapshot = buffer.read(cx).snapshot(cx);
let (_, folds_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (_, tabs_snapshot) = TabMap::new(folds_snapshot, 1.try_into().unwrap());
let (_, wraps_snapshot) = WrapMap::new(tabs_snapshot, font_id, 14.0, Some(60.), cx);
let (_, fold_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (_, suggestion_snapshot) = SuggestionMap::new(fold_snapshot);
let (_, tab_snapshot) = TabMap::new(suggestion_snapshot, 1.try_into().unwrap());
let (_, wraps_snapshot) = WrapMap::new(tab_snapshot, font_id, 14.0, Some(60.), cx);
let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1);
let mut writer = block_map.write(wraps_snapshot.clone(), Default::default());
@ -1241,7 +1254,10 @@ mod tests {
Some(rng.gen_range(0.0..=100.0))
};
let tab_size = 1.try_into().unwrap();
let family_id = cx.font_cache().load_family(&["Helvetica"]).unwrap();
let family_id = cx
.font_cache()
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = cx
.font_cache()
.select_font(family_id, &Default::default())
@ -1263,10 +1279,11 @@ mod tests {
};
let mut buffer_snapshot = buffer.read(cx).snapshot(cx);
let (fold_map, folds_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (tab_map, tabs_snapshot) = TabMap::new(folds_snapshot, tab_size);
let (fold_map, fold_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (suggestion_map, suggestion_snapshot) = SuggestionMap::new(fold_snapshot);
let (tab_map, tab_snapshot) = TabMap::new(suggestion_snapshot, tab_size);
let (wrap_map, wraps_snapshot) =
WrapMap::new(tabs_snapshot, font_id, font_size, wrap_width, cx);
WrapMap::new(tab_snapshot, font_id, font_size, wrap_width, cx);
let mut block_map = BlockMap::new(
wraps_snapshot,
buffer_start_header_height,
@ -1317,12 +1334,14 @@ mod tests {
})
.collect::<Vec<_>>();
let (folds_snapshot, fold_edits) =
let (fold_snapshot, fold_edits) =
fold_map.read(buffer_snapshot.clone(), vec![]);
let (tabs_snapshot, tab_edits) =
tab_map.sync(folds_snapshot, fold_edits, tab_size);
let (suggestion_snapshot, suggestion_edits) =
suggestion_map.sync(fold_snapshot, fold_edits);
let (tab_snapshot, tab_edits) =
tab_map.sync(suggestion_snapshot, suggestion_edits, tab_size);
let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| {
wrap_map.sync(tabs_snapshot, tab_edits, cx)
wrap_map.sync(tab_snapshot, tab_edits, cx)
});
let mut block_map = block_map.write(wraps_snapshot, wrap_edits);
let block_ids = block_map.insert(block_properties.clone());
@ -1340,12 +1359,14 @@ mod tests {
})
.collect();
let (folds_snapshot, fold_edits) =
let (fold_snapshot, fold_edits) =
fold_map.read(buffer_snapshot.clone(), vec![]);
let (tabs_snapshot, tab_edits) =
tab_map.sync(folds_snapshot, fold_edits, tab_size);
let (suggestion_snapshot, suggestion_edits) =
suggestion_map.sync(fold_snapshot, fold_edits);
let (tab_snapshot, tab_edits) =
tab_map.sync(suggestion_snapshot, suggestion_edits, tab_size);
let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| {
wrap_map.sync(tabs_snapshot, tab_edits, cx)
wrap_map.sync(tab_snapshot, tab_edits, cx)
});
let mut block_map = block_map.write(wraps_snapshot, wrap_edits);
block_map.remove(block_ids_to_remove);
@ -1362,10 +1383,13 @@ mod tests {
}
}
let (folds_snapshot, fold_edits) = fold_map.read(buffer_snapshot.clone(), buffer_edits);
let (tabs_snapshot, tab_edits) = tab_map.sync(folds_snapshot, fold_edits, tab_size);
let (fold_snapshot, fold_edits) = fold_map.read(buffer_snapshot.clone(), buffer_edits);
let (suggestion_snapshot, suggestion_edits) =
suggestion_map.sync(fold_snapshot, fold_edits);
let (tab_snapshot, tab_edits) =
tab_map.sync(suggestion_snapshot, suggestion_edits, tab_size);
let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| {
wrap_map.sync(tabs_snapshot, tab_edits, cx)
wrap_map.sync(tab_snapshot, tab_edits, cx)
});
let blocks_snapshot = block_map.read(wraps_snapshot.clone(), wrap_edits);
assert_eq!(
@ -1476,6 +1500,7 @@ mod tests {
start_row as u32..blocks_snapshot.max_point().row + 1,
false,
None,
None,
)
.map(|chunk| chunk.text)
.collect::<String>();

View file

@ -4,7 +4,7 @@ use crate::{
ToOffset,
};
use collections::BTreeMap;
use gpui::fonts::HighlightStyle;
use gpui::{color::Color, fonts::HighlightStyle};
use language::{Chunk, Edit, Point, TextSummary};
use parking_lot::Mutex;
use std::{
@ -29,10 +29,6 @@ impl FoldPoint {
self.0.row
}
pub fn column(self) -> u32 {
self.0.column
}
pub fn row_mut(&mut self) -> &mut u32 {
&mut self.0.row
}
@ -133,6 +129,7 @@ impl<'a> FoldMapWriter<'a> {
folds: self.0.folds.clone(),
buffer_snapshot: buffer,
version: self.0.version.load(SeqCst),
ellipses_color: self.0.ellipses_color,
};
(snapshot, edits)
}
@ -182,6 +179,7 @@ impl<'a> FoldMapWriter<'a> {
folds: self.0.folds.clone(),
buffer_snapshot: buffer,
version: self.0.version.load(SeqCst),
ellipses_color: self.0.ellipses_color,
};
(snapshot, edits)
}
@ -192,6 +190,7 @@ pub struct FoldMap {
transforms: Mutex<SumTree<Transform>>,
folds: SumTree<Fold>,
version: AtomicUsize,
ellipses_color: Option<Color>,
}
impl FoldMap {
@ -209,6 +208,7 @@ impl FoldMap {
},
&(),
)),
ellipses_color: None,
version: Default::default(),
};
@ -217,6 +217,7 @@ impl FoldMap {
folds: this.folds.clone(),
buffer_snapshot: this.buffer.lock().clone(),
version: this.version.load(SeqCst),
ellipses_color: None,
};
(this, snapshot)
}
@ -233,6 +234,7 @@ impl FoldMap {
folds: self.folds.clone(),
buffer_snapshot: self.buffer.lock().clone(),
version: self.version.load(SeqCst),
ellipses_color: self.ellipses_color,
};
(snapshot, edits)
}
@ -246,6 +248,15 @@ impl FoldMap {
(FoldMapWriter(self), snapshot, edits)
}
pub fn set_ellipses_color(&mut self, color: Color) -> bool {
if self.ellipses_color != Some(color) {
self.ellipses_color = Some(color);
true
} else {
false
}
}
fn check_invariants(&self) {
if cfg!(test) {
assert_eq!(
@ -370,7 +381,7 @@ impl FoldMap {
}
if fold.end > fold.start {
let output_text = "";
let output_text = "";
new_transforms.push(
Transform {
summary: TransformSummary {
@ -477,6 +488,7 @@ pub struct FoldSnapshot {
folds: SumTree<Fold>,
buffer_snapshot: MultiBufferSnapshot,
pub version: usize,
pub ellipses_color: Option<Color>,
}
impl FoldSnapshot {
@ -623,14 +635,14 @@ impl FoldSnapshot {
cursor.item().map_or(false, |t| t.output_text.is_some())
}
pub fn is_line_folded(&self, output_row: u32) -> bool {
let mut cursor = self.transforms.cursor::<FoldPoint>();
cursor.seek(&FoldPoint::new(output_row, 0), Bias::Right, &());
pub fn is_line_folded(&self, buffer_row: u32) -> bool {
let mut cursor = self.transforms.cursor::<Point>();
cursor.seek(&Point::new(buffer_row, 0), Bias::Right, &());
while let Some(transform) = cursor.item() {
if transform.output_text.is_some() {
return true;
}
if cursor.end(&()).row() == output_row {
if cursor.end(&()).row == buffer_row {
cursor.next(&())
} else {
break;
@ -639,12 +651,6 @@ impl FoldSnapshot {
false
}
pub fn chars_at(&self, start: FoldPoint) -> impl '_ + Iterator<Item = char> {
let start = start.to_offset(self);
self.chunks(start..self.len(), false, None)
.flat_map(|chunk| chunk.text.chars())
}
pub fn chunks<'a>(
&'a self,
range: Range<FoldOffset>,
@ -739,6 +745,7 @@ impl FoldSnapshot {
max_output_offset: range.end.0,
highlight_endpoints: highlight_endpoints.into_iter().peekable(),
active_highlights: Default::default(),
ellipses_color: self.ellipses_color,
}
}
@ -1029,6 +1036,7 @@ pub struct FoldChunks<'a> {
max_output_offset: usize,
highlight_endpoints: Peekable<vec::IntoIter<HighlightEndpoint>>,
active_highlights: BTreeMap<Option<TypeId>, HighlightStyle>,
ellipses_color: Option<Color>,
}
impl<'a> Iterator for FoldChunks<'a> {
@ -1058,7 +1066,10 @@ impl<'a> Iterator for FoldChunks<'a> {
return Some(Chunk {
text: output_text,
syntax_highlight_id: None,
highlight_style: None,
highlight_style: self.ellipses_color.map(|color| HighlightStyle {
color: Some(color),
..Default::default()
}),
diagnostic_severity: None,
is_unnecessary: false,
});
@ -1193,6 +1204,7 @@ pub type FoldEdit = Edit<FoldOffset>;
mod tests {
use super::*;
use crate::{MultiBuffer, ToPoint};
use collections::HashSet;
use rand::prelude::*;
use settings::Settings;
use std::{cmp::Reverse, env, mem, sync::Arc};
@ -1214,7 +1226,7 @@ mod tests {
Point::new(0, 2)..Point::new(2, 2),
Point::new(2, 4)..Point::new(4, 1),
]);
assert_eq!(snapshot2.text(), "aa…cc…eeeee");
assert_eq!(snapshot2.text(), "aa⋯cc⋯eeeee");
assert_eq!(
edits,
&[
@ -1241,7 +1253,7 @@ mod tests {
buffer.snapshot(cx)
});
let (snapshot3, edits) = map.read(buffer_snapshot, subscription.consume().into_inner());
assert_eq!(snapshot3.text(), "123a…c123c…eeeee");
assert_eq!(snapshot3.text(), "123a⋯c123c⋯eeeee");
assert_eq!(
edits,
&[
@ -1261,12 +1273,12 @@ mod tests {
buffer.snapshot(cx)
});
let (snapshot4, _) = map.read(buffer_snapshot.clone(), subscription.consume().into_inner());
assert_eq!(snapshot4.text(), "123ac123456eee");
assert_eq!(snapshot4.text(), "123ac123456eee");
let (mut writer, _, _) = map.write(buffer_snapshot.clone(), vec![]);
writer.unfold(Some(Point::new(0, 4)..Point::new(0, 4)), false);
let (snapshot5, _) = map.read(buffer_snapshot.clone(), vec![]);
assert_eq!(snapshot5.text(), "123ac123456eee");
assert_eq!(snapshot5.text(), "123ac123456eee");
let (mut writer, _, _) = map.write(buffer_snapshot.clone(), vec![]);
writer.unfold(Some(Point::new(0, 4)..Point::new(0, 4)), true);
@ -1287,19 +1299,19 @@ mod tests {
let (mut writer, _, _) = map.write(buffer_snapshot.clone(), vec![]);
writer.fold(vec![5..8]);
let (snapshot, _) = map.read(buffer_snapshot.clone(), vec![]);
assert_eq!(snapshot.text(), "abcdeijkl");
assert_eq!(snapshot.text(), "abcdeijkl");
// Create an fold adjacent to the start of the first fold.
let (mut writer, _, _) = map.write(buffer_snapshot.clone(), vec![]);
writer.fold(vec![0..1, 2..5]);
let (snapshot, _) = map.read(buffer_snapshot.clone(), vec![]);
assert_eq!(snapshot.text(), "…b…ijkl");
assert_eq!(snapshot.text(), "⋯b⋯ijkl");
// Create an fold adjacent to the end of the first fold.
let (mut writer, _, _) = map.write(buffer_snapshot.clone(), vec![]);
writer.fold(vec![11..11, 8..10]);
let (snapshot, _) = map.read(buffer_snapshot.clone(), vec![]);
assert_eq!(snapshot.text(), "…b…kl");
assert_eq!(snapshot.text(), "⋯b⋯kl");
}
{
@ -1309,7 +1321,7 @@ mod tests {
let (mut writer, _, _) = map.write(buffer_snapshot.clone(), vec![]);
writer.fold(vec![0..2, 2..5]);
let (snapshot, _) = map.read(buffer_snapshot, vec![]);
assert_eq!(snapshot.text(), "fghijkl");
assert_eq!(snapshot.text(), "fghijkl");
// Edit within one of the folds.
let buffer_snapshot = buffer.update(cx, |buffer, cx| {
@ -1317,7 +1329,7 @@ mod tests {
buffer.snapshot(cx)
});
let (snapshot, _) = map.read(buffer_snapshot, subscription.consume().into_inner());
assert_eq!(snapshot.text(), "12345fghijkl");
assert_eq!(snapshot.text(), "12345fghijkl");
}
}
@ -1334,7 +1346,7 @@ mod tests {
Point::new(3, 1)..Point::new(4, 1),
]);
let (snapshot, _) = map.read(buffer_snapshot, vec![]);
assert_eq!(snapshot.text(), "aaeeeee");
assert_eq!(snapshot.text(), "aaeeeee");
}
#[gpui::test]
@ -1351,14 +1363,14 @@ mod tests {
Point::new(3, 1)..Point::new(4, 1),
]);
let (snapshot, _) = map.read(buffer_snapshot, vec![]);
assert_eq!(snapshot.text(), "aa…cccc\nd…eeeee");
assert_eq!(snapshot.text(), "aa⋯cccc\nd⋯eeeee");
let buffer_snapshot = buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(2, 2)..Point::new(3, 1), "")], None, cx);
buffer.snapshot(cx)
});
let (snapshot, _) = map.read(buffer_snapshot, subscription.consume().into_inner());
assert_eq!(snapshot.text(), "aaeeeee");
assert_eq!(snapshot.text(), "aaeeeee");
}
#[gpui::test]
@ -1450,7 +1462,7 @@ mod tests {
let mut expected_text: String = buffer_snapshot.text().to_string();
for fold_range in map.merged_fold_ranges().into_iter().rev() {
expected_text.replace_range(fold_range.start..fold_range.end, "");
expected_text.replace_range(fold_range.start..fold_range.end, "");
}
assert_eq!(snapshot.text(), expected_text);
@ -1572,10 +1584,13 @@ mod tests {
fold_row += 1;
}
for fold_range in map.merged_fold_ranges() {
let fold_point =
snapshot.to_fold_point(fold_range.start.to_point(&buffer_snapshot), Right);
assert!(snapshot.is_line_folded(fold_point.row()));
let fold_start_rows = map
.merged_fold_ranges()
.iter()
.map(|range| range.start.to_point(&buffer_snapshot).row)
.collect::<HashSet<_>>();
for row in fold_start_rows {
assert!(snapshot.is_line_folded(row));
}
for _ in 0..5 {
@ -1655,7 +1670,7 @@ mod tests {
]);
let (snapshot, _) = map.read(buffer_snapshot, vec![]);
assert_eq!(snapshot.text(), "aa…cccc\nd…eeeee\nffffff\n");
assert_eq!(snapshot.text(), "aa⋯cccc\nd⋯eeeee\nffffff\n");
assert_eq!(
snapshot.buffer_rows(0).collect::<Vec<_>>(),
[Some(0), Some(3), Some(5), Some(6)]

View file

@ -0,0 +1,860 @@
use super::{
fold_map::{FoldBufferRows, FoldChunks, FoldEdit, FoldOffset, FoldPoint, FoldSnapshot},
TextHighlights,
};
use crate::{MultiBufferSnapshot, ToPoint};
use gpui::fonts::HighlightStyle;
use language::{Bias, Chunk, Edit, Patch, Point, Rope, TextSummary};
use parking_lot::Mutex;
use std::{
cmp,
ops::{Add, AddAssign, Range, Sub},
};
use util::post_inc;
pub type SuggestionEdit = Edit<SuggestionOffset>;
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
pub struct SuggestionOffset(pub usize);
impl Add for SuggestionOffset {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl Sub for SuggestionOffset {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0)
}
}
impl AddAssign for SuggestionOffset {
fn add_assign(&mut self, rhs: Self) {
self.0 += rhs.0;
}
}
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
pub struct SuggestionPoint(pub Point);
impl SuggestionPoint {
pub fn new(row: u32, column: u32) -> Self {
Self(Point::new(row, column))
}
pub fn row(self) -> u32 {
self.0.row
}
pub fn column(self) -> u32 {
self.0.column
}
}
#[derive(Clone, Debug)]
pub struct Suggestion<T> {
pub position: T,
pub text: Rope,
}
pub struct SuggestionMap(Mutex<SuggestionSnapshot>);
impl SuggestionMap {
pub fn new(fold_snapshot: FoldSnapshot) -> (Self, SuggestionSnapshot) {
let snapshot = SuggestionSnapshot {
fold_snapshot,
suggestion: None,
version: 0,
};
(Self(Mutex::new(snapshot.clone())), snapshot)
}
pub fn replace<T>(
&self,
new_suggestion: Option<Suggestion<T>>,
fold_snapshot: FoldSnapshot,
fold_edits: Vec<FoldEdit>,
) -> (SuggestionSnapshot, Vec<SuggestionEdit>)
where
T: ToPoint,
{
let new_suggestion = new_suggestion.map(|new_suggestion| {
let buffer_point = new_suggestion
.position
.to_point(fold_snapshot.buffer_snapshot());
let fold_point = fold_snapshot.to_fold_point(buffer_point, Bias::Left);
let fold_offset = fold_point.to_offset(&fold_snapshot);
Suggestion {
position: fold_offset,
text: new_suggestion.text,
}
});
let (_, edits) = self.sync(fold_snapshot, fold_edits);
let mut snapshot = self.0.lock();
let mut patch = Patch::new(edits);
if let Some(suggestion) = snapshot.suggestion.take() {
patch = patch.compose([SuggestionEdit {
old: SuggestionOffset(suggestion.position.0)
..SuggestionOffset(suggestion.position.0 + suggestion.text.len()),
new: SuggestionOffset(suggestion.position.0)
..SuggestionOffset(suggestion.position.0),
}]);
}
if let Some(suggestion) = new_suggestion.as_ref() {
patch = patch.compose([SuggestionEdit {
old: SuggestionOffset(suggestion.position.0)
..SuggestionOffset(suggestion.position.0),
new: SuggestionOffset(suggestion.position.0)
..SuggestionOffset(suggestion.position.0 + suggestion.text.len()),
}]);
}
snapshot.suggestion = new_suggestion;
snapshot.version += 1;
(snapshot.clone(), patch.into_inner())
}
pub fn sync(
&self,
fold_snapshot: FoldSnapshot,
fold_edits: Vec<FoldEdit>,
) -> (SuggestionSnapshot, Vec<SuggestionEdit>) {
let mut snapshot = self.0.lock();
if snapshot.fold_snapshot.version != fold_snapshot.version {
snapshot.version += 1;
}
let mut suggestion_edits = Vec::new();
let mut suggestion_old_len = 0;
let mut suggestion_new_len = 0;
for fold_edit in fold_edits {
let start = fold_edit.new.start;
let end = FoldOffset(start.0 + fold_edit.old_len().0);
if let Some(suggestion) = snapshot.suggestion.as_mut() {
if end <= suggestion.position {
suggestion.position.0 += fold_edit.new_len().0;
suggestion.position.0 -= fold_edit.old_len().0;
} else if start > suggestion.position {
suggestion_old_len = suggestion.text.len();
suggestion_new_len = suggestion_old_len;
} else {
suggestion_old_len = suggestion.text.len();
snapshot.suggestion.take();
suggestion_edits.push(SuggestionEdit {
old: SuggestionOffset(fold_edit.old.start.0)
..SuggestionOffset(fold_edit.old.end.0 + suggestion_old_len),
new: SuggestionOffset(fold_edit.new.start.0)
..SuggestionOffset(fold_edit.new.end.0),
});
continue;
}
}
suggestion_edits.push(SuggestionEdit {
old: SuggestionOffset(fold_edit.old.start.0 + suggestion_old_len)
..SuggestionOffset(fold_edit.old.end.0 + suggestion_old_len),
new: SuggestionOffset(fold_edit.new.start.0 + suggestion_new_len)
..SuggestionOffset(fold_edit.new.end.0 + suggestion_new_len),
});
}
snapshot.fold_snapshot = fold_snapshot;
(snapshot.clone(), suggestion_edits)
}
pub fn has_suggestion(&self) -> bool {
let snapshot = self.0.lock();
snapshot.suggestion.is_some()
}
}
#[derive(Clone)]
pub struct SuggestionSnapshot {
pub fold_snapshot: FoldSnapshot,
pub suggestion: Option<Suggestion<FoldOffset>>,
pub version: usize,
}
impl SuggestionSnapshot {
pub fn buffer_snapshot(&self) -> &MultiBufferSnapshot {
self.fold_snapshot.buffer_snapshot()
}
pub fn max_point(&self) -> SuggestionPoint {
if let Some(suggestion) = self.suggestion.as_ref() {
let suggestion_point = suggestion.position.to_point(&self.fold_snapshot);
let mut max_point = suggestion_point.0;
max_point += suggestion.text.max_point();
max_point += self.fold_snapshot.max_point().0 - suggestion_point.0;
SuggestionPoint(max_point)
} else {
SuggestionPoint(self.fold_snapshot.max_point().0)
}
}
pub fn len(&self) -> SuggestionOffset {
if let Some(suggestion) = self.suggestion.as_ref() {
let mut len = suggestion.position.0;
len += suggestion.text.len();
len += self.fold_snapshot.len().0 - suggestion.position.0;
SuggestionOffset(len)
} else {
SuggestionOffset(self.fold_snapshot.len().0)
}
}
pub fn line_len(&self, row: u32) -> u32 {
if let Some(suggestion) = &self.suggestion {
let suggestion_start = suggestion.position.to_point(&self.fold_snapshot).0;
let suggestion_end = suggestion_start + suggestion.text.max_point();
if row < suggestion_start.row {
self.fold_snapshot.line_len(row)
} else if row > suggestion_end.row {
self.fold_snapshot
.line_len(suggestion_start.row + (row - suggestion_end.row))
} else {
let mut result = suggestion.text.line_len(row - suggestion_start.row);
if row == suggestion_start.row {
result += suggestion_start.column;
}
if row == suggestion_end.row {
result +=
self.fold_snapshot.line_len(suggestion_start.row) - suggestion_start.column;
}
result
}
} else {
self.fold_snapshot.line_len(row)
}
}
pub fn clip_point(&self, point: SuggestionPoint, bias: Bias) -> SuggestionPoint {
if let Some(suggestion) = self.suggestion.as_ref() {
let suggestion_start = suggestion.position.to_point(&self.fold_snapshot).0;
let suggestion_end = suggestion_start + suggestion.text.max_point();
if point.0 <= suggestion_start {
SuggestionPoint(self.fold_snapshot.clip_point(FoldPoint(point.0), bias).0)
} else if point.0 > suggestion_end {
let fold_point = self.fold_snapshot.clip_point(
FoldPoint(suggestion_start + (point.0 - suggestion_end)),
bias,
);
let suggestion_point = suggestion_end + (fold_point.0 - suggestion_start);
if bias == Bias::Left && suggestion_point == suggestion_end {
SuggestionPoint(suggestion_start)
} else {
SuggestionPoint(suggestion_point)
}
} else if bias == Bias::Left || suggestion_start == self.fold_snapshot.max_point().0 {
SuggestionPoint(suggestion_start)
} else {
let fold_point = if self.fold_snapshot.line_len(suggestion_start.row)
> suggestion_start.column
{
FoldPoint(suggestion_start + Point::new(0, 1))
} else {
FoldPoint(suggestion_start + Point::new(1, 0))
};
let clipped_fold_point = self.fold_snapshot.clip_point(fold_point, bias);
SuggestionPoint(suggestion_end + (clipped_fold_point.0 - suggestion_start))
}
} else {
SuggestionPoint(self.fold_snapshot.clip_point(FoldPoint(point.0), bias).0)
}
}
pub fn to_offset(&self, point: SuggestionPoint) -> SuggestionOffset {
if let Some(suggestion) = self.suggestion.as_ref() {
let suggestion_start = suggestion.position.to_point(&self.fold_snapshot).0;
let suggestion_end = suggestion_start + suggestion.text.max_point();
if point.0 <= suggestion_start {
SuggestionOffset(FoldPoint(point.0).to_offset(&self.fold_snapshot).0)
} else if point.0 > suggestion_end {
let fold_offset = FoldPoint(suggestion_start + (point.0 - suggestion_end))
.to_offset(&self.fold_snapshot);
SuggestionOffset(fold_offset.0 + suggestion.text.len())
} else {
let offset_in_suggestion =
suggestion.text.point_to_offset(point.0 - suggestion_start);
SuggestionOffset(suggestion.position.0 + offset_in_suggestion)
}
} else {
SuggestionOffset(FoldPoint(point.0).to_offset(&self.fold_snapshot).0)
}
}
pub fn to_point(&self, offset: SuggestionOffset) -> SuggestionPoint {
if let Some(suggestion) = self.suggestion.as_ref() {
let suggestion_point_start = suggestion.position.to_point(&self.fold_snapshot).0;
if offset.0 <= suggestion.position.0 {
SuggestionPoint(FoldOffset(offset.0).to_point(&self.fold_snapshot).0)
} else if offset.0 > (suggestion.position.0 + suggestion.text.len()) {
let fold_point = FoldOffset(offset.0 - suggestion.text.len())
.to_point(&self.fold_snapshot)
.0;
SuggestionPoint(
suggestion_point_start
+ suggestion.text.max_point()
+ (fold_point - suggestion_point_start),
)
} else {
let point_in_suggestion = suggestion
.text
.offset_to_point(offset.0 - suggestion.position.0);
SuggestionPoint(suggestion_point_start + point_in_suggestion)
}
} else {
SuggestionPoint(FoldOffset(offset.0).to_point(&self.fold_snapshot).0)
}
}
pub fn to_fold_point(&self, point: SuggestionPoint) -> FoldPoint {
if let Some(suggestion) = self.suggestion.as_ref() {
let suggestion_start = suggestion.position.to_point(&self.fold_snapshot).0;
let suggestion_end = suggestion_start + suggestion.text.max_point();
if point.0 <= suggestion_start {
FoldPoint(point.0)
} else if point.0 > suggestion_end {
FoldPoint(suggestion_start + (point.0 - suggestion_end))
} else {
FoldPoint(suggestion_start)
}
} else {
FoldPoint(point.0)
}
}
pub fn to_suggestion_point(&self, point: FoldPoint) -> SuggestionPoint {
if let Some(suggestion) = self.suggestion.as_ref() {
let suggestion_start = suggestion.position.to_point(&self.fold_snapshot).0;
if point.0 <= suggestion_start {
SuggestionPoint(point.0)
} else {
let suggestion_end = suggestion_start + suggestion.text.max_point();
SuggestionPoint(suggestion_end + (point.0 - suggestion_start))
}
} else {
SuggestionPoint(point.0)
}
}
pub fn text_summary_for_range(&self, range: Range<SuggestionPoint>) -> TextSummary {
if let Some(suggestion) = self.suggestion.as_ref() {
let suggestion_start = suggestion.position.to_point(&self.fold_snapshot).0;
let suggestion_end = suggestion_start + suggestion.text.max_point();
let mut summary = TextSummary::default();
let prefix_range =
cmp::min(range.start.0, suggestion_start)..cmp::min(range.end.0, suggestion_start);
if prefix_range.start < prefix_range.end {
summary += self.fold_snapshot.text_summary_for_range(
FoldPoint(prefix_range.start)..FoldPoint(prefix_range.end),
);
}
let suggestion_range =
cmp::max(range.start.0, suggestion_start)..cmp::min(range.end.0, suggestion_end);
if suggestion_range.start < suggestion_range.end {
let point_range = suggestion_range.start - suggestion_start
..suggestion_range.end - suggestion_start;
let offset_range = suggestion.text.point_to_offset(point_range.start)
..suggestion.text.point_to_offset(point_range.end);
summary += suggestion
.text
.cursor(offset_range.start)
.summary::<TextSummary>(offset_range.end);
}
let suffix_range = cmp::max(range.start.0, suggestion_end)..range.end.0;
if suffix_range.start < suffix_range.end {
let start = suggestion_start + (suffix_range.start - suggestion_end);
let end = suggestion_start + (suffix_range.end - suggestion_end);
summary += self
.fold_snapshot
.text_summary_for_range(FoldPoint(start)..FoldPoint(end));
}
summary
} else {
self.fold_snapshot
.text_summary_for_range(FoldPoint(range.start.0)..FoldPoint(range.end.0))
}
}
pub fn chars_at(&self, start: SuggestionPoint) -> impl '_ + Iterator<Item = char> {
let start = self.to_offset(start);
self.chunks(start..self.len(), false, None, None)
.flat_map(|chunk| chunk.text.chars())
}
pub fn chunks<'a>(
&'a self,
range: Range<SuggestionOffset>,
language_aware: bool,
text_highlights: Option<&'a TextHighlights>,
suggestion_highlight: Option<HighlightStyle>,
) -> SuggestionChunks<'a> {
if let Some(suggestion) = self.suggestion.as_ref() {
let suggestion_range =
suggestion.position.0..suggestion.position.0 + suggestion.text.len();
let prefix_chunks = if range.start.0 < suggestion_range.start {
Some(self.fold_snapshot.chunks(
FoldOffset(range.start.0)
..cmp::min(FoldOffset(suggestion_range.start), FoldOffset(range.end.0)),
language_aware,
text_highlights,
))
} else {
None
};
let clipped_suggestion_range = cmp::max(range.start.0, suggestion_range.start)
..cmp::min(range.end.0, suggestion_range.end);
let suggestion_chunks = if clipped_suggestion_range.start < clipped_suggestion_range.end
{
let start = clipped_suggestion_range.start - suggestion_range.start;
let end = clipped_suggestion_range.end - suggestion_range.start;
Some(suggestion.text.chunks_in_range(start..end))
} else {
None
};
let suffix_chunks = if range.end.0 > suggestion_range.end {
let start = cmp::max(suggestion_range.end, range.start.0) - suggestion_range.len();
let end = range.end.0 - suggestion_range.len();
Some(self.fold_snapshot.chunks(
FoldOffset(start)..FoldOffset(end),
language_aware,
text_highlights,
))
} else {
None
};
SuggestionChunks {
prefix_chunks,
suggestion_chunks,
suffix_chunks,
highlight_style: suggestion_highlight,
}
} else {
SuggestionChunks {
prefix_chunks: Some(self.fold_snapshot.chunks(
FoldOffset(range.start.0)..FoldOffset(range.end.0),
language_aware,
text_highlights,
)),
suggestion_chunks: None,
suffix_chunks: None,
highlight_style: None,
}
}
}
pub fn buffer_rows<'a>(&'a self, row: u32) -> SuggestionBufferRows<'a> {
let suggestion_range = if let Some(suggestion) = self.suggestion.as_ref() {
let start = suggestion.position.to_point(&self.fold_snapshot).0;
let end = start + suggestion.text.max_point();
start.row..end.row
} else {
u32::MAX..u32::MAX
};
let fold_buffer_rows = if row <= suggestion_range.start {
self.fold_snapshot.buffer_rows(row)
} else if row > suggestion_range.end {
self.fold_snapshot
.buffer_rows(row - (suggestion_range.end - suggestion_range.start))
} else {
let mut rows = self.fold_snapshot.buffer_rows(suggestion_range.start);
rows.next();
rows
};
SuggestionBufferRows {
current_row: row,
suggestion_row_start: suggestion_range.start,
suggestion_row_end: suggestion_range.end,
fold_buffer_rows,
}
}
#[cfg(test)]
pub fn text(&self) -> String {
self.chunks(Default::default()..self.len(), false, None, None)
.map(|chunk| chunk.text)
.collect()
}
}
pub struct SuggestionChunks<'a> {
prefix_chunks: Option<FoldChunks<'a>>,
suggestion_chunks: Option<text::Chunks<'a>>,
suffix_chunks: Option<FoldChunks<'a>>,
highlight_style: Option<HighlightStyle>,
}
impl<'a> Iterator for SuggestionChunks<'a> {
type Item = Chunk<'a>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(chunks) = self.prefix_chunks.as_mut() {
if let Some(chunk) = chunks.next() {
return Some(chunk);
} else {
self.prefix_chunks = None;
}
}
if let Some(chunks) = self.suggestion_chunks.as_mut() {
if let Some(chunk) = chunks.next() {
return Some(Chunk {
text: chunk,
syntax_highlight_id: None,
highlight_style: self.highlight_style,
diagnostic_severity: None,
is_unnecessary: false,
});
} else {
self.suggestion_chunks = None;
}
}
if let Some(chunks) = self.suffix_chunks.as_mut() {
if let Some(chunk) = chunks.next() {
return Some(chunk);
} else {
self.suffix_chunks = None;
}
}
None
}
}
#[derive(Clone)]
pub struct SuggestionBufferRows<'a> {
current_row: u32,
suggestion_row_start: u32,
suggestion_row_end: u32,
fold_buffer_rows: FoldBufferRows<'a>,
}
impl<'a> Iterator for SuggestionBufferRows<'a> {
type Item = Option<u32>;
fn next(&mut self) -> Option<Self::Item> {
let row = post_inc(&mut self.current_row);
if row <= self.suggestion_row_start || row > self.suggestion_row_end {
self.fold_buffer_rows.next()
} else {
Some(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{display_map::fold_map::FoldMap, MultiBuffer};
use gpui::MutableAppContext;
use rand::{prelude::StdRng, Rng};
use settings::Settings;
use std::{
env,
ops::{Bound, RangeBounds},
};
#[gpui::test]
fn test_basic(cx: &mut MutableAppContext) {
let buffer = MultiBuffer::build_simple("abcdefghi", cx);
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
let (mut fold_map, fold_snapshot) = FoldMap::new(buffer.read(cx).snapshot(cx));
let (suggestion_map, suggestion_snapshot) = SuggestionMap::new(fold_snapshot.clone());
assert_eq!(suggestion_snapshot.text(), "abcdefghi");
let (suggestion_snapshot, _) = suggestion_map.replace(
Some(Suggestion {
position: 3,
text: "123\n456".into(),
}),
fold_snapshot,
Default::default(),
);
assert_eq!(suggestion_snapshot.text(), "abc123\n456defghi");
buffer.update(cx, |buffer, cx| {
buffer.edit(
[(0..0, "ABC"), (3..3, "DEF"), (4..4, "GHI"), (9..9, "JKL")],
None,
cx,
)
});
let (fold_snapshot, fold_edits) = fold_map.read(
buffer.read(cx).snapshot(cx),
buffer_edits.consume().into_inner(),
);
let (suggestion_snapshot, _) = suggestion_map.sync(fold_snapshot.clone(), fold_edits);
assert_eq!(suggestion_snapshot.text(), "ABCabcDEF123\n456dGHIefghiJKL");
let (mut fold_map_writer, _, _) =
fold_map.write(buffer.read(cx).snapshot(cx), Default::default());
let (fold_snapshot, fold_edits) = fold_map_writer.fold([0..3]);
let (suggestion_snapshot, _) = suggestion_map.sync(fold_snapshot, fold_edits);
assert_eq!(suggestion_snapshot.text(), "⋯abcDEF123\n456dGHIefghiJKL");
let (mut fold_map_writer, _, _) =
fold_map.write(buffer.read(cx).snapshot(cx), Default::default());
let (fold_snapshot, fold_edits) = fold_map_writer.fold([6..10]);
let (suggestion_snapshot, _) = suggestion_map.sync(fold_snapshot, fold_edits);
assert_eq!(suggestion_snapshot.text(), "⋯abc⋯GHIefghiJKL");
}
#[gpui::test(iterations = 100)]
fn test_random_suggestions(cx: &mut MutableAppContext, mut rng: StdRng) {
cx.set_global(Settings::test(cx));
let operations = env::var("OPERATIONS")
.map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
.unwrap_or(10);
let len = rng.gen_range(0..30);
let buffer = if rng.gen() {
let text = util::RandomCharIter::new(&mut rng)
.take(len)
.collect::<String>();
MultiBuffer::build_simple(&text, cx)
} else {
MultiBuffer::build_random(&mut rng, cx)
};
let mut buffer_snapshot = buffer.read(cx).snapshot(cx);
log::info!("buffer text: {:?}", buffer_snapshot.text());
let (mut fold_map, mut fold_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (suggestion_map, mut suggestion_snapshot) = SuggestionMap::new(fold_snapshot.clone());
for _ in 0..operations {
let mut suggestion_edits = Patch::default();
let mut prev_suggestion_text = suggestion_snapshot.text();
let mut buffer_edits = Vec::new();
match rng.gen_range(0..=100) {
0..=29 => {
let (_, edits) = suggestion_map.randomly_mutate(&mut rng);
suggestion_edits = suggestion_edits.compose(edits);
}
30..=59 => {
for (new_fold_snapshot, fold_edits) in fold_map.randomly_mutate(&mut rng) {
fold_snapshot = new_fold_snapshot;
let (_, edits) = suggestion_map.sync(fold_snapshot.clone(), fold_edits);
suggestion_edits = suggestion_edits.compose(edits);
}
}
_ => buffer.update(cx, |buffer, cx| {
let subscription = buffer.subscribe();
let edit_count = rng.gen_range(1..=5);
buffer.randomly_mutate(&mut rng, edit_count, cx);
buffer_snapshot = buffer.snapshot(cx);
let edits = subscription.consume().into_inner();
log::info!("editing {:?}", edits);
buffer_edits.extend(edits);
}),
};
let (new_fold_snapshot, fold_edits) =
fold_map.read(buffer_snapshot.clone(), buffer_edits);
fold_snapshot = new_fold_snapshot;
let (new_suggestion_snapshot, edits) =
suggestion_map.sync(fold_snapshot.clone(), fold_edits);
suggestion_snapshot = new_suggestion_snapshot;
suggestion_edits = suggestion_edits.compose(edits);
log::info!("buffer text: {:?}", buffer_snapshot.text());
log::info!("folds text: {:?}", fold_snapshot.text());
log::info!("suggestions text: {:?}", suggestion_snapshot.text());
let mut expected_text = Rope::from(fold_snapshot.text().as_str());
let mut expected_buffer_rows = fold_snapshot.buffer_rows(0).collect::<Vec<_>>();
if let Some(suggestion) = suggestion_snapshot.suggestion.as_ref() {
expected_text.replace(
suggestion.position.0..suggestion.position.0,
&suggestion.text.to_string(),
);
let suggestion_start = suggestion.position.to_point(&fold_snapshot).0;
let suggestion_end = suggestion_start + suggestion.text.max_point();
expected_buffer_rows.splice(
(suggestion_start.row + 1) as usize..(suggestion_start.row + 1) as usize,
(0..suggestion_end.row - suggestion_start.row).map(|_| None),
);
}
assert_eq!(suggestion_snapshot.text(), expected_text.to_string());
for row_start in 0..expected_buffer_rows.len() {
assert_eq!(
suggestion_snapshot
.buffer_rows(row_start as u32)
.collect::<Vec<_>>(),
&expected_buffer_rows[row_start..],
"incorrect buffer rows starting at {}",
row_start
);
}
for _ in 0..5 {
let mut end = rng.gen_range(0..=suggestion_snapshot.len().0);
end = expected_text.clip_offset(end, Bias::Right);
let mut start = rng.gen_range(0..=end);
start = expected_text.clip_offset(start, Bias::Right);
let actual_text = suggestion_snapshot
.chunks(
SuggestionOffset(start)..SuggestionOffset(end),
false,
None,
None,
)
.map(|chunk| chunk.text)
.collect::<String>();
assert_eq!(
actual_text,
expected_text.slice(start..end).to_string(),
"incorrect text in range {:?}",
start..end
);
let start_point = SuggestionPoint(expected_text.offset_to_point(start));
let end_point = SuggestionPoint(expected_text.offset_to_point(end));
assert_eq!(
suggestion_snapshot.text_summary_for_range(start_point..end_point),
expected_text.slice(start..end).summary()
);
}
for edit in suggestion_edits.into_inner() {
prev_suggestion_text.replace_range(
edit.new.start.0..edit.new.start.0 + edit.old_len().0,
&suggestion_snapshot.text()[edit.new.start.0..edit.new.end.0],
);
}
assert_eq!(prev_suggestion_text, suggestion_snapshot.text());
assert_eq!(expected_text.max_point(), suggestion_snapshot.max_point().0);
assert_eq!(expected_text.len(), suggestion_snapshot.len().0);
let mut suggestion_point = SuggestionPoint::default();
let mut suggestion_offset = SuggestionOffset::default();
for ch in expected_text.chars() {
assert_eq!(
suggestion_snapshot.to_offset(suggestion_point),
suggestion_offset,
"invalid to_offset({:?})",
suggestion_point
);
assert_eq!(
suggestion_snapshot.to_point(suggestion_offset),
suggestion_point,
"invalid to_point({:?})",
suggestion_offset
);
assert_eq!(
suggestion_snapshot
.to_suggestion_point(suggestion_snapshot.to_fold_point(suggestion_point)),
suggestion_snapshot.clip_point(suggestion_point, Bias::Left),
);
let mut bytes = [0; 4];
for byte in ch.encode_utf8(&mut bytes).as_bytes() {
suggestion_offset.0 += 1;
if *byte == b'\n' {
suggestion_point.0 += Point::new(1, 0);
} else {
suggestion_point.0 += Point::new(0, 1);
}
let clipped_left_point =
suggestion_snapshot.clip_point(suggestion_point, Bias::Left);
let clipped_right_point =
suggestion_snapshot.clip_point(suggestion_point, Bias::Right);
assert!(
clipped_left_point <= clipped_right_point,
"clipped left point {:?} is greater than clipped right point {:?}",
clipped_left_point,
clipped_right_point
);
assert_eq!(
clipped_left_point.0,
expected_text.clip_point(clipped_left_point.0, Bias::Left)
);
assert_eq!(
clipped_right_point.0,
expected_text.clip_point(clipped_right_point.0, Bias::Right)
);
assert!(clipped_left_point <= suggestion_snapshot.max_point());
assert!(clipped_right_point <= suggestion_snapshot.max_point());
if let Some(suggestion) = suggestion_snapshot.suggestion.as_ref() {
let suggestion_start = suggestion.position.to_point(&fold_snapshot).0;
let suggestion_end = suggestion_start + suggestion.text.max_point();
let invalid_range = (
Bound::Excluded(suggestion_start),
Bound::Included(suggestion_end),
);
assert!(
!invalid_range.contains(&clipped_left_point.0),
"clipped left point {:?} is inside invalid suggestion range {:?}",
clipped_left_point,
invalid_range
);
assert!(
!invalid_range.contains(&clipped_right_point.0),
"clipped right point {:?} is inside invalid suggestion range {:?}",
clipped_right_point,
invalid_range
);
}
}
}
}
}
impl SuggestionMap {
pub fn randomly_mutate(
&self,
rng: &mut impl Rng,
) -> (SuggestionSnapshot, Vec<SuggestionEdit>) {
let fold_snapshot = self.0.lock().fold_snapshot.clone();
let new_suggestion = if rng.gen_bool(0.3) {
None
} else {
let index = rng.gen_range(0..=fold_snapshot.buffer_snapshot().len());
let len = rng.gen_range(0..30);
Some(Suggestion {
position: index,
text: util::RandomCharIter::new(rng)
.take(len)
.filter(|ch| *ch != '\r')
.collect::<String>()
.as_str()
.into(),
})
};
log::info!("replacing suggestion with {:?}", new_suggestion);
self.replace(new_suggestion, fold_snapshot, Default::default())
}
}
}

View file

@ -1,86 +1,140 @@
use super::{
fold_map::{self, FoldEdit, FoldPoint, FoldSnapshot},
suggestion_map::{self, SuggestionChunks, SuggestionEdit, SuggestionPoint, SuggestionSnapshot},
TextHighlights,
};
use crate::MultiBufferSnapshot;
use gpui::fonts::HighlightStyle;
use language::{Chunk, Point};
use parking_lot::Mutex;
use std::{cmp, mem, num::NonZeroU32, ops::Range};
use sum_tree::Bias;
const MAX_EXPANSION_COLUMN: u32 = 256;
pub struct TabMap(Mutex<TabSnapshot>);
impl TabMap {
pub fn new(input: FoldSnapshot, tab_size: NonZeroU32) -> (Self, TabSnapshot) {
pub fn new(input: SuggestionSnapshot, tab_size: NonZeroU32) -> (Self, TabSnapshot) {
let snapshot = TabSnapshot {
fold_snapshot: input,
suggestion_snapshot: input,
tab_size,
max_expansion_column: MAX_EXPANSION_COLUMN,
version: 0,
};
(Self(Mutex::new(snapshot.clone())), snapshot)
}
#[cfg(test)]
pub fn set_max_expansion_column(&self, column: u32) -> TabSnapshot {
self.0.lock().max_expansion_column = column;
self.0.lock().clone()
}
pub fn sync(
&self,
fold_snapshot: FoldSnapshot,
mut fold_edits: Vec<FoldEdit>,
suggestion_snapshot: SuggestionSnapshot,
mut suggestion_edits: Vec<SuggestionEdit>,
tab_size: NonZeroU32,
) -> (TabSnapshot, Vec<TabEdit>) {
let mut old_snapshot = self.0.lock();
let mut new_snapshot = TabSnapshot {
fold_snapshot,
suggestion_snapshot,
tab_size,
max_expansion_column: old_snapshot.max_expansion_column,
version: old_snapshot.version,
};
if old_snapshot.fold_snapshot.version != new_snapshot.fold_snapshot.version {
if old_snapshot.suggestion_snapshot.version != new_snapshot.suggestion_snapshot.version {
new_snapshot.version += 1;
}
let old_max_offset = old_snapshot.fold_snapshot.len();
let mut tab_edits = Vec::with_capacity(fold_edits.len());
let mut tab_edits = Vec::with_capacity(suggestion_edits.len());
if old_snapshot.tab_size == new_snapshot.tab_size {
for fold_edit in &mut fold_edits {
let mut delta = 0;
for chunk in old_snapshot.fold_snapshot.chunks(
fold_edit.old.end..old_max_offset,
// Expand each edit to include the next tab on the same line as the edit,
// and any subsequent tabs on that line that moved across the tab expansion
// boundary.
for suggestion_edit in &mut suggestion_edits {
let old_end = old_snapshot
.suggestion_snapshot
.to_point(suggestion_edit.old.end);
let old_end_row_successor_offset =
old_snapshot.suggestion_snapshot.to_offset(cmp::min(
SuggestionPoint::new(old_end.row() + 1, 0),
old_snapshot.suggestion_snapshot.max_point(),
));
let new_end = new_snapshot
.suggestion_snapshot
.to_point(suggestion_edit.new.end);
let mut offset_from_edit = 0;
let mut first_tab_offset = None;
let mut last_tab_with_changed_expansion_offset = None;
'outer: for chunk in old_snapshot.suggestion_snapshot.chunks(
suggestion_edit.old.end..old_end_row_successor_offset,
false,
None,
None,
) {
let patterns: &[_] = &['\t', '\n'];
if let Some(ix) = chunk.text.find(patterns) {
if &chunk.text[ix..ix + 1] == "\t" {
fold_edit.old.end.0 += delta + ix + 1;
fold_edit.new.end.0 += delta + ix + 1;
for (ix, _) in chunk.text.match_indices('\t') {
let offset_from_edit = offset_from_edit + (ix as u32);
if first_tab_offset.is_none() {
first_tab_offset = Some(offset_from_edit);
}
break;
let old_column = old_end.column() + offset_from_edit;
let new_column = new_end.column() + offset_from_edit;
let was_expanded = old_column < old_snapshot.max_expansion_column;
let is_expanded = new_column < new_snapshot.max_expansion_column;
if was_expanded != is_expanded {
last_tab_with_changed_expansion_offset = Some(offset_from_edit);
} else if !was_expanded && !is_expanded {
break 'outer;
}
}
delta += chunk.text.len();
offset_from_edit += chunk.text.len() as u32;
if old_end.column() + offset_from_edit >= old_snapshot.max_expansion_column
&& new_end.column() + offset_from_edit >= new_snapshot.max_expansion_column
{
break;
}
}
if let Some(offset) = last_tab_with_changed_expansion_offset.or(first_tab_offset) {
suggestion_edit.old.end.0 += offset as usize + 1;
suggestion_edit.new.end.0 += offset as usize + 1;
}
}
// Combine any edits that overlap due to the expansion.
let mut ix = 1;
while ix < fold_edits.len() {
let (prev_edits, next_edits) = fold_edits.split_at_mut(ix);
while ix < suggestion_edits.len() {
let (prev_edits, next_edits) = suggestion_edits.split_at_mut(ix);
let prev_edit = prev_edits.last_mut().unwrap();
let edit = &next_edits[0];
if prev_edit.old.end >= edit.old.start {
prev_edit.old.end = edit.old.end;
prev_edit.new.end = edit.new.end;
fold_edits.remove(ix);
suggestion_edits.remove(ix);
} else {
ix += 1;
}
}
for fold_edit in fold_edits {
let old_start = fold_edit.old.start.to_point(&old_snapshot.fold_snapshot);
let old_end = fold_edit.old.end.to_point(&old_snapshot.fold_snapshot);
let new_start = fold_edit.new.start.to_point(&new_snapshot.fold_snapshot);
let new_end = fold_edit.new.end.to_point(&new_snapshot.fold_snapshot);
for suggestion_edit in suggestion_edits {
let old_start = old_snapshot
.suggestion_snapshot
.to_point(suggestion_edit.old.start);
let old_end = old_snapshot
.suggestion_snapshot
.to_point(suggestion_edit.old.end);
let new_start = new_snapshot
.suggestion_snapshot
.to_point(suggestion_edit.new.start);
let new_end = new_snapshot
.suggestion_snapshot
.to_point(suggestion_edit.new.end);
tab_edits.push(TabEdit {
old: old_snapshot.to_tab_point(old_start)..old_snapshot.to_tab_point(old_end),
new: new_snapshot.to_tab_point(new_start)..new_snapshot.to_tab_point(new_end),
@ -101,27 +155,26 @@ impl TabMap {
#[derive(Clone)]
pub struct TabSnapshot {
pub fold_snapshot: FoldSnapshot,
pub suggestion_snapshot: SuggestionSnapshot,
pub tab_size: NonZeroU32,
pub max_expansion_column: u32,
pub version: usize,
}
impl TabSnapshot {
pub fn buffer_snapshot(&self) -> &MultiBufferSnapshot {
self.fold_snapshot.buffer_snapshot()
self.suggestion_snapshot.buffer_snapshot()
}
pub fn line_len(&self, row: u32) -> u32 {
let max_point = self.max_point();
if row < max_point.row() {
self.chunks(
TabPoint::new(row, 0)..TabPoint::new(row + 1, 0),
false,
None,
)
.map(|chunk| chunk.text.len() as u32)
.sum::<u32>()
- 1
self.to_tab_point(SuggestionPoint::new(
row,
self.suggestion_snapshot.line_len(row),
))
.0
.column
} else {
max_point.column()
}
@ -132,10 +185,10 @@ impl TabSnapshot {
}
pub fn text_summary_for_range(&self, range: Range<TabPoint>) -> TextSummary {
let input_start = self.to_fold_point(range.start, Bias::Left).0;
let input_end = self.to_fold_point(range.end, Bias::Right).0;
let input_start = self.to_suggestion_point(range.start, Bias::Left).0;
let input_end = self.to_suggestion_point(range.end, Bias::Right).0;
let input_summary = self
.fold_snapshot
.suggestion_snapshot
.text_summary_for_range(input_start..input_end);
let mut first_line_chars = 0;
@ -145,7 +198,7 @@ impl TabSnapshot {
self.max_point()
};
for c in self
.chunks(range.start..line_end, false, None)
.chunks(range.start..line_end, false, None, None)
.flat_map(|chunk| chunk.text.chars())
{
if c == '\n' {
@ -159,7 +212,12 @@ impl TabSnapshot {
last_line_chars = first_line_chars;
} else {
for _ in self
.chunks(TabPoint::new(range.end.row(), 0)..range.end, false, None)
.chunks(
TabPoint::new(range.end.row(), 0)..range.end,
false,
None,
None,
)
.flat_map(|chunk| chunk.text.chars())
{
last_line_chars += 1;
@ -180,120 +238,133 @@ impl TabSnapshot {
range: Range<TabPoint>,
language_aware: bool,
text_highlights: Option<&'a TextHighlights>,
suggestion_highlight: Option<HighlightStyle>,
) -> TabChunks<'a> {
let (input_start, expanded_char_column, to_next_stop) =
self.to_fold_point(range.start, Bias::Left);
let input_start = input_start.to_offset(&self.fold_snapshot);
self.to_suggestion_point(range.start, Bias::Left);
let input_column = input_start.column();
let input_start = self.suggestion_snapshot.to_offset(input_start);
let input_end = self
.to_fold_point(range.end, Bias::Right)
.0
.to_offset(&self.fold_snapshot);
let to_next_stop = if range.start.0 + Point::new(0, to_next_stop as u32) > range.end.0 {
(range.end.column() - range.start.column()) as usize
.suggestion_snapshot
.to_offset(self.to_suggestion_point(range.end, Bias::Right).0);
let to_next_stop = if range.start.0 + Point::new(0, to_next_stop) > range.end.0 {
range.end.column() - range.start.column()
} else {
to_next_stop
};
TabChunks {
fold_chunks: self.fold_snapshot.chunks(
suggestion_chunks: self.suggestion_snapshot.chunks(
input_start..input_end,
language_aware,
text_highlights,
suggestion_highlight,
),
input_column,
column: expanded_char_column,
max_expansion_column: self.max_expansion_column,
output_position: range.start.0,
max_output_position: range.end.0,
tab_size: self.tab_size,
chunk: Chunk {
text: &SPACES[0..to_next_stop],
text: &SPACES[0..(to_next_stop as usize)],
..Default::default()
},
skip_leading_tab: to_next_stop > 0,
inside_leading_tab: to_next_stop > 0,
}
}
pub fn buffer_rows(&self, row: u32) -> fold_map::FoldBufferRows {
self.fold_snapshot.buffer_rows(row)
pub fn buffer_rows(&self, row: u32) -> suggestion_map::SuggestionBufferRows {
self.suggestion_snapshot.buffer_rows(row)
}
#[cfg(test)]
pub fn text(&self) -> String {
self.chunks(TabPoint::zero()..self.max_point(), false, None)
self.chunks(TabPoint::zero()..self.max_point(), false, None, None)
.map(|chunk| chunk.text)
.collect()
}
pub fn max_point(&self) -> TabPoint {
self.to_tab_point(self.fold_snapshot.max_point())
self.to_tab_point(self.suggestion_snapshot.max_point())
}
pub fn clip_point(&self, point: TabPoint, bias: Bias) -> TabPoint {
self.to_tab_point(
self.fold_snapshot
.clip_point(self.to_fold_point(point, bias).0, bias),
self.suggestion_snapshot
.clip_point(self.to_suggestion_point(point, bias).0, bias),
)
}
pub fn to_tab_point(&self, input: FoldPoint) -> TabPoint {
let chars = self.fold_snapshot.chars_at(FoldPoint::new(input.row(), 0));
let expanded = Self::expand_tabs(chars, input.column() as usize, self.tab_size);
TabPoint::new(input.row(), expanded as u32)
pub fn to_tab_point(&self, input: SuggestionPoint) -> TabPoint {
let chars = self
.suggestion_snapshot
.chars_at(SuggestionPoint::new(input.row(), 0));
let expanded = self.expand_tabs(chars, input.column());
TabPoint::new(input.row(), expanded)
}
pub fn to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, usize, usize) {
let chars = self.fold_snapshot.chars_at(FoldPoint::new(output.row(), 0));
let expanded = output.column() as usize;
pub fn to_suggestion_point(&self, output: TabPoint, bias: Bias) -> (SuggestionPoint, u32, u32) {
let chars = self
.suggestion_snapshot
.chars_at(SuggestionPoint::new(output.row(), 0));
let expanded = output.column();
let (collapsed, expanded_char_column, to_next_stop) =
Self::collapse_tabs(chars, expanded, bias, self.tab_size);
self.collapse_tabs(chars, expanded, bias);
(
FoldPoint::new(output.row(), collapsed as u32),
SuggestionPoint::new(output.row(), collapsed as u32),
expanded_char_column,
to_next_stop,
)
}
pub fn make_tab_point(&self, point: Point, bias: Bias) -> TabPoint {
self.to_tab_point(self.fold_snapshot.to_fold_point(point, bias))
let fold_point = self
.suggestion_snapshot
.fold_snapshot
.to_fold_point(point, bias);
let suggestion_point = self.suggestion_snapshot.to_suggestion_point(fold_point);
self.to_tab_point(suggestion_point)
}
pub fn to_point(&self, point: TabPoint, bias: Bias) -> Point {
self.to_fold_point(point, bias)
.0
.to_buffer_point(&self.fold_snapshot)
let suggestion_point = self.to_suggestion_point(point, bias).0;
let fold_point = self.suggestion_snapshot.to_fold_point(suggestion_point);
fold_point.to_buffer_point(&self.suggestion_snapshot.fold_snapshot)
}
fn expand_tabs(
chars: impl Iterator<Item = char>,
column: usize,
tab_size: NonZeroU32,
) -> usize {
fn expand_tabs(&self, chars: impl Iterator<Item = char>, column: u32) -> u32 {
let tab_size = self.tab_size.get();
let mut expanded_chars = 0;
let mut expanded_bytes = 0;
let mut collapsed_bytes = 0;
let end_column = column.min(self.max_expansion_column);
for c in chars {
if collapsed_bytes == column {
if collapsed_bytes >= end_column {
break;
}
if c == '\t' {
let tab_size = tab_size.get() as usize;
let tab_len = tab_size - expanded_chars % tab_size;
expanded_bytes += tab_len;
expanded_chars += tab_len;
} else {
expanded_bytes += c.len_utf8();
expanded_bytes += c.len_utf8() as u32;
expanded_chars += 1;
}
collapsed_bytes += c.len_utf8();
collapsed_bytes += c.len_utf8() as u32;
}
expanded_bytes
expanded_bytes + column.saturating_sub(collapsed_bytes)
}
fn collapse_tabs(
&self,
chars: impl Iterator<Item = char>,
column: usize,
column: u32,
bias: Bias,
tab_size: NonZeroU32,
) -> (usize, usize, usize) {
) -> (u32, u32, u32) {
let tab_size = self.tab_size.get();
let mut expanded_bytes = 0;
let mut expanded_chars = 0;
let mut collapsed_bytes = 0;
@ -301,9 +372,11 @@ impl TabSnapshot {
if expanded_bytes >= column {
break;
}
if collapsed_bytes >= self.max_expansion_column {
break;
}
if c == '\t' {
let tab_size = tab_size.get() as usize;
let tab_len = tab_size - (expanded_chars % tab_size);
expanded_chars += tab_len;
expanded_bytes += tab_len;
@ -316,7 +389,7 @@ impl TabSnapshot {
}
} else {
expanded_chars += 1;
expanded_bytes += c.len_utf8();
expanded_bytes += c.len_utf8() as u32;
}
if expanded_bytes > column && matches!(bias, Bias::Left) {
@ -324,9 +397,13 @@ impl TabSnapshot {
break;
}
collapsed_bytes += c.len_utf8();
collapsed_bytes += c.len_utf8() as u32;
}
(collapsed_bytes, expanded_chars, 0)
(
collapsed_bytes + column.saturating_sub(expanded_bytes),
expanded_chars,
0,
)
}
}
@ -412,13 +489,15 @@ impl<'a> std::ops::AddAssign<&'a Self> for TextSummary {
const SPACES: &str = " ";
pub struct TabChunks<'a> {
fold_chunks: fold_map::FoldChunks<'a>,
suggestion_chunks: SuggestionChunks<'a>,
chunk: Chunk<'a>,
column: usize,
column: u32,
max_expansion_column: u32,
output_position: Point,
input_column: u32,
max_output_position: Point,
tab_size: NonZeroU32,
skip_leading_tab: bool,
inside_leading_tab: bool,
}
impl<'a> Iterator for TabChunks<'a> {
@ -426,11 +505,12 @@ impl<'a> Iterator for TabChunks<'a> {
fn next(&mut self) -> Option<Self::Item> {
if self.chunk.text.is_empty() {
if let Some(chunk) = self.fold_chunks.next() {
if let Some(chunk) = self.suggestion_chunks.next() {
self.chunk = chunk;
if self.skip_leading_tab {
if self.inside_leading_tab {
self.chunk.text = &self.chunk.text[1..];
self.skip_leading_tab = false;
self.inside_leading_tab = false;
self.input_column += 1;
}
} else {
return None;
@ -449,27 +529,36 @@ impl<'a> Iterator for TabChunks<'a> {
});
} else {
self.chunk.text = &self.chunk.text[1..];
let tab_size = self.tab_size.get() as u32;
let mut len = tab_size - self.column as u32 % tab_size;
let tab_size = if self.input_column < self.max_expansion_column {
self.tab_size.get() as u32
} else {
1
};
let mut len = tab_size - self.column % tab_size;
let next_output_position = cmp::min(
self.output_position + Point::new(0, len),
self.max_output_position,
);
len = next_output_position.column - self.output_position.column;
self.column += len as usize;
self.column += len;
self.input_column += 1;
self.output_position = next_output_position;
return Some(Chunk {
text: &SPACES[0..len as usize],
text: &SPACES[..len as usize],
..self.chunk
});
}
}
'\n' => {
self.column = 0;
self.input_column = 0;
self.output_position += Point::new(1, 0);
}
_ => {
self.column += 1;
if !self.inside_leading_tab {
self.input_column += c.len_utf8() as u32;
}
self.output_position.column += c.len_utf8() as u32;
}
}
@ -482,23 +571,89 @@ impl<'a> Iterator for TabChunks<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{display_map::fold_map::FoldMap, MultiBuffer};
use crate::{
display_map::{fold_map::FoldMap, suggestion_map::SuggestionMap},
MultiBuffer,
};
use rand::{prelude::StdRng, Rng};
#[test]
fn test_expand_tabs() {
assert_eq!(
TabSnapshot::expand_tabs("\t".chars(), 0, 4.try_into().unwrap()),
0
);
assert_eq!(
TabSnapshot::expand_tabs("\t".chars(), 1, 4.try_into().unwrap()),
4
);
assert_eq!(
TabSnapshot::expand_tabs("\ta".chars(), 2, 4.try_into().unwrap()),
5
);
#[gpui::test]
fn test_expand_tabs(cx: &mut gpui::MutableAppContext) {
let buffer = MultiBuffer::build_simple("", cx);
let buffer_snapshot = buffer.read(cx).snapshot(cx);
let (_, fold_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (_, suggestion_snapshot) = SuggestionMap::new(fold_snapshot);
let (_, tab_snapshot) = TabMap::new(suggestion_snapshot, 4.try_into().unwrap());
assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 0), 0);
assert_eq!(tab_snapshot.expand_tabs("\t".chars(), 1), 4);
assert_eq!(tab_snapshot.expand_tabs("\ta".chars(), 2), 5);
}
#[gpui::test]
fn test_long_lines(cx: &mut gpui::MutableAppContext) {
let max_expansion_column = 12;
let input = "A\tBC\tDEF\tG\tHI\tJ\tK\tL\tM";
let output = "A BC DEF G HI J K L M";
let buffer = MultiBuffer::build_simple(input, cx);
let buffer_snapshot = buffer.read(cx).snapshot(cx);
let (_, fold_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (_, suggestion_snapshot) = SuggestionMap::new(fold_snapshot);
let (_, mut tab_snapshot) = TabMap::new(suggestion_snapshot, 4.try_into().unwrap());
tab_snapshot.max_expansion_column = max_expansion_column;
assert_eq!(tab_snapshot.text(), output);
for (ix, c) in input.char_indices() {
assert_eq!(
tab_snapshot
.chunks(
TabPoint::new(0, ix as u32)..tab_snapshot.max_point(),
false,
None,
None,
)
.map(|c| c.text)
.collect::<String>(),
&output[ix..],
"text from index {ix}"
);
if c != '\t' {
let input_point = Point::new(0, ix as u32);
let output_point = Point::new(0, output.find(c).unwrap() as u32);
assert_eq!(
tab_snapshot.to_tab_point(SuggestionPoint(input_point)),
TabPoint(output_point),
"to_tab_point({input_point:?})"
);
assert_eq!(
tab_snapshot
.to_suggestion_point(TabPoint(output_point), Bias::Left)
.0,
SuggestionPoint(input_point),
"to_suggestion_point({output_point:?})"
);
}
}
}
#[gpui::test]
fn test_long_lines_with_character_spanning_max_expansion_column(
cx: &mut gpui::MutableAppContext,
) {
let max_expansion_column = 8;
let input = "abcdefg⋯hij";
let buffer = MultiBuffer::build_simple(input, cx);
let buffer_snapshot = buffer.read(cx).snapshot(cx);
let (_, fold_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (_, suggestion_snapshot) = SuggestionMap::new(fold_snapshot);
let (_, mut tab_snapshot) = TabMap::new(suggestion_snapshot, 4.try_into().unwrap());
tab_snapshot.max_expansion_column = max_expansion_column;
assert_eq!(tab_snapshot.text(), input);
}
#[gpui::test(iterations = 100)]
@ -518,10 +673,15 @@ mod tests {
let (mut fold_map, _) = FoldMap::new(buffer_snapshot.clone());
fold_map.randomly_mutate(&mut rng);
let (folds_snapshot, _) = fold_map.read(buffer_snapshot, vec![]);
log::info!("FoldMap text: {:?}", folds_snapshot.text());
let (fold_snapshot, _) = fold_map.read(buffer_snapshot, vec![]);
log::info!("FoldMap text: {:?}", fold_snapshot.text());
let (suggestion_map, _) = SuggestionMap::new(fold_snapshot);
let (suggestion_snapshot, _) = suggestion_map.randomly_mutate(&mut rng);
log::info!("SuggestionMap text: {:?}", suggestion_snapshot.text());
let (tab_map, _) = TabMap::new(suggestion_snapshot.clone(), tab_size);
let tabs_snapshot = tab_map.set_max_expansion_column(32);
let (_, tabs_snapshot) = TabMap::new(folds_snapshot.clone(), tab_size);
let text = text::Rope::from(tabs_snapshot.text().as_str());
log::info!(
"TabMap text (tab size: {}): {:?}",
@ -546,18 +706,18 @@ mod tests {
.collect::<String>();
let expected_summary = TextSummary::from(expected_text.as_str());
assert_eq!(
expected_text,
tabs_snapshot
.chunks(start..end, false, None)
.chunks(start..end, false, None, None)
.map(|c| c.text)
.collect::<String>(),
expected_text,
"chunks({:?}..{:?})",
start,
end
);
let mut actual_summary = tabs_snapshot.text_summary_for_range(start..end);
if tab_size.get() > 1 && folds_snapshot.text().contains('\t') {
if tab_size.get() > 1 && suggestion_snapshot.text().contains('\t') {
actual_summary.longest_row = expected_summary.longest_row;
actual_summary.longest_row_chars = expected_summary.longest_row_chars;
}
@ -565,7 +725,11 @@ mod tests {
}
for row in 0..=text.max_point().row {
assert_eq!(tabs_snapshot.line_len(row), text.line_len(row));
assert_eq!(
tabs_snapshot.line_len(row),
text.line_len(row),
"line_len({row})"
);
}
}
}

View file

@ -1,12 +1,13 @@
use super::{
fold_map,
suggestion_map::SuggestionBufferRows,
tab_map::{self, TabEdit, TabPoint, TabSnapshot},
TextHighlights,
};
use crate::MultiBufferSnapshot;
use gpui::{
fonts::FontId, text_layout::LineWrapper, Entity, ModelContext, ModelHandle, MutableAppContext,
Task,
fonts::{FontId, HighlightStyle},
text_layout::LineWrapper,
Entity, ModelContext, ModelHandle, MutableAppContext, Task,
};
use language::{Chunk, Point};
use lazy_static::lazy_static;
@ -64,7 +65,7 @@ pub struct WrapChunks<'a> {
#[derive(Clone)]
pub struct WrapBufferRows<'a> {
input_buffer_rows: fold_map::FoldBufferRows<'a>,
input_buffer_rows: SuggestionBufferRows<'a>,
input_buffer_row: Option<u32>,
output_row: u32,
soft_wrapped: bool,
@ -444,6 +445,7 @@ impl WrapSnapshot {
TabPoint::new(edit.new_rows.start, 0)..new_tab_snapshot.max_point(),
false,
None,
None,
);
let mut edit_transforms = Vec::<Transform>::new();
for _ in edit.new_rows.start..edit.new_rows.end {
@ -573,6 +575,7 @@ impl WrapSnapshot {
rows: Range<u32>,
language_aware: bool,
text_highlights: Option<&'a TextHighlights>,
suggestion_highlight: Option<HighlightStyle>,
) -> WrapChunks<'a> {
let output_start = WrapPoint::new(rows.start, 0);
let output_end = WrapPoint::new(rows.end, 0);
@ -590,6 +593,7 @@ impl WrapSnapshot {
input_start..input_end,
language_aware,
text_highlights,
suggestion_highlight,
),
input_chunk: Default::default(),
output_position: output_start,
@ -755,16 +759,24 @@ impl WrapSnapshot {
let text = language::Rope::from(self.text().as_str());
let input_buffer_rows = self.buffer_snapshot().buffer_rows(0).collect::<Vec<_>>();
let mut expected_buffer_rows = Vec::new();
let mut prev_tab_row = 0;
let mut prev_fold_row = 0;
for display_row in 0..=self.max_point().row() {
let tab_point = self.to_tab_point(WrapPoint::new(display_row, 0));
if tab_point.row() == prev_tab_row && display_row != 0 {
let suggestion_point = self
.tab_snapshot
.to_suggestion_point(tab_point, Bias::Left)
.0;
let fold_point = self
.tab_snapshot
.suggestion_snapshot
.to_fold_point(suggestion_point);
if fold_point.row() == prev_fold_row && display_row != 0 {
expected_buffer_rows.push(None);
} else {
let fold_point = self.tab_snapshot.to_fold_point(tab_point, Bias::Left).0;
let buffer_point = fold_point.to_buffer_point(&self.tab_snapshot.fold_snapshot);
let buffer_point = fold_point
.to_buffer_point(&self.tab_snapshot.suggestion_snapshot.fold_snapshot);
expected_buffer_rows.push(input_buffer_rows[buffer_point.row as usize]);
prev_tab_row = tab_point.row();
prev_fold_row = fold_point.row();
}
assert_eq!(self.line_len(display_row), text.line_len(display_row));
@ -1026,7 +1038,7 @@ fn consolidate_wrap_edits(edits: &mut Vec<WrapEdit>) {
mod tests {
use super::*;
use crate::{
display_map::{fold_map::FoldMap, tab_map::TabMap},
display_map::{fold_map::FoldMap, suggestion_map::SuggestionMap, tab_map::TabMap},
MultiBuffer,
};
use gpui::test::observe;
@ -1053,7 +1065,9 @@ mod tests {
Some(rng.gen_range(0.0..=1000.0))
};
let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap();
let family_id = font_cache.load_family(&["Helvetica"]).unwrap();
let family_id = font_cache
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = font_cache
.select_font(family_id, &Default::default())
.unwrap();
@ -1074,14 +1088,14 @@ mod tests {
}
});
let mut buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
let (mut fold_map, folds_snapshot) = FoldMap::new(buffer_snapshot.clone());
let (tab_map, tabs_snapshot) = TabMap::new(folds_snapshot.clone(), tab_size);
log::info!("Unwrapped text (no folds): {:?}", buffer_snapshot.text());
log::info!(
"Unwrapped text (unexpanded tabs): {:?}",
folds_snapshot.text()
);
log::info!("Unwrapped text (expanded tabs): {:?}", tabs_snapshot.text());
log::info!("Buffer text: {:?}", buffer_snapshot.text());
let (mut fold_map, fold_snapshot) = FoldMap::new(buffer_snapshot.clone());
log::info!("FoldMap text: {:?}", fold_snapshot.text());
let (suggestion_map, suggestion_snapshot) = SuggestionMap::new(fold_snapshot.clone());
log::info!("SuggestionMap text: {:?}", suggestion_snapshot.text());
let (tab_map, _) = TabMap::new(suggestion_snapshot.clone(), tab_size);
let tabs_snapshot = tab_map.set_max_expansion_column(32);
log::info!("TabMap text: {:?}", tabs_snapshot.text());
let mut line_wrapper = LineWrapper::new(font_id, font_size, font_system);
let unwrapped_text = tabs_snapshot.text();
@ -1124,9 +1138,11 @@ mod tests {
wrap_map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx));
}
20..=39 => {
for (folds_snapshot, fold_edits) in fold_map.randomly_mutate(&mut rng) {
for (fold_snapshot, fold_edits) in fold_map.randomly_mutate(&mut rng) {
let (suggestion_snapshot, suggestion_edits) =
suggestion_map.sync(fold_snapshot, fold_edits);
let (tabs_snapshot, tab_edits) =
tab_map.sync(folds_snapshot, fold_edits, tab_size);
tab_map.sync(suggestion_snapshot, suggestion_edits, tab_size);
let (mut snapshot, wrap_edits) =
wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, tab_edits, cx));
snapshot.check_invariants();
@ -1134,6 +1150,17 @@ mod tests {
edits.push((snapshot, wrap_edits));
}
}
40..=59 => {
let (suggestion_snapshot, suggestion_edits) =
suggestion_map.randomly_mutate(&mut rng);
let (tabs_snapshot, tab_edits) =
tab_map.sync(suggestion_snapshot, suggestion_edits, tab_size);
let (mut snapshot, wrap_edits) =
wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, tab_edits, cx));
snapshot.check_invariants();
snapshot.verify_chunks(&mut rng);
edits.push((snapshot, wrap_edits));
}
_ => {
buffer.update(cx, |buffer, cx| {
let subscription = buffer.subscribe();
@ -1145,14 +1172,15 @@ mod tests {
}
}
log::info!("Unwrapped text (no folds): {:?}", buffer_snapshot.text());
let (folds_snapshot, fold_edits) = fold_map.read(buffer_snapshot.clone(), buffer_edits);
log::info!(
"Unwrapped text (unexpanded tabs): {:?}",
folds_snapshot.text()
);
let (tabs_snapshot, tab_edits) = tab_map.sync(folds_snapshot, fold_edits, tab_size);
log::info!("Unwrapped text (expanded tabs): {:?}", tabs_snapshot.text());
log::info!("Buffer text: {:?}", buffer_snapshot.text());
let (fold_snapshot, fold_edits) = fold_map.read(buffer_snapshot.clone(), buffer_edits);
log::info!("FoldMap text: {:?}", fold_snapshot.text());
let (suggestion_snapshot, suggestion_edits) =
suggestion_map.sync(fold_snapshot, fold_edits);
log::info!("SuggestionMap text: {:?}", suggestion_snapshot.text());
let (tabs_snapshot, tab_edits) =
tab_map.sync(suggestion_snapshot, suggestion_edits, tab_size);
log::info!("TabMap text: {:?}", tabs_snapshot.text());
let unwrapped_text = tabs_snapshot.text();
let expected_text = wrap_text(&unwrapped_text, wrap_width, &mut line_wrapper);
@ -1199,7 +1227,7 @@ mod tests {
if tab_size.get() == 1
|| !wrapped_snapshot
.tab_snapshot
.fold_snapshot
.suggestion_snapshot
.text()
.contains('\t')
{
@ -1292,7 +1320,7 @@ mod tests {
}
pub fn text_chunks(&self, wrap_row: u32) -> impl Iterator<Item = &str> {
self.chunks(wrap_row..self.max_point().row() + 1, false, None)
self.chunks(wrap_row..self.max_point().row() + 1, false, None, None)
.map(|h| h.text)
}
@ -1316,7 +1344,7 @@ mod tests {
}
let actual_text = self
.chunks(start_row..end_row, true, None)
.chunks(start_row..end_row, true, None, None)
.map(|c| c.text)
.collect::<String>();
assert_eq!(

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,23 @@
use drag_and_drop::DragAndDrop;
use futures::StreamExt;
use indoc::indoc;
use std::{cell::RefCell, rc::Rc, time::Instant};
use unindent::Unindent;
use super::*;
use crate::test::{
assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext,
editor_test_context::EditorTestContext, select_ranges,
};
use drag_and_drop::DragAndDrop;
use futures::StreamExt;
use gpui::{
executor::Deterministic,
geometry::{rect::RectF, vector::vec2f},
platform::{WindowBounds, WindowOptions},
serde_json,
};
use language::{FakeLspAdapter, LanguageConfig, LanguageRegistry, Point};
use indoc::indoc;
use language::{BracketPairConfig, FakeLspAdapter, LanguageConfig, LanguageRegistry, Point};
use parking_lot::Mutex;
use project::FakeFs;
use settings::EditorSettings;
use std::{cell::RefCell, rc::Rc, time::Instant};
use unindent::Unindent;
use util::{
assert_set_eq,
test::{marked_text_ranges, marked_text_ranges_by, sample_text, TextRangeMarker},
@ -446,6 +447,7 @@ fn test_clone(cx: &mut gpui::MutableAppContext) {
Point::new(1, 0)..Point::new(2, 0),
Point::new(3, 0)..Point::new(4, 0),
],
true,
cx,
);
});
@ -482,7 +484,7 @@ fn test_navigation_history(cx: &mut gpui::MutableAppContext) {
cx.set_global(Settings::test(cx));
cx.set_global(DragAndDrop::<Workspace>::default());
use workspace::item::Item;
let (_, pane) = cx.add_window(Default::default(), |cx| Pane::new(None, cx));
let (_, pane) = cx.add_window(Default::default(), |cx| Pane::new(0, None, || &[], cx));
let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx);
cx.add_view(&pane, |cx| {
@ -628,7 +630,7 @@ fn test_cancel(cx: &mut gpui::MutableAppContext) {
}
#[gpui::test]
fn test_fold(cx: &mut gpui::MutableAppContext) {
fn test_fold_action(cx: &mut gpui::MutableAppContext) {
cx.set_global(Settings::test(cx));
let buffer = MultiBuffer::build_simple(
&"
@ -668,10 +670,10 @@ fn test_fold(cx: &mut gpui::MutableAppContext) {
1
}
fn b() {
fn b() {
}
fn c() {
fn c() {
}
}
"
@ -682,7 +684,7 @@ fn test_fold(cx: &mut gpui::MutableAppContext) {
assert_eq!(
view.display_text(cx),
"
impl Foo {
impl Foo {
}
"
.unindent(),
@ -699,10 +701,10 @@ fn test_fold(cx: &mut gpui::MutableAppContext) {
1
}
fn b() {
fn b() {
}
fn c() {
fn c() {
}
}
"
@ -806,9 +808,10 @@ fn test_move_cursor_multibyte(cx: &mut gpui::MutableAppContext) {
Point::new(1, 2)..Point::new(1, 4),
Point::new(2, 4)..Point::new(2, 8),
],
true,
cx,
);
assert_eq!(view.display_text(cx), "ⓐⓑ…ⓔ\nab…e\nαβ…ε\n");
assert_eq!(view.display_text(cx), "ⓐⓑ⋯ⓔ\nab⋯e\nαβ⋯ε\n");
view.move_right(&MoveRight, cx);
assert_eq!(
@ -823,13 +826,13 @@ fn test_move_cursor_multibyte(cx: &mut gpui::MutableAppContext) {
view.move_right(&MoveRight, cx);
assert_eq!(
view.selections.display_ranges(cx),
&[empty_range(0, "ⓐⓑ".len())]
&[empty_range(0, "ⓐⓑ".len())]
);
view.move_down(&MoveDown, cx);
assert_eq!(
view.selections.display_ranges(cx),
&[empty_range(1, "ab".len())]
&[empty_range(1, "ab".len())]
);
view.move_left(&MoveLeft, cx);
assert_eq!(
@ -855,28 +858,28 @@ fn test_move_cursor_multibyte(cx: &mut gpui::MutableAppContext) {
view.move_right(&MoveRight, cx);
assert_eq!(
view.selections.display_ranges(cx),
&[empty_range(2, "αβ".len())]
&[empty_range(2, "αβ".len())]
);
view.move_right(&MoveRight, cx);
assert_eq!(
view.selections.display_ranges(cx),
&[empty_range(2, "αβε".len())]
&[empty_range(2, "αβε".len())]
);
view.move_up(&MoveUp, cx);
assert_eq!(
view.selections.display_ranges(cx),
&[empty_range(1, "abe".len())]
&[empty_range(1, "abe".len())]
);
view.move_up(&MoveUp, cx);
assert_eq!(
view.selections.display_ranges(cx),
&[empty_range(0, "ⓐⓑ".len())]
&[empty_range(0, "ⓐⓑ".len())]
);
view.move_left(&MoveLeft, cx);
assert_eq!(
view.selections.display_ranges(cx),
&[empty_range(0, "ⓐⓑ".len())]
&[empty_range(0, "ⓐⓑ".len())]
);
view.move_left(&MoveLeft, cx);
assert_eq!(
@ -2118,6 +2121,7 @@ fn test_move_line_up_down(cx: &mut gpui::MutableAppContext) {
Point::new(2, 3)..Point::new(4, 1),
Point::new(7, 0)..Point::new(8, 4),
],
true,
cx,
);
view.change_selections(None, cx, |s| {
@ -2130,13 +2134,13 @@ fn test_move_line_up_down(cx: &mut gpui::MutableAppContext) {
});
assert_eq!(
view.display_text(cx),
"aa…bbb\nccc…eeee\nfffff\nggggg\ni\njjjjj"
"aa⋯bbb\nccc⋯eeee\nfffff\nggggg\ni\njjjjj"
);
view.move_line_up(&MoveLineUp, cx);
assert_eq!(
view.display_text(cx),
"aa…bbb\nccc…eeee\nggggg\ni\njjjjj\nfffff"
"aa⋯bbb\nccc⋯eeee\nggggg\ni\njjjjj\nfffff"
);
assert_eq!(
view.selections.display_ranges(cx),
@ -2153,7 +2157,7 @@ fn test_move_line_up_down(cx: &mut gpui::MutableAppContext) {
view.move_line_down(&MoveLineDown, cx);
assert_eq!(
view.display_text(cx),
"ccc…eeee\naa…bbb\nfffff\nggggg\ni\njjjjj"
"ccc⋯eeee\naa⋯bbb\nfffff\nggggg\ni\njjjjj"
);
assert_eq!(
view.selections.display_ranges(cx),
@ -2170,7 +2174,7 @@ fn test_move_line_up_down(cx: &mut gpui::MutableAppContext) {
view.move_line_down(&MoveLineDown, cx);
assert_eq!(
view.display_text(cx),
"ccc…eeee\nfffff\naa…bbb\nggggg\ni\njjjjj"
"ccc⋯eeee\nfffff\naa⋯bbb\nggggg\ni\njjjjj"
);
assert_eq!(
view.selections.display_ranges(cx),
@ -2187,7 +2191,7 @@ fn test_move_line_up_down(cx: &mut gpui::MutableAppContext) {
view.move_line_up(&MoveLineUp, cx);
assert_eq!(
view.display_text(cx),
"ccc…eeee\naa…bbb\nggggg\ni\njjjjj\nfffff"
"ccc⋯eeee\naa⋯bbb\nggggg\ni\njjjjj\nfffff"
);
assert_eq!(
view.selections.display_ranges(cx),
@ -2349,12 +2353,16 @@ async fn test_clipboard(cx: &mut gpui::TestAppContext) {
e.paste(&Paste, cx);
e.handle_input(") ", cx);
});
cx.assert_editor_state(indoc! {"
( one
three
five ) ˇtwo one four three six five ( one
three
five ) ˇ"});
cx.assert_editor_state(
&([
"( one✅ ",
"three ",
"five ) ˇtwo one✅ four three six five ( one✅ ",
"three ",
"five ) ˇ",
]
.join("\n")),
);
// Cut with three selections, one of which is full-line.
cx.set_state(indoc! {"
@ -2585,6 +2593,7 @@ fn test_split_selection_into_lines(cx: &mut gpui::MutableAppContext) {
Point::new(2, 3)..Point::new(4, 1),
Point::new(7, 0)..Point::new(8, 4),
],
true,
cx,
);
view.change_selections(None, cx, |s| {
@ -2595,14 +2604,14 @@ fn test_split_selection_into_lines(cx: &mut gpui::MutableAppContext) {
DisplayPoint::new(4, 4)..DisplayPoint::new(4, 4),
])
});
assert_eq!(view.display_text(cx), "aa…bbb\nccc…eeee\nfffff\nggggg\ni");
assert_eq!(view.display_text(cx), "aa⋯bbb\nccc⋯eeee\nfffff\nggggg\ni");
});
view.update(cx, |view, cx| {
view.split_selection_into_lines(&SplitSelectionIntoLines, cx);
assert_eq!(
view.display_text(cx),
"aaaaa\nbbbbb\nccc…eeee\nfffff\nggggg\ni"
"aaaaa\nbbbbb\nccc⋯eeee\nfffff\nggggg\ni"
);
assert_eq!(
view.selections.display_ranges(cx),
@ -2982,6 +2991,7 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) {
Point::new(0, 21)..Point::new(0, 24),
Point::new(3, 20)..Point::new(3, 22),
],
true,
cx,
);
view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx);
@ -3002,20 +3012,23 @@ async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) {
let language = Arc::new(
Language::new(
LanguageConfig {
brackets: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: false,
newline: true,
},
BracketPair {
start: "(".to_string(),
end: ")".to_string(),
close: false,
newline: true,
},
],
brackets: BracketPairConfig {
pairs: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: false,
newline: true,
},
BracketPair {
start: "(".to_string(),
end: ")".to_string(),
close: false,
newline: true,
},
],
..Default::default()
},
..Default::default()
},
Some(tree_sitter_rust::language()),
@ -3059,38 +3072,41 @@ async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) {
let language = Arc::new(Language::new(
LanguageConfig {
brackets: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "(".to_string(),
end: ")".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "/*".to_string(),
end: " */".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "[".to_string(),
end: "]".to_string(),
close: false,
newline: true,
},
BracketPair {
start: "\"".to_string(),
end: "\"".to_string(),
close: true,
newline: false,
},
],
brackets: BracketPairConfig {
pairs: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "(".to_string(),
end: ")".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "/*".to_string(),
end: " */".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "[".to_string(),
end: "]".to_string(),
close: false,
newline: true,
},
BracketPair {
start: "\"".to_string(),
end: "\"".to_string(),
close: true,
newline: false,
},
],
..Default::default()
},
autoclose_before: "})]".to_string(),
..Default::default()
},
@ -3227,26 +3243,29 @@ async fn test_autoclose_with_embedded_language(cx: &mut gpui::TestAppContext) {
Language::new(
LanguageConfig {
name: "HTML".into(),
brackets: vec![
BracketPair {
start: "<".into(),
end: ">".into(),
close: true,
..Default::default()
},
BracketPair {
start: "{".into(),
end: "}".into(),
close: true,
..Default::default()
},
BracketPair {
start: "(".into(),
end: ")".into(),
close: true,
..Default::default()
},
],
brackets: BracketPairConfig {
pairs: vec![
BracketPair {
start: "<".into(),
end: ">".into(),
close: true,
..Default::default()
},
BracketPair {
start: "{".into(),
end: "}".into(),
close: true,
..Default::default()
},
BracketPair {
start: "(".into(),
end: ")".into(),
close: true,
..Default::default()
},
],
..Default::default()
},
autoclose_before: "})]>".into(),
..Default::default()
},
@ -3265,26 +3284,29 @@ async fn test_autoclose_with_embedded_language(cx: &mut gpui::TestAppContext) {
let javascript_language = Arc::new(Language::new(
LanguageConfig {
name: "JavaScript".into(),
brackets: vec![
BracketPair {
start: "/*".into(),
end: " */".into(),
close: true,
..Default::default()
},
BracketPair {
start: "{".into(),
end: "}".into(),
close: true,
..Default::default()
},
BracketPair {
start: "(".into(),
end: ")".into(),
close: true,
..Default::default()
},
],
brackets: BracketPairConfig {
pairs: vec![
BracketPair {
start: "/*".into(),
end: " */".into(),
close: true,
..Default::default()
},
BracketPair {
start: "{".into(),
end: "}".into(),
close: true,
..Default::default()
},
BracketPair {
start: "(".into(),
end: ")".into(),
close: true,
..Default::default()
},
],
..Default::default()
},
autoclose_before: "})]>".into(),
..Default::default()
},
@ -3447,25 +3469,125 @@ async fn test_autoclose_with_embedded_language(cx: &mut gpui::TestAppContext) {
);
}
#[gpui::test]
async fn test_autoclose_with_overrides(cx: &mut gpui::TestAppContext) {
let mut cx = EditorTestContext::new(cx);
let rust_language = Arc::new(
Language::new(
LanguageConfig {
name: "Rust".into(),
brackets: serde_json::from_value(json!([
{ "start": "{", "end": "}", "close": true, "newline": true },
{ "start": "\"", "end": "\"", "close": true, "newline": false, "not_in": ["string"] },
]))
.unwrap(),
autoclose_before: "})]>".into(),
..Default::default()
},
Some(tree_sitter_rust::language()),
)
.with_override_query("(string_literal) @string")
.unwrap(),
);
let registry = Arc::new(LanguageRegistry::test());
registry.add(rust_language.clone());
cx.update_buffer(|buffer, cx| {
buffer.set_language_registry(registry);
buffer.set_language(Some(rust_language), cx);
});
cx.set_state(
&r#"
let x = ˇ
"#
.unindent(),
);
// Inserting a quotation mark. A closing quotation mark is automatically inserted.
cx.update_editor(|editor, cx| {
editor.handle_input("\"", cx);
});
cx.assert_editor_state(
&r#"
let x = "ˇ"
"#
.unindent(),
);
// Inserting another quotation mark. The cursor moves across the existing
// automatically-inserted quotation mark.
cx.update_editor(|editor, cx| {
editor.handle_input("\"", cx);
});
cx.assert_editor_state(
&r#"
let x = ""ˇ
"#
.unindent(),
);
// Reset
cx.set_state(
&r#"
let x = ˇ
"#
.unindent(),
);
// Inserting a quotation mark inside of a string. A second quotation mark is not inserted.
cx.update_editor(|editor, cx| {
editor.handle_input("\"", cx);
editor.handle_input(" ", cx);
editor.move_left(&Default::default(), cx);
editor.handle_input("\\", cx);
editor.handle_input("\"", cx);
});
cx.assert_editor_state(
&r#"
let x = "\"ˇ "
"#
.unindent(),
);
// Inserting a closing quotation mark at the position of an automatically-inserted quotation
// mark. Nothing is inserted.
cx.update_editor(|editor, cx| {
editor.move_right(&Default::default(), cx);
editor.handle_input("\"", cx);
});
cx.assert_editor_state(
&r#"
let x = "\" "ˇ
"#
.unindent(),
);
}
#[gpui::test]
async fn test_surround_with_pair(cx: &mut gpui::TestAppContext) {
cx.update(|cx| cx.set_global(Settings::test(cx)));
let language = Arc::new(Language::new(
LanguageConfig {
brackets: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "/* ".to_string(),
end: "*/".to_string(),
close: true,
..Default::default()
},
],
brackets: BracketPairConfig {
pairs: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "/* ".to_string(),
end: "*/".to_string(),
close: true,
..Default::default()
},
],
..Default::default()
},
..Default::default()
},
Some(tree_sitter_rust::language()),
@ -3603,12 +3725,15 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
cx.update(|cx| cx.set_global(Settings::test(cx)));
let language = Arc::new(Language::new(
LanguageConfig {
brackets: vec![BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: true,
newline: true,
}],
brackets: BracketPairConfig {
pairs: vec![BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: true,
newline: true,
}],
..Default::default()
},
autoclose_before: "}".to_string(),
..Default::default()
},
@ -4077,7 +4202,9 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx));
editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx));
let format = editor.update(cx, |editor, cx| editor.perform_format(project.clone(), cx));
let format = editor.update(cx, |editor, cx| {
editor.perform_format(project.clone(), FormatTrigger::Manual, cx)
});
fake_server
.handle_request::<lsp::request::Formatting, _, _>(move |params, _| async move {
assert_eq!(
@ -4109,7 +4236,9 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
futures::future::pending::<()>().await;
unreachable!()
});
let format = editor.update(cx, |editor, cx| editor.perform_format(project, cx));
let format = editor.update(cx, |editor, cx| {
editor.perform_format(project, FormatTrigger::Manual, cx)
});
cx.foreground().advance_clock(super::FORMAT_TIMEOUT);
cx.foreground().start_waiting();
format.await.unwrap();
@ -4176,6 +4305,121 @@ async fn test_concurrent_format_requests(cx: &mut gpui::TestAppContext) {
"});
}
#[gpui::test]
async fn test_strip_whitespace_and_format_via_lsp(cx: &mut gpui::TestAppContext) {
cx.foreground().forbid_parking();
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
document_formatting_provider: Some(lsp::OneOf::Left(true)),
..Default::default()
},
cx,
)
.await;
// Set up a buffer white some trailing whitespace and no trailing newline.
cx.set_state(
&[
"one ", //
"twoˇ", //
"three ", //
"four", //
]
.join("\n"),
);
// Submit a format request.
let format = cx
.update_editor(|editor, cx| editor.format(&Format, cx))
.unwrap();
// Record which buffer changes have been sent to the language server
let buffer_changes = Arc::new(Mutex::new(Vec::new()));
cx.lsp
.handle_notification::<lsp::notification::DidChangeTextDocument, _>({
let buffer_changes = buffer_changes.clone();
move |params, _| {
buffer_changes.lock().extend(
params
.content_changes
.into_iter()
.map(|e| (e.range.unwrap(), e.text)),
);
}
});
// Handle formatting requests to the language server.
cx.lsp.handle_request::<lsp::request::Formatting, _, _>({
let buffer_changes = buffer_changes.clone();
move |_, _| {
// When formatting is requested, trailing whitespace has already been stripped,
// and the trailing newline has already been added.
assert_eq!(
&buffer_changes.lock()[1..],
&[
(
lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 4)),
"".into()
),
(
lsp::Range::new(lsp::Position::new(2, 5), lsp::Position::new(2, 6)),
"".into()
),
(
lsp::Range::new(lsp::Position::new(3, 4), lsp::Position::new(3, 4)),
"\n".into()
),
]
);
// Insert blank lines between each line of the buffer.
async move {
Ok(Some(vec![
lsp::TextEdit {
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 0)),
new_text: "\n".into(),
},
lsp::TextEdit {
range: lsp::Range::new(lsp::Position::new(2, 0), lsp::Position::new(2, 0)),
new_text: "\n".into(),
},
]))
}
}
});
// After formatting the buffer, the trailing whitespace is stripped,
// a newline is appended, and the edits provided by the language server
// have been applied.
format.await.unwrap();
cx.assert_editor_state(
&[
"one", //
"", //
"twoˇ", //
"", //
"three", //
"four", //
"", //
]
.join("\n"),
);
// Undoing the formatting undoes the trailing whitespace removal, the
// trailing newline, and the LSP edits.
cx.update_buffer(|buffer, cx| buffer.undo(cx));
cx.assert_editor_state(
&[
"one ", //
"twoˇ", //
"three ", //
"four", //
]
.join("\n"),
);
}
#[gpui::test]
async fn test_completion(cx: &mut gpui::TestAppContext) {
let mut cx = EditorLspTestContext::new_rust(
@ -5030,20 +5274,23 @@ async fn test_extra_newline_insertion(cx: &mut gpui::TestAppContext) {
let language = Arc::new(
Language::new(
LanguageConfig {
brackets: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "/* ".to_string(),
end: " */".to_string(),
close: true,
newline: true,
},
],
brackets: BracketPairConfig {
pairs: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: true,
newline: true,
},
BracketPair {
start: "/* ".to_string(),
end: " */".to_string(),
close: true,
newline: true,
},
],
..Default::default()
},
..Default::default()
},
Some(tree_sitter_rust::language()),
@ -5319,7 +5566,7 @@ async fn test_following_with_multiple_excerpts(cx: &mut gpui::TestAppContext) {
Settings::test_async(cx);
let fs = FakeFs::new(cx.background());
let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
let (_, pane) = cx.add_window(|cx| Pane::new(None, cx));
let (_, pane) = cx.add_window(|cx| Pane::new(0, None, || &[], cx));
let leader = pane.update(cx, |_, cx| {
let multibuffer = cx.add_model(|_| MultiBuffer::new(0));
@ -5588,11 +5835,11 @@ async fn go_to_hunk(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppCon
cx.assert_editor_state(
&r#"
ˇuse some::modified;
fn main() {
println!("hello there");
println!("around the");
println!("world");
}

View file

@ -4,7 +4,7 @@ use super::{
ToPoint, MAX_LINE_LEN,
};
use crate::{
display_map::{BlockStyle, DisplaySnapshot, TransformBlock},
display_map::{BlockStyle, DisplaySnapshot, FoldStatus, TransformBlock},
git::{diff_hunk_to_display, DisplayDiffHunk},
hover_popover::{
HideHover, HoverAt, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
@ -14,7 +14,7 @@ use crate::{
},
mouse_context_menu::DeployMouseContextMenu,
scroll::actions::Scroll,
EditorStyle,
EditorStyle, GutterHover, UnfoldAt,
};
use clock::ReplicaId;
use collections::{BTreeMap, HashMap};
@ -48,6 +48,9 @@ use std::{
ops::{DerefMut, Range},
sync::Arc,
};
use workspace::item::Item;
enum FoldMarkers {}
struct SelectionLayout {
head: DisplayPoint,
@ -212,6 +215,17 @@ impl EditorElement {
}
}),
);
enum GutterHandlers {}
cx.scene.push_mouse_region(
MouseRegion::new::<GutterHandlers>(view.id(), view.id() + 1, gutter_bounds).on_hover(
|hover, cx| {
cx.dispatch_action(GutterHover {
hovered: hover.started,
})
},
),
)
}
fn mouse_down(
@ -400,16 +414,7 @@ impl EditorElement {
) -> bool {
// This will be handled more correctly once https://github.com/zed-industries/zed/issues/1218 is completed
// Don't trigger hover popover if mouse is hovering over context menu
let point = if text_bounds.contains_point(position) {
let (point, target_point) = position_map.point_for_position(text_bounds, position);
if point == target_point {
Some(point)
} else {
None
}
} else {
None
};
let point = position_to_display_point(position, text_bounds, position_map);
cx.dispatch_action(UpdateGoToDefinitionLink {
point,
@ -418,6 +423,7 @@ impl EditorElement {
});
cx.dispatch_action(HoverAt { point });
true
}
@ -568,8 +574,25 @@ impl EditorElement {
}
}
for (ix, fold_indicator) in layout.fold_indicators.iter_mut().enumerate() {
if let Some(indicator) = fold_indicator.as_mut() {
let position = vec2f(
bounds.width() - layout.gutter_padding,
ix as f32 * line_height - (scroll_top % line_height),
);
let centering_offset = vec2f(
(layout.gutter_padding + layout.gutter_margin - indicator.size().x()) / 2.,
(line_height - indicator.size().y()) / 2.,
);
let indicator_origin = bounds.origin() + position + centering_offset;
indicator.paint(indicator_origin, visible_bounds, cx);
}
}
if let Some((row, indicator)) = layout.code_actions_indicator.as_mut() {
let mut x = bounds.width() - layout.gutter_padding;
let mut x = 0.;
let mut y = *row as f32 * line_height - scroll_top;
x += ((layout.gutter_padding + layout.gutter_margin) - indicator.size().x()) / 2.;
y += (line_height - indicator.size().y()) / 2.;
@ -676,6 +699,7 @@ impl EditorElement {
let max_glyph_width = layout.position_map.em_width;
let scroll_left = scroll_position.x() * max_glyph_width;
let content_origin = bounds.origin() + vec2f(layout.gutter_margin, 0.);
let line_end_overshoot = 0.15 * layout.position_map.line_height;
cx.scene.push_layer(Some(bounds));
@ -688,12 +712,59 @@ impl EditorElement {
},
});
let fold_corner_radius =
self.style.folds.ellipses.corner_radius_factor * layout.position_map.line_height;
for (id, range, color) in layout.fold_ranges.iter() {
self.paint_highlighted_range(
range.clone(),
*color,
fold_corner_radius,
fold_corner_radius * 2.,
layout,
content_origin,
scroll_top,
scroll_left,
bounds,
cx,
);
for bound in range_to_bounds(
&range,
content_origin,
scroll_left,
scroll_top,
&layout.visible_display_row_range,
line_end_overshoot,
&layout.position_map,
) {
cx.scene.push_cursor_region(CursorRegion {
bounds: bound,
style: CursorStyle::PointingHand,
});
let display_row = range.start.row();
let buffer_row = DisplayPoint::new(display_row, 0)
.to_point(&layout.position_map.snapshot.display_snapshot)
.row;
cx.scene.push_mouse_region(
MouseRegion::new::<FoldMarkers>(self.view.id(), *id as usize, bound)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_action(UnfoldAt { buffer_row })
})
.with_notify_on_hover(true)
.with_notify_on_click(true),
)
}
}
for (range, color) in &layout.highlighted_ranges {
self.paint_highlighted_range(
range.clone(),
*color,
0.,
0.15 * layout.position_map.line_height,
line_end_overshoot,
layout,
content_origin,
scroll_top,
@ -704,9 +775,10 @@ impl EditorElement {
}
let mut cursors = SmallVec::<[Cursor; 32]>::new();
let corner_radius = 0.15 * layout.position_map.line_height;
for (replica_id, selections) in &layout.selections {
let selection_style = style.replica_selection_style(*replica_id);
let corner_radius = 0.15 * layout.position_map.line_height;
for selection in selections {
self.paint_highlighted_range(
@ -1145,12 +1217,17 @@ impl EditorElement {
&self,
rows: Range<u32>,
active_rows: &BTreeMap<u32, bool>,
is_singleton: bool,
snapshot: &EditorSnapshot,
cx: &LayoutContext,
) -> Vec<Option<text_layout::Line>> {
) -> (
Vec<Option<text_layout::Line>>,
Vec<Option<(FoldStatus, BufferRow, bool)>>,
) {
let style = &self.style;
let include_line_numbers = snapshot.mode == EditorMode::Full;
let mut line_number_layouts = Vec::with_capacity(rows.len());
let mut fold_statuses = Vec::with_capacity(rows.len());
let mut line_number = String::new();
for (ix, row) in snapshot
.buffer_rows(rows.start)
@ -1158,10 +1235,10 @@ impl EditorElement {
.enumerate()
{
let display_row = rows.start + ix as u32;
let color = if active_rows.contains_key(&display_row) {
style.line_number_active
let (active, color) = if active_rows.contains_key(&display_row) {
(true, style.line_number_active)
} else {
style.line_number
(false, style.line_number)
};
if let Some(buffer_row) = row {
if include_line_numbers {
@ -1179,13 +1256,23 @@ impl EditorElement {
},
)],
)));
fold_statuses.push(
is_singleton
.then(|| {
snapshot
.fold_for_line(buffer_row)
.map(|fold_status| (fold_status, buffer_row, active))
})
.flatten(),
)
}
} else {
fold_statuses.push(None);
line_number_layouts.push(None);
}
}
line_number_layouts
(line_number_layouts, fold_statuses)
}
fn layout_lines(
@ -1231,45 +1318,47 @@ impl EditorElement {
.collect()
} else {
let style = &self.style;
let chunks = snapshot.chunks(rows.clone(), true).map(|chunk| {
let mut highlight_style = chunk
.syntax_highlight_id
.and_then(|id| id.style(&style.syntax));
let chunks = snapshot
.chunks(rows.clone(), true, Some(style.theme.suggestion))
.map(|chunk| {
let mut highlight_style = chunk
.syntax_highlight_id
.and_then(|id| id.style(&style.syntax));
if let Some(chunk_highlight) = chunk.highlight_style {
if let Some(highlight_style) = highlight_style.as_mut() {
highlight_style.highlight(chunk_highlight);
} else {
highlight_style = Some(chunk_highlight);
}
}
let mut diagnostic_highlight = HighlightStyle::default();
if chunk.is_unnecessary {
diagnostic_highlight.fade_out = Some(style.unnecessary_code_fade);
}
if let Some(severity) = chunk.diagnostic_severity {
// Omit underlines for HINT/INFO diagnostics on 'unnecessary' code.
if severity <= DiagnosticSeverity::WARNING || !chunk.is_unnecessary {
let diagnostic_style = super::diagnostic_style(severity, true, style);
diagnostic_highlight.underline = Some(Underline {
color: Some(diagnostic_style.message.text.color),
thickness: 1.0.into(),
squiggly: true,
});
}
}
if let Some(chunk_highlight) = chunk.highlight_style {
if let Some(highlight_style) = highlight_style.as_mut() {
highlight_style.highlight(chunk_highlight);
highlight_style.highlight(diagnostic_highlight);
} else {
highlight_style = Some(chunk_highlight);
highlight_style = Some(diagnostic_highlight);
}
}
let mut diagnostic_highlight = HighlightStyle::default();
if chunk.is_unnecessary {
diagnostic_highlight.fade_out = Some(style.unnecessary_code_fade);
}
if let Some(severity) = chunk.diagnostic_severity {
// Omit underlines for HINT/INFO diagnostics on 'unnecessary' code.
if severity <= DiagnosticSeverity::WARNING || !chunk.is_unnecessary {
let diagnostic_style = super::diagnostic_style(severity, true, style);
diagnostic_highlight.underline = Some(Underline {
color: Some(diagnostic_style.message.text.color),
thickness: 1.0.into(),
squiggly: true,
});
}
}
if let Some(highlight_style) = highlight_style.as_mut() {
highlight_style.highlight(diagnostic_highlight);
} else {
highlight_style = Some(diagnostic_highlight);
}
(chunk.text, highlight_style)
});
(chunk.text, highlight_style)
});
layout_highlighted_chunks(
chunks,
&style.text,
@ -1438,7 +1527,7 @@ impl EditorElement {
} else {
let text_style = self.style.text.clone();
Flex::row()
.with_child(Label::new("".to_string(), text_style).boxed())
.with_child(Label::new("", text_style).boxed())
.with_children(jump_icon)
.contained()
.with_padding_left(gutter_padding)
@ -1606,9 +1695,13 @@ impl Element for EditorElement {
let mut active_rows = BTreeMap::new();
let mut highlighted_rows = None;
let mut highlighted_ranges = Vec::new();
let mut fold_ranges = Vec::new();
let mut show_scrollbars = false;
let mut include_root = false;
let mut is_singleton = false;
self.update_view(cx.app, |view, cx| {
is_singleton = view.is_singleton(cx);
let display_map = view.display_map.update(cx, |map, cx| map.snapshot(cx));
highlighted_rows = view.highlighted_rows();
@ -1616,6 +1709,19 @@ impl Element for EditorElement {
highlighted_ranges =
view.background_highlights_in_range(start_anchor..end_anchor, &display_map, theme);
fold_ranges.extend(
snapshot
.folds_in_range(start_anchor..end_anchor)
.map(|anchor| {
let start = anchor.start.to_point(&snapshot.buffer_snapshot);
(
start.row,
start.to_display_point(&snapshot.display_snapshot)
..anchor.end.to_display_point(&snapshot),
)
}),
);
let mut remote_selections = HashMap::default();
for (replica_id, line_mode, cursor_shape, selection) in display_map
.buffer_snapshot
@ -1684,8 +1790,28 @@ impl Element for EditorElement {
.unwrap_or_default()
});
let line_number_layouts =
self.layout_line_numbers(start_row..end_row, &active_rows, &snapshot, cx);
let fold_ranges: Vec<(BufferRow, Range<DisplayPoint>, Color)> = fold_ranges
.into_iter()
.map(|(id, fold)| {
let color = self
.style
.folds
.ellipses
.background
.style_for(&mut cx.mouse_state::<FoldMarkers>(id as usize), false)
.color;
(id, fold, color)
})
.collect();
let (line_number_layouts, fold_statuses) = self.layout_line_numbers(
start_row..end_row,
&active_rows,
is_singleton,
&snapshot,
cx,
);
let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot);
@ -1755,7 +1881,7 @@ impl Element for EditorElement {
let mut code_actions_indicator = None;
let mut hover = None;
let mut mode = EditorMode::Full;
cx.render(&self.view.upgrade(cx).unwrap(), |view, cx| {
let mut fold_indicators = cx.render(&self.view.upgrade(cx).unwrap(), |view, cx| {
let newest_selection_head = view
.selections
.newest::<usize>(cx)
@ -1769,14 +1895,25 @@ impl Element for EditorElement {
view.render_context_menu(newest_selection_head, style.clone(), cx);
}
let active = matches!(view.context_menu, Some(crate::ContextMenu::CodeActions(_)));
code_actions_indicator = view
.render_code_actions_indicator(&style, cx)
.render_code_actions_indicator(&style, active, cx)
.map(|indicator| (newest_selection_head.row(), indicator));
}
let visible_rows = start_row..start_row + line_layouts.len() as u32;
hover = view.hover_state.render(&snapshot, &style, visible_rows, cx);
mode = view.mode;
view.render_fold_indicators(
fold_statuses,
&style,
view.gutter_hovered,
line_height,
gutter_margin,
cx,
)
});
if let Some((_, context_menu)) = context_menu.as_mut() {
@ -1802,6 +1939,18 @@ impl Element for EditorElement {
);
}
for fold_indicator in fold_indicators.iter_mut() {
if let Some(indicator) = fold_indicator.as_mut() {
indicator.layout(
SizeConstraint::strict_along(
Axis::Vertical,
line_height * style.code_actions.vertical_scale,
),
cx,
);
}
}
if let Some((_, hover_popovers)) = hover.as_mut() {
for hover_popover in hover_popovers.iter_mut() {
hover_popover.layout(
@ -1845,12 +1994,14 @@ impl Element for EditorElement {
active_rows,
highlighted_rows,
highlighted_ranges,
fold_ranges,
line_number_layouts,
display_hunks,
blocks,
selections,
context_menu,
code_actions_indicator,
fold_indicators,
hover_popovers: hover,
},
)
@ -1958,6 +2109,8 @@ impl Element for EditorElement {
}
}
type BufferRow = u32;
pub struct LayoutState {
position_map: Arc<PositionMap>,
gutter_size: Vector2F,
@ -1972,6 +2125,7 @@ pub struct LayoutState {
display_hunks: Vec<DisplayDiffHunk>,
blocks: Vec<BlockLayout>,
highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
fold_ranges: Vec<(BufferRow, Range<DisplayPoint>, Color)>,
selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
scrollbar_row_range: Range<f32>,
show_scrollbars: bool,
@ -1979,6 +2133,7 @@ pub struct LayoutState {
context_menu: Option<(DisplayPoint, ElementBox)>,
code_actions_indicator: Option<(u32, ElementBox)>,
hover_popovers: Option<(DisplayPoint, Vec<ElementBox>)>,
fold_indicators: Vec<Option<ElementBox>>,
}
pub struct PositionMap {
@ -2277,6 +2432,75 @@ impl HighlightedRange {
}
}
pub fn position_to_display_point(
position: Vector2F,
text_bounds: RectF,
position_map: &PositionMap,
) -> Option<DisplayPoint> {
if text_bounds.contains_point(position) {
let (point, target_point) = position_map.point_for_position(text_bounds, position);
if point == target_point {
Some(point)
} else {
None
}
} else {
None
}
}
pub fn range_to_bounds(
range: &Range<DisplayPoint>,
content_origin: Vector2F,
scroll_left: f32,
scroll_top: f32,
visible_row_range: &Range<u32>,
line_end_overshoot: f32,
position_map: &PositionMap,
) -> impl Iterator<Item = RectF> {
let mut bounds: SmallVec<[RectF; 1]> = SmallVec::new();
if range.start == range.end {
return bounds.into_iter();
}
let start_row = visible_row_range.start;
let end_row = visible_row_range.end;
let row_range = if range.end.column() == 0 {
cmp::max(range.start.row(), start_row)..cmp::min(range.end.row(), end_row)
} else {
cmp::max(range.start.row(), start_row)..cmp::min(range.end.row() + 1, end_row)
};
let first_y =
content_origin.y() + row_range.start as f32 * position_map.line_height - scroll_top;
for (idx, row) in row_range.enumerate() {
let line_layout = &position_map.line_layouts[(row - start_row) as usize];
let start_x = if row == range.start.row() {
content_origin.x() + line_layout.x_for_index(range.start.column() as usize)
- scroll_left
} else {
content_origin.x() - scroll_left
};
let end_x = if row == range.end.row() {
content_origin.x() + line_layout.x_for_index(range.end.column() as usize) - scroll_left
} else {
content_origin.x() + line_layout.width() + line_end_overshoot - scroll_left
};
bounds.push(RectF::from_points(
vec2f(start_x, first_y + position_map.line_height * idx as f32),
vec2f(end_x, first_y + position_map.line_height * (idx + 1) as f32),
))
}
bounds.into_iter()
}
pub fn scale_vertical_mouse_autoscroll_delta(delta: f32) -> f32 {
delta.powf(1.5) / 100.0
}
@ -2310,7 +2534,9 @@ mod tests {
let snapshot = editor.snapshot(cx);
let mut presenter = cx.build_presenter(window_id, 30., Default::default());
let layout_cx = presenter.build_layout_context(Vector2F::zero(), false, cx);
element.layout_line_numbers(0..6, &Default::default(), &snapshot, &layout_cx)
element
.layout_line_numbers(0..6, &Default::default(), false, &snapshot, &layout_cx)
.0
});
assert_eq!(layouts.len(), 6);
}

View file

@ -32,11 +32,10 @@ pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewCon
#[cfg(test)]
mod tests {
use crate::test::editor_lsp_test_context::EditorLspTestContext;
use super::*;
use crate::test::editor_lsp_test_context::EditorLspTestContext;
use indoc::indoc;
use language::{BracketPair, Language, LanguageConfig};
use language::{BracketPair, BracketPairConfig, Language, LanguageConfig};
#[gpui::test]
async fn test_matching_bracket_highlights(cx: &mut gpui::TestAppContext) {
@ -45,20 +44,23 @@ mod tests {
LanguageConfig {
name: "Rust".into(),
path_suffixes: vec!["rs".to_string()],
brackets: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: false,
newline: true,
},
BracketPair {
start: "(".to_string(),
end: ")".to_string(),
close: false,
newline: true,
},
],
brackets: BracketPairConfig {
pairs: vec![
BracketPair {
start: "{".to_string(),
end: "}".to_string(),
close: false,
newline: true,
},
BracketPair {
start: "(".to_string(),
end: ")".to_string(),
close: false,
newline: true,
},
],
..Default::default()
},
..Default::default()
},
Some(tree_sitter_rust::language()),

View file

@ -1,3 +1,4 @@
use futures::FutureExt;
use gpui::{
actions,
elements::{Flex, MouseEventHandler, Padding, Text},
@ -327,12 +328,10 @@ impl InfoPopover {
MouseEventHandler::<InfoPopover>::new(0, cx, |_, cx| {
let mut flex = Flex::new(Axis::Vertical).scrollable::<HoverBlock, _>(1, None, cx);
flex.extend(self.contents.iter().map(|content| {
let project = self.project.read(cx);
if let Some(language) = content
.language
.clone()
.and_then(|language| project.languages().language_for_name(&language))
{
let languages = self.project.read(cx).languages();
if let Some(language) = content.language.clone().and_then(|language| {
languages.language_for_name(&language).now_or_never()?.ok()
}) {
let runs = language
.highlight_text(&content.text.as_str().into(), 0..content.text.len());

View file

@ -14,7 +14,7 @@ use language::{
proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, OffsetRangeExt, Point,
SelectionGoal,
};
use project::{Item as _, Project, ProjectPath};
use project::{FormatTrigger, Item as _, Project, ProjectPath};
use rpc::proto::{self, update_view};
use settings::Settings;
use smallvec::SmallVec;
@ -529,7 +529,7 @@ impl Item for Editor {
) -> ElementBox {
Flex::row()
.with_child(
Label::new(self.title(cx).into(), style.label.clone())
Label::new(self.title(cx).to_string(), style.label.clone())
.aligned()
.boxed(),
)
@ -538,11 +538,7 @@ impl Item for Editor {
let description = path.to_string_lossy();
Some(
Label::new(
if description.len() > MAX_TAB_TITLE_LEN {
description[..MAX_TAB_TITLE_LEN].to_string() + ""
} else {
description.into()
},
util::truncate_and_trailoff(&description, MAX_TAB_TITLE_LEN),
style.description.text.clone(),
)
.contained()
@ -608,13 +604,38 @@ impl Item for Editor {
cx: &mut ViewContext<Self>,
) -> Task<Result<()>> {
self.report_event("save editor", cx);
let format = self.perform_format(project.clone(), cx);
let format = self.perform_format(project.clone(), FormatTrigger::Save, cx);
let buffers = self.buffer().clone().read(cx).all_buffers();
cx.as_mut().spawn(|mut cx| async move {
format.await?;
project
.update(&mut cx, |project, cx| project.save_buffers(buffers, cx))
.await?;
if buffers.len() == 1 {
project
.update(&mut cx, |project, cx| project.save_buffers(buffers, cx))
.await?;
} else {
// For multi-buffers, only save those ones that contain changes. For clean buffers
// we simulate saving by calling `Buffer::did_save`, so that language servers or
// other downstream listeners of save events get notified.
let (dirty_buffers, clean_buffers) = buffers.into_iter().partition(|buffer| {
buffer.read_with(&cx, |buffer, _| buffer.is_dirty() || buffer.has_conflict())
});
project
.update(&mut cx, |project, cx| {
project.save_buffers(dirty_buffers, cx)
})
.await?;
for buffer in clean_buffers {
buffer.update(&mut cx, |buffer, cx| {
let version = buffer.saved_version().clone();
let fingerprint = buffer.saved_version_fingerprint();
let mtime = buffer.saved_mtime();
buffer.did_save(version, fingerprint, mtime, cx);
});
}
}
Ok(())
})
}
@ -726,11 +747,15 @@ impl Item for Editor {
.map(|path| path.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let mut breadcrumbs = vec![Label::new(filename, theme.breadcrumbs.text.clone()).boxed()];
let filename_label = Label::new(filename, theme.workspace.breadcrumbs.default.text.clone());
let mut breadcrumbs = vec![filename_label.boxed()];
breadcrumbs.extend(symbols.into_iter().map(|symbol| {
Text::new(symbol.text, theme.breadcrumbs.text.clone())
.with_highlights(symbol.highlight_ranges)
.boxed()
Text::new(
symbol.text,
theme.workspace.breadcrumbs.default.text.clone(),
)
.with_highlights(symbol.highlight_ranges)
.boxed()
}));
Some(breadcrumbs)
}
@ -810,7 +835,7 @@ impl Item for Editor {
.context("Project item at stored path was not a buffer")?;
Ok(cx.update(|cx| {
cx.add_view(pane, |cx| {
cx.add_view(&pane, |cx| {
let mut editor = Editor::for_buffer(buffer, Some(project), cx);
editor.read_scroll_position_from_db(item_id, workspace_id, cx);
editor
@ -886,7 +911,7 @@ impl SearchableItem for Editor {
matches: Vec<Range<Anchor>>,
cx: &mut ViewContext<Self>,
) {
self.unfold_ranges([matches[index].clone()], false, cx);
self.unfold_ranges([matches[index].clone()], false, true, cx);
self.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.select_ranges([matches[index].clone()])
});

View file

@ -6,7 +6,7 @@ use gpui::{
use crate::{
DisplayPoint, Editor, EditorMode, FindAllReferences, GoToDefinition, GoToTypeDefinition,
Rename, SelectMode, ToggleCodeActions,
Rename, RevealInFinder, SelectMode, ToggleCodeActions,
};
#[derive(Clone, PartialEq)]
@ -61,6 +61,8 @@ pub fn deploy_context_menu(
deployed_from_indicator: false,
},
),
ContextMenuItem::Separator,
ContextMenuItem::item("Reveal in Finder", RevealInFinder),
],
cx,
);

View file

@ -69,16 +69,11 @@ pub fn up_by_rows(
goal_column = 0;
}
let clip_bias = if point.column() == map.line_len(point.row()) {
Bias::Left
} else {
Bias::Right
};
(
map.clip_point(point, clip_bias),
SelectionGoal::Column(goal_column),
)
let mut clipped_point = map.clip_point(point, Bias::Left);
if clipped_point.row() < point.row() {
clipped_point = map.clip_point(point, Bias::Right);
}
(clipped_point, SelectionGoal::Column(goal_column))
}
pub fn down_by_rows(
@ -105,16 +100,11 @@ pub fn down_by_rows(
goal_column = map.column_to_chars(point.row(), point.column())
}
let clip_bias = if point.column() == map.line_len(point.row()) {
Bias::Left
} else {
Bias::Right
};
(
map.clip_point(point, clip_bias),
SelectionGoal::Column(goal_column),
)
let mut clipped_point = map.clip_point(point, Bias::Right);
if clipped_point.row() > point.row() {
clipped_point = map.clip_point(point, Bias::Left);
}
(clipped_point, SelectionGoal::Column(goal_column))
}
pub fn line_beginning(
@ -587,7 +577,10 @@ mod tests {
#[gpui::test]
fn test_move_up_and_down_with_excerpts(cx: &mut gpui::MutableAppContext) {
cx.set_global(Settings::test(cx));
let family_id = cx.font_cache().load_family(&["Helvetica"]).unwrap();
let family_id = cx
.font_cache()
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = cx
.font_cache()
.select_font(family_id, &Default::default())

View file

@ -1082,18 +1082,21 @@ impl MultiBuffer {
let mut cursor = snapshot.excerpts.cursor::<usize>();
cursor.seek(&position, Bias::Right, &());
cursor.item().map(|excerpt| {
(
excerpt.id.clone(),
self.buffers
.borrow()
.get(&excerpt.buffer_id)
.unwrap()
.buffer
.clone(),
excerpt.range.context.clone(),
)
})
cursor
.item()
.or_else(|| snapshot.excerpts.last())
.map(|excerpt| {
(
excerpt.id.clone(),
self.buffers
.borrow()
.get(&excerpt.buffer_id)
.unwrap()
.buffer
.clone(),
excerpt.range.context.clone(),
)
})
}
// If point is at the end of the buffer, the last excerpt is returned
@ -2191,7 +2194,11 @@ impl MultiBufferSnapshot {
pub fn buffer_line_for_row(&self, row: u32) -> Option<(&BufferSnapshot, Range<Point>)> {
let mut cursor = self.excerpts.cursor::<Point>();
cursor.seek(&Point::new(row, 0), Bias::Right, &());
let point = Point::new(row, 0);
cursor.seek(&point, Bias::Right, &());
if cursor.item().is_none() && *cursor.start() == point {
cursor.prev(&());
}
if let Some(excerpt) = cursor.item() {
let overshoot = row - cursor.start().row;
let excerpt_start = excerpt.range.context.start.to_point(&excerpt.buffer);
@ -2926,6 +2933,10 @@ impl MultiBufferSnapshot {
Some(self.excerpt(excerpt_id)?.buffer_id)
}
pub fn buffer_for_excerpt(&self, excerpt_id: ExcerptId) -> Option<&BufferSnapshot> {
Some(&self.excerpt(excerpt_id)?.buffer)
}
fn excerpt<'a>(&'a self, excerpt_id: ExcerptId) -> Option<&'a Excerpt> {
let mut cursor = self.excerpts.cursor::<Option<&Locator>>();
let locator = self.excerpt_locator_for_id(excerpt_id);

View file

@ -25,7 +25,10 @@ pub fn marked_display_snapshot(
) -> (DisplaySnapshot, Vec<DisplayPoint>) {
let (unmarked_text, markers) = marked_text_offsets(text);
let family_id = cx.font_cache().load_family(&["Helvetica"]).unwrap();
let family_id = cx
.font_cache()
.load_family(&["Helvetica"], &Default::default())
.unwrap();
let font_id = cx
.font_cache()
.select_font(family_id, &Default::default())

View file

@ -39,7 +39,7 @@ impl<'a> EditorLspTestContext<'a> {
pane::init(cx);
});
let params = cx.update(AppState::test);
let app_state = cx.update(AppState::test);
let file_name = format!(
"file.{}",
@ -56,24 +56,16 @@ impl<'a> EditorLspTestContext<'a> {
}))
.await;
let project = Project::test(params.fs.clone(), [], cx).await;
let project = Project::test(app_state.fs.clone(), [], cx).await;
project.update(cx, |project, _| project.languages().add(Arc::new(language)));
params
app_state
.fs
.as_fake()
.insert_tree("/root", json!({ "dir": { file_name.clone(): "" }}))
.await;
let (window_id, workspace) = cx.add_window(|cx| {
Workspace::new(
Default::default(),
0,
project.clone(),
|_, _| unimplemented!(),
cx,
)
});
let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
project
.update(cx, |project, cx| {
project.find_or_create_local_worktree("/root", true, cx)
@ -134,7 +126,7 @@ impl<'a> EditorLspTestContext<'a> {
(let_chain)
(await_expression)
] @indent
(_ "[" "]" @end) @indent
(_ "<" ">" @end) @indent
(_ "{" "}" @end) @indent

View file

@ -185,6 +185,7 @@ impl<'a> EditorTestContext<'a> {
/// of its selections using a string containing embedded range markers.
///
/// See the `util::test::marked_text_ranges` function for more information.
#[track_caller]
pub fn assert_editor_state(&mut self, marked_text: &str) {
let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true);
let buffer_text = self.buffer_text();

View file

@ -21,14 +21,15 @@ gpui = { path = "../gpui" }
human_bytes = "0.4.1"
isahc = "1.7"
lazy_static = "1.4.0"
postage = { version = "0.4", features = ["futures-traits"] }
postage = { workspace = true }
project = { path = "../project" }
search = { path = "../search" }
serde = { version = "1.0", features = ["derive", "rc"] }
serde = { workspace = true }
serde_derive = { workspace = true }
settings = { path = "../settings" }
sysinfo = "0.27.1"
theme = { path = "../theme" }
tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" }
urlencoding = "2.1.2"
util = { path = "../util" }
workspace = { path = "../workspace" }
workspace = { path = "../workspace" }

View file

@ -1,38 +1,66 @@
use gpui::{
elements::{MouseEventHandler, ParentElement, Stack, Text},
CursorStyle, Element, ElementBox, Entity, MouseButton, RenderContext, View, ViewContext,
};
use gpui::{elements::*, CursorStyle, Entity, MouseButton, RenderContext, View, ViewContext};
use settings::Settings;
use workspace::{item::ItemHandle, StatusItemView};
use crate::feedback_editor::GiveFeedback;
use crate::feedback_editor::{FeedbackEditor, GiveFeedback};
pub struct DeployFeedbackButton;
pub struct DeployFeedbackButton {
active: bool,
}
impl Entity for DeployFeedbackButton {
type Event = ();
}
impl DeployFeedbackButton {
pub fn new() -> Self {
DeployFeedbackButton { active: false }
}
}
impl View for DeployFeedbackButton {
fn ui_name() -> &'static str {
"DeployFeedbackButton"
}
fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
let active = self.active;
let theme = cx.global::<Settings>().theme.clone();
Stack::new()
.with_child(
MouseEventHandler::<Self>::new(0, cx, |state, cx| {
let theme = &cx.global::<Settings>().theme;
let theme = &theme.workspace.status_bar.feedback;
MouseEventHandler::<Self>::new(0, cx, |state, _| {
let style = &theme
.workspace
.status_bar
.sidebar_buttons
.item
.style_for(state, active);
Text::new(
"Give Feedback".to_string(),
theme.style_for(state, true).clone(),
)
.boxed()
Svg::new("icons/feedback_16.svg")
.with_color(style.icon_color)
.constrained()
.with_width(style.icon_size)
.aligned()
.constrained()
.with_width(style.icon_size)
.with_height(style.icon_size)
.contained()
.with_style(style.container)
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, |_, cx| cx.dispatch_action(GiveFeedback))
.on_click(MouseButton::Left, move |_, cx| {
if !active {
cx.dispatch_action(GiveFeedback)
}
})
.with_tooltip::<Self, _>(
0,
"Send Feedback".into(),
Some(Box::new(GiveFeedback)),
theme.tooltip.clone(),
cx,
)
.boxed(),
)
.boxed()
@ -40,5 +68,15 @@ impl View for DeployFeedbackButton {
}
impl StatusItemView for DeployFeedbackButton {
fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, _: &mut ViewContext<Self>) {}
fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext<Self>) {
if let Some(item) = item {
if let Some(_) = item.downcast::<FeedbackEditor>() {
self.active = true;
cx.notify();
return;
}
}
self.active = false;
cx.notify();
}
}

View file

@ -20,7 +20,12 @@ impl_actions!(zed, [OpenBrowser]);
actions!(
zed,
[CopySystemSpecsIntoClipboard, FileBugReport, RequestFeature]
[
CopySystemSpecsIntoClipboard,
FileBugReport,
RequestFeature,
OpenZedCommunityRepo
]
);
pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
@ -66,4 +71,11 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
});
},
);
cx.add_action(
|_: &mut Workspace, _: &OpenZedCommunityRepo, cx: &mut ViewContext<Workspace>| {
let url = "https://github.com/zed-industries/community";
cx.dispatch_action(OpenBrowser { url: url.into() });
},
);
}

View file

@ -10,10 +10,9 @@ use editor::{Anchor, Editor};
use futures::AsyncReadExt;
use gpui::{
actions,
elements::{ChildView, Flex, Label, ParentElement},
elements::{ChildView, Flex, Label, ParentElement, Svg},
serde_json, AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle,
MutableAppContext, PromptLevel, RenderContext, Task, View, ViewContext, ViewHandle,
WeakViewHandle,
};
use isahc::Request;
use language::Buffer;
@ -21,10 +20,10 @@ use postage::prelude::Stream;
use project::Project;
use serde::Serialize;
use util::ResultExt;
use workspace::{
item::{Item, ItemHandle},
searchable::{SearchableItem, SearchableItemHandle},
smallvec::SmallVec,
AppState, Workspace,
};
@ -202,24 +201,28 @@ impl FeedbackEditor {
impl FeedbackEditor {
pub fn deploy(
system_specs: SystemSpecs,
workspace: &mut Workspace,
_: &mut Workspace,
app_state: Arc<AppState>,
cx: &mut ViewContext<Workspace>,
) {
workspace
.with_local_workspace(&app_state, cx, |workspace, cx| {
let project = workspace.project().clone();
let markdown_language = project.read(cx).languages().language_for_name("Markdown");
let buffer = project
.update(cx, |project, cx| {
project.create_buffer("", markdown_language, cx)
let markdown = app_state.languages.language_for_name("Markdown");
cx.spawn(|workspace, mut cx| async move {
let markdown = markdown.await.log_err();
workspace
.update(&mut cx, |workspace, cx| {
workspace.with_local_workspace(&app_state, cx, |workspace, cx| {
let project = workspace.project().clone();
let buffer = project
.update(cx, |project, cx| project.create_buffer("", markdown, cx))
.expect("creating buffers on a local workspace always succeeds");
let feedback_editor = cx
.add_view(|cx| FeedbackEditor::new(system_specs, project, buffer, cx));
workspace.add_item(Box::new(feedback_editor), cx);
})
.expect("creating buffers on a local workspace always succeeds");
let feedback_editor =
cx.add_view(|cx| FeedbackEditor::new(system_specs, project, buffer, cx));
workspace.add_item(Box::new(feedback_editor), cx);
})
.detach();
})
.await;
})
.detach();
}
}
@ -247,7 +250,17 @@ impl Item for FeedbackEditor {
fn tab_content(&self, _: Option<usize>, style: &theme::Tab, _: &AppContext) -> ElementBox {
Flex::row()
.with_child(
Label::new("Feedback".to_string(), style.label.clone())
Svg::new("icons/feedback_16.svg")
.with_color(style.label.text.color)
.constrained()
.with_width(style.type_icon_width)
.aligned()
.contained()
.with_margin_right(style.spacing)
.boxed(),
)
.with_child(
Label::new("Send Feedback", style.label.clone())
.aligned()
.contained()
.boxed(),
@ -259,16 +272,10 @@ impl Item for FeedbackEditor {
self.editor.for_each_project_item(cx, f)
}
fn to_item_events(_: &Self::Event) -> SmallVec<[workspace::item::ItemEvent; 2]> {
SmallVec::new()
}
fn is_singleton(&self, _: &AppContext) -> bool {
true
}
fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
fn can_save(&self, _: &AppContext) -> bool {
true
}
@ -295,7 +302,7 @@ impl Item for FeedbackEditor {
_: ModelHandle<Project>,
_: &mut ViewContext<Self>,
) -> Task<anyhow::Result<()>> {
unreachable!("reload should not have been called")
Task::Ready(Some(Ok(())))
}
fn clone_on_split(
@ -322,34 +329,20 @@ impl Item for FeedbackEditor {
))
}
fn serialized_item_kind() -> Option<&'static str> {
None
}
fn deserialize(
_: ModelHandle<Project>,
_: WeakViewHandle<Workspace>,
_: workspace::WorkspaceId,
_: workspace::ItemId,
_: &mut ViewContext<workspace::Pane>,
) -> Task<anyhow::Result<ViewHandle<Self>>> {
unreachable!()
}
fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(handle.clone()))
}
fn act_as_type(
&self,
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &ViewHandle<Self>,
_: &AppContext,
) -> Option<AnyViewHandle> {
self_handle: &'a ViewHandle<Self>,
_: &'a AppContext,
) -> Option<&'a AnyViewHandle> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.into())
Some(self_handle)
} else if type_id == TypeId::of::<Editor>() {
Some((&self.editor).into())
Some(&self.editor)
} else {
None
}

View file

@ -1,10 +1,12 @@
use gpui::{
elements::Label, Element, ElementBox, Entity, RenderContext, View, ViewContext, ViewHandle,
elements::{Flex, Label, MouseEventHandler, ParentElement, Text},
CursorStyle, Element, ElementBox, Entity, MouseButton, RenderContext, View, ViewContext,
ViewHandle,
};
use settings::Settings;
use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView};
use crate::feedback_editor::FeedbackEditor;
use crate::{feedback_editor::FeedbackEditor, OpenZedCommunityRepo};
pub struct FeedbackInfoText {
active_item: Option<ViewHandle<FeedbackEditor>>,
@ -29,9 +31,44 @@ impl View for FeedbackInfoText {
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
let theme = cx.global::<Settings>().theme.clone();
let text = "We read whatever you submit here. For issues and discussions, visit the community repo on GitHub.";
Label::new(text.to_string(), theme.feedback.info_text.text.clone())
.contained()
Flex::row()
.with_child(
Text::new(
"We read whatever you submit here. For issues and discussions, visit the ",
theme.feedback.info_text_default.text.clone(),
)
.with_soft_wrap(false)
.aligned()
.boxed(),
)
.with_child(
MouseEventHandler::<OpenZedCommunityRepo>::new(0, cx, |state, _| {
let contained_text = if state.hovered() {
&theme.feedback.link_text_hover
} else {
&theme.feedback.link_text_default
};
Label::new("community repo", contained_text.text.clone())
.contained()
.aligned()
.left()
.clipped()
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, |_, cx| {
cx.dispatch_action(OpenZedCommunityRepo)
})
.boxed(),
)
.with_child(
Text::new(" on GitHub.", theme.feedback.info_text_default.text.clone())
.with_soft_wrap(false)
.aligned()
.boxed(),
)
.aligned()
.left()
.clipped()

View file

@ -34,7 +34,7 @@ impl View for SubmitFeedbackButton {
enum SubmitFeedbackButton {}
MouseEventHandler::<SubmitFeedbackButton>::new(0, cx, |state, _| {
let style = theme.feedback.submit_button.style_for(state, false);
Label::new("Submit as Markdown".into(), style.text.clone())
Label::new("Submit as Markdown", style.text.clone())
.contained()
.with_style(style.container)
.boxed()

View file

@ -19,11 +19,11 @@ settings = { path = "../settings" }
util = { path = "../util" }
theme = { path = "../theme" }
workspace = { path = "../workspace" }
postage = { version = "0.4.1", features = ["futures-traits"] }
postage = { workspace = true }
[dev-dependencies]
gpui = { path = "../gpui", features = ["test-support"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_json = { workspace = true }
workspace = { path = "../workspace", features = ["test-support"] }
ctor = "0.1"
env_logger = "0.9"

View file

@ -23,6 +23,7 @@ pub struct FileFinder {
latest_search_id: usize,
latest_search_did_cancel: bool,
latest_search_query: String,
relative_to: Option<Arc<Path>>,
matches: Vec<PathMatch>,
selected: Option<(usize, Arc<Path>)>,
cancel_flag: Arc<AtomicBool>,
@ -50,7 +51,7 @@ impl View for FileFinder {
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
ChildView::new(self.picker.clone(), cx).boxed()
ChildView::new(&self.picker, cx).boxed()
}
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@ -90,7 +91,11 @@ impl FileFinder {
fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
workspace.toggle_modal(cx, |workspace, cx| {
let project = workspace.project().clone();
let finder = cx.add_view(|cx| Self::new(project, cx));
let relative_to = workspace
.active_item(cx)
.and_then(|item| item.project_path(cx))
.map(|project_path| project_path.path.clone());
let finder = cx.add_view(|cx| Self::new(project, relative_to, cx));
cx.subscribe(&finder, Self::on_event).detach();
finder
});
@ -115,7 +120,11 @@ impl FileFinder {
}
}
pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
pub fn new(
project: ModelHandle<Project>,
relative_to: Option<Arc<Path>>,
cx: &mut ViewContext<Self>,
) -> Self {
let handle = cx.weak_handle();
cx.observe(&project, Self::project_updated).detach();
Self {
@ -125,6 +134,7 @@ impl FileFinder {
latest_search_id: 0,
latest_search_did_cancel: false,
latest_search_query: String::new(),
relative_to,
matches: Vec::new(),
selected: None,
cancel_flag: Arc::new(AtomicBool::new(false)),
@ -137,6 +147,7 @@ impl FileFinder {
}
fn spawn_search(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
let relative_to = self.relative_to.clone();
let worktrees = self
.project
.read(cx)
@ -165,6 +176,7 @@ impl FileFinder {
let matches = fuzzy::match_path_sets(
candidate_sets.as_slice(),
&query,
relative_to,
false,
100,
&cancel_flag,
@ -317,9 +329,7 @@ mod tests {
.await;
let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
let (window_id, workspace) = cx.add_window(|cx| {
Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
});
let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
cx.dispatch_action(window_id, Toggle);
let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
@ -342,8 +352,8 @@ mod tests {
let active_item = active_pane.read(cx).active_item().unwrap();
assert_eq!(
active_item
.to_any()
.downcast::<Editor>()
.as_any()
.downcast_ref::<Editor>()
.unwrap()
.read(cx)
.title(cx),
@ -373,11 +383,9 @@ mod tests {
.await;
let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
let (_, workspace) = cx.add_window(|cx| {
Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
});
let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
let (_, finder) =
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
let query = "hi".to_string();
finder
@ -449,11 +457,9 @@ mod tests {
cx,
)
.await;
let (_, workspace) = cx.add_window(|cx| {
Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
});
let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
let (_, finder) =
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
finder
.update(cx, |f, cx| f.spawn_search("hi".into(), cx))
.await;
@ -475,11 +481,9 @@ mod tests {
cx,
)
.await;
let (_, workspace) = cx.add_window(|cx| {
Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
});
let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
let (_, finder) =
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
// Even though there is only one worktree, that worktree's filename
// is included in the matching, because the worktree is a single file.
@ -529,11 +533,10 @@ mod tests {
cx,
)
.await;
let (_, workspace) = cx.add_window(|cx| {
Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
});
let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
let (_, finder) =
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
// Run a search that matches two files with the same relative path.
finder
@ -551,6 +554,46 @@ mod tests {
});
}
#[gpui::test]
async fn test_path_distance_ordering(cx: &mut gpui::TestAppContext) {
cx.foreground().forbid_parking();
let app_state = cx.update(AppState::test);
app_state
.fs
.as_fake()
.insert_tree(
"/root",
json!({
"dir1": { "a.txt": "" },
"dir2": {
"a.txt": "",
"b.txt": ""
}
}),
)
.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));
// When workspace has an active item, sort items which are closer to that item
// first when they have the same name. In this case, b.txt is closer to dir2's a.txt
// so that one should be sorted earlier
let b_path = Some(Arc::from(Path::new("/root/dir2/b.txt")));
let (_, finder) =
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), b_path, cx));
finder
.update(cx, |f, cx| f.spawn_search("a.txt".into(), cx))
.await;
finder.read_with(cx, |f, _| {
assert_eq!(f.matches[0].path.as_ref(), Path::new("dir2/a.txt"));
assert_eq!(f.matches[1].path.as_ref(), Path::new("dir1/a.txt"));
});
}
#[gpui::test]
async fn test_search_worktree_without_files(cx: &mut gpui::TestAppContext) {
let app_state = cx.update(AppState::test);
@ -569,11 +612,9 @@ mod tests {
.await;
let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
let (_, workspace) = cx.add_window(|cx| {
Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
});
let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
let (_, finder) =
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx));
finder
.update(cx, |f, cx| f.spawn_search("dir".into(), cx))
.await;

View file

@ -24,6 +24,7 @@ smol = "1.2.5"
regex = "1.5"
git2 = { version = "0.15", default-features = false }
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
log = { version = "0.4.16", features = ["kv_unstable_serde"] }
libc = "0.2"

View file

@ -380,6 +380,8 @@ struct FakeFsState {
next_inode: u64,
next_mtime: SystemTime,
event_txs: Vec<smol::channel::Sender<Vec<fsevent::Event>>>,
events_paused: bool,
buffered_events: Vec<fsevent::Event>,
}
#[cfg(any(test, feature = "test-support"))]
@ -483,15 +485,21 @@ impl FakeFsState {
I: IntoIterator<Item = T>,
T: Into<PathBuf>,
{
let events = paths
.into_iter()
.map(|path| fsevent::Event {
self.buffered_events
.extend(paths.into_iter().map(|path| fsevent::Event {
event_id: 0,
flags: fsevent::StreamFlags::empty(),
path: path.into(),
})
.collect::<Vec<_>>();
}));
if !self.events_paused {
self.flush_events(self.buffered_events.len());
}
}
fn flush_events(&mut self, mut count: usize) {
count = count.min(self.buffered_events.len());
let events = self.buffered_events.drain(0..count).collect::<Vec<_>>();
self.event_txs.retain(|tx| {
let _ = tx.try_send(events.clone());
!tx.is_closed()
@ -514,6 +522,8 @@ impl FakeFs {
next_mtime: SystemTime::UNIX_EPOCH,
next_inode: 1,
event_txs: Default::default(),
buffered_events: Vec::new(),
events_paused: false,
}),
})
}
@ -567,6 +577,18 @@ impl FakeFs {
state.emit_event(&[path]);
}
pub async fn pause_events(&self) {
self.state.lock().await.events_paused = true;
}
pub async fn buffered_event_count(&self) -> usize {
self.state.lock().await.buffered_events.len()
}
pub async fn flush_events(&self, count: usize) {
self.state.lock().await.flush_events(count);
}
#[must_use]
pub fn insert_tree<'a>(
&'a self,
@ -868,7 +890,7 @@ impl Fs for FakeFs {
.ok_or_else(|| anyhow!("cannot remove the root"))?;
let base_name = path.file_name().unwrap();
let state = self.state.lock().await;
let mut state = self.state.lock().await;
let parent_entry = state.read_path(parent_path).await?;
let mut parent_entry = parent_entry.lock().await;
let entry = parent_entry
@ -892,7 +914,7 @@ impl Fs for FakeFs {
e.remove();
}
}
state.emit_event(&[path]);
Ok(())
}

View file

@ -2,7 +2,7 @@ use anyhow::Result;
use collections::HashMap;
use parking_lot::Mutex;
use std::{
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
sync::Arc,
};
@ -27,7 +27,11 @@ impl GitRepository for LibGitRepository {
fn logic(repo: &LibGitRepository, relative_file_path: &Path) -> Result<Option<String>> {
const STAGE_NORMAL: i32 = 0;
let index = repo.index()?;
let oid = match index.get_path(relative_file_path, STAGE_NORMAL) {
// This check is required because index.get_path() unwraps internally :(
check_path_to_repo_path_errors(relative_file_path)?;
let oid = match index.get_path(&relative_file_path, STAGE_NORMAL) {
Some(entry) => entry.id,
None => return Ok(None),
};
@ -69,3 +73,32 @@ impl GitRepository for FakeGitRepository {
state.index_contents.get(path).cloned()
}
}
fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
match relative_file_path.components().next() {
None => anyhow::bail!("repo path should not be empty"),
Some(Component::Prefix(_)) => anyhow::bail!(
"repo path `{}` should be relative, not a windows prefix",
relative_file_path.to_string_lossy()
),
Some(Component::RootDir) => {
anyhow::bail!(
"repo path `{}` should be relative",
relative_file_path.to_string_lossy()
)
}
Some(Component::CurDir) => {
anyhow::bail!(
"repo path `{}` should not start with `.`",
relative_file_path.to_string_lossy()
)
}
Some(Component::ParentDir) => {
anyhow::bail!(
"repo path `{}` should not start with `..`",
relative_file_path.to_string_lossy()
)
}
_ => Ok(()),
}
}

View file

@ -443,6 +443,7 @@ mod tests {
positions: Vec::new(),
path: candidate.path.clone(),
path_prefix: "".into(),
distance_to_relative_ancestor: usize::MAX,
},
);

View file

@ -25,6 +25,9 @@ pub struct PathMatch {
pub worktree_id: usize,
pub path: Arc<Path>,
pub path_prefix: Arc<str>,
/// Number of steps removed from a shared parent with the relative path
/// Used to order closer paths first in the search list
pub distance_to_relative_ancestor: usize,
}
pub trait PathMatchCandidateSet<'a>: Send + Sync {
@ -78,6 +81,11 @@ impl Ord for PathMatch {
.partial_cmp(&other.score)
.unwrap_or(Ordering::Equal)
.then_with(|| self.worktree_id.cmp(&other.worktree_id))
.then_with(|| {
other
.distance_to_relative_ancestor
.cmp(&self.distance_to_relative_ancestor)
})
.then_with(|| self.path.cmp(&other.path))
}
}
@ -85,6 +93,7 @@ impl Ord for PathMatch {
pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
candidate_sets: &'a [Set],
query: &str,
relative_to: Option<Arc<Path>>,
smart_case: bool,
max_results: usize,
cancel_flag: &AtomicBool,
@ -111,6 +120,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
background
.scoped(|scope| {
for (segment_idx, results) in segment_results.iter_mut().enumerate() {
let relative_to = relative_to.clone();
scope.spawn(async move {
let segment_start = segment_idx * segment_size;
let segment_end = segment_start + segment_size;
@ -149,6 +159,15 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
positions: Vec::new(),
path: candidate.path.clone(),
path_prefix: candidate_set.prefix(),
distance_to_relative_ancestor: relative_to.as_ref().map_or(
usize::MAX,
|relative_to| {
distance_between_paths(
candidate.path.as_ref(),
relative_to.as_ref(),
)
},
),
},
);
}
@ -172,3 +191,30 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
}
results
}
/// Compute the distance from a given path to some other path
/// If there is no shared path, returns usize::MAX
fn distance_between_paths(path: &Path, relative_to: &Path) -> usize {
let mut path_components = path.components();
let mut relative_components = relative_to.components();
while path_components
.next()
.zip(relative_components.next())
.map(|(path_component, relative_component)| path_component == relative_component)
.unwrap_or_default()
{}
path_components.count() + relative_components.count() + 1
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::distance_between_paths;
#[test]
fn test_distance_between_paths_empty() {
distance_between_paths(Path::new(""), Path::new(""));
}
}

View file

@ -15,4 +15,4 @@ menu = { path = "../menu" }
settings = { path = "../settings" }
text = { path = "../text" }
workspace = { path = "../workspace" }
postage = { version = "0.4", features = ["futures-traits"] }
postage = { workspace = true }

View file

@ -36,12 +36,14 @@ parking = "2.0.0"
parking_lot = "0.11.1"
pathfinder_color = "0.5"
pathfinder_geometry = "0.5"
postage = { version = "0.4.1", features = ["futures-traits"] }
postage = { workspace = true }
rand = "0.8.3"
resvg = "0.14"
schemars = "0.8"
seahash = "4.1"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
serde = { workspace = true }
serde_derive = { workspace = true }
serde_json = { workspace = true }
smallvec = { version = "1.6", features = ["union"] }
smol = "1.2"
time = { version = "0.3", features = ["serde", "serde-well-known"] }

View file

@ -56,7 +56,10 @@ impl gpui::Element for TextElement {
cx: &mut gpui::PaintContext,
) -> Self::PaintState {
let font_size = 12.;
let family = cx.font_cache.load_family(&["SF Pro Display"]).unwrap();
let family = cx
.font_cache
.load_family(&["SF Pro Display"], &Default::default())
.unwrap();
let normal = RunStyle {
font_id: cx
.font_cache

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more