Compare commits

...

50 Commits

Author SHA1 Message Date
Max Brunsfeld
5218a2f966 collab 0.3.3 2022-12-13 10:17:01 -08:00
Max Brunsfeld
95748123b5 Merge remote-tracking branch 'origin/collab-0.3.x' 2022-12-13 10:16:25 -08:00
Max Brunsfeld
6ad326ac58 Wait longer before deleting outdated rooms from the database
Co-authored-by: Antonio Scandurra <antonio@zed.dev>
2022-12-13 10:14:43 -08:00
Mikayla Maki
b0652c55c6 Merge pull request #1968 from zed-industries/fix-text-size-in-updates
Fix text size in notifications
2022-12-13 10:00:24 -08:00
Mikayla Maki
790ef19a48 Fix mis-set variables 2022-12-13 09:58:04 -08:00
Antonio Scandurra
99c5f8c713 collab 0.3.2 2022-12-13 15:16:41 +01:00
Antonio Scandurra
461c2400ad Merge pull request #1965 from zed-industries/preserve-calls-during-server-restarts
Automatically re-join call when server is restarted
2022-12-13 14:12:32 +01:00
Antonio Scandurra
073a2988e6 Move creation of room_id index into its own migration 2022-12-13 13:57:41 +01:00
Antonio Scandurra
70aac75dd5 Run until parked before asserting about participants in restart test 2022-12-13 13:53:51 +01:00
Antonio Scandurra
4dc838fbb7 Reset connection pool when tearing down the server in tests 2022-12-13 13:51:25 +01:00
Antonio Scandurra
d4c8fa3090 Use a synchronous mutex for ConnectionPool 2022-12-13 13:50:51 +01:00
Antonio Scandurra
a594ba8f8a Simulate server restarts in randomized test 2022-12-13 12:18:38 +01:00
Antonio Scandurra
f1884d608b Allow server to see client disconnection before giving up on reconnecting 2022-12-13 12:17:21 +01:00
Antonio Scandurra
417db95693 Fix typo in index name 2022-12-13 11:44:48 +01:00
Antonio Scandurra
0220d7ba5d Include room_id in CallCanceled message
This ensures we don't accidentally cancel old calls.
2022-12-13 11:43:09 +01:00
Antonio Scandurra
e2b132ef23 💄 2022-12-13 11:37:39 +01:00
Antonio Scandurra
7e8d9d52d3 Delete stray debug statement 2022-12-13 11:36:40 +01:00
Antonio Scandurra
6a6a032f1f Delete stale rooms/participants after RECONNECT_TIMEOUT 2022-12-13 11:32:37 +01:00
Mikayla Maki
fcea254e8e Merge pull request #1963 from zed-industries/fix-workspace-corner-cases
Fix small workspace deserialization corner cases
2022-12-12 17:56:43 -08:00
Mikayla Maki
9bf0a02eae Allow an empty center group to successfully deserialize into an empty pane.
Fix error when deserializing pane axis which caused it's members.len() > 1 invariant to be violated
Fix failure to gain center pane focus when failing to deserialize a center pane entirely

Co-authored-by: Max <max@zed.dev>
2022-12-12 17:51:16 -08:00
Max Brunsfeld
2affbcc495 Merge pull request #1962 from zed-industries/scrolling-breaks-follow
Avoid breaking follow when syncing leader's scroll position
2022-12-12 16:37:58 -08:00
Mikayla Maki
8012e9fcbd Merge pull request #1961 from zed-industries/fix-next-screen-bug
Fixed issue where the NextScreen action would never have an effect
2022-12-12 15:47:16 -08:00
Mikayla Maki
cd2d593a6c Fixed issue where the NextScreen action would never have an effect 2022-12-12 15:36:51 -08:00
Max Brunsfeld
9ef00ea44c Avoid breaking follow when syncing leader's scroll position
Co-authored-by: Mikayla Maki <mikayla@zed.dev>
Co-authored-by: Kay Simmons <kay@zed.dev>
2022-12-12 15:36:30 -08:00
Mikayla Maki
91d6b66fc4 Merge pull request #1959 from zed-industries/serializing-bug-fixes
Add check for if the user wants a blanks workspace when deserializing
2022-12-12 13:37:43 -08:00
Mikayla Maki
5a29a74956 Fetch last workspace explicitly when starting Zed
co-authored-by: Max <max@zed.dev>
2022-12-12 13:29:18 -08:00
Mikayla Maki
db3119b553 Add check for if the user wants a blanks workspace when deserializing 2022-12-12 12:25:52 -08:00
Antonio Scandurra
beea9b68ff Allow re-joining room after server restarts 2022-12-12 16:03:21 +01:00
Antonio Scandurra
82397f34d1 Merge pull request #1950 from zed-industries/reconnect-to-room
Automatically re-join call when client connection drops
2022-12-12 13:40:21 +01:00
Antonio Scandurra
3cd77bfcc4 Always cast connection ids to i32
Postgres doesn't support unsigned types. This also adds indices to
support querying `project_collaborators` and `room_participants`
by connection id.
2022-12-12 11:43:08 +01:00
Antonio Scandurra
456396ca6e Rename connection_lost to answering_connection_lost 2022-12-12 11:43:08 +01:00
Antonio Scandurra
26b5653427 Delete hosted projects from database when connection is lost 2022-12-12 11:43:08 +01:00
Antonio Scandurra
895c365485 Introduce random reconnections in the randomized test 2022-12-12 11:43:08 +01:00
Antonio Scandurra
8fa26bfe18 Fix test_calls_on_multiple_connections after adding room reconnection 2022-12-12 11:43:08 +01:00
Antonio Scandurra
aca3f02590 Re-join room when client temporarily loses connection 2022-12-12 11:43:08 +01:00
Antonio Scandurra
d74fb97158 Remove Executor trait from collab and use an enum instead
This will let us save off the executor and avoid using generics.
2022-12-12 11:43:08 +01:00
Mikayla Maki
5879dcc4e9 Merge pull request #1951 from zed-industries/dock-bugfix
Fix infinite loop in dock position when deserializing
2022-12-09 13:19:44 -08:00
Mikayla Maki
34388a1d31 Updated is_child() to omit self 2022-12-09 12:07:49 -08:00
Mikayla Maki
3a4f8d267a Fix infinite loop in dock position when deserializing 2022-12-09 11:50:24 -08:00
Antonio Scandurra
0366d725ea collab 0.3.1 2022-12-09 08:19:41 +01:00
Antonio Scandurra
8bd7b28056 Merge pull request #1949 from zed-industries/do-not-drop-unregistered
Don't drop `unregistered` column in reconnection support migration
2022-12-09 08:15:53 +01:00
Antonio Scandurra
2697112a8a Don't drop unregistered column in reconnection support migration
We don't use this column anymore because, when a project is unshared, we
simply remove it from the `projects` table. However, this column is expected
in the stable version of the server and the database is shared between stable
and preview. If we dropped it, stable would start throwing errors.
2022-12-09 08:11:18 +01:00
Mikayla Maki
9bd4bc8813 Merge pull request #1940 from zed-industries/terminal-collab-kickoff
WIP - move terminal to project as pre-prep for collaboration
2022-12-08 20:30:32 -08:00
Mikayla Maki
925c9e13bb Remove terminal container view, switch to notify errors 2022-12-08 20:21:00 -08:00
Mikayla Maki
da100a09fb WIP 2022-12-08 20:21:00 -08:00
Mikayla Maki
c42da5c9b9 WIP 2022-12-08 20:21:00 -08:00
Mikayla Maki
2733f91d8c Fix bugs resulting from refactoring the terminal into project and workspace halves 2022-12-08 20:21:00 -08:00
Mikayla Maki
83aefffa38 Rearrange the terminal code to not have a cyclic dependency with the project 2022-12-08 20:21:00 -08:00
Mikayla Maki
1b8763d0cf WIP - move terminal to project as pre-prep for collaboration 2022-12-08 20:21:00 -08:00
Max Brunsfeld
7dde54b052 v0.68.x dev 2022-12-08 15:33:02 -08:00
53 changed files with 2831 additions and 1961 deletions

34
Cargo.lock generated
View File

@@ -1130,7 +1130,7 @@ dependencies = [
[[package]]
name = "collab"
version = "0.3.0"
version = "0.3.3"
dependencies = [
"anyhow",
"async-tungstenite",
@@ -4463,6 +4463,7 @@ dependencies = [
"smol",
"sum_tree",
"tempdir",
"terminal",
"text",
"thiserror",
"toml",
@@ -6259,6 +6260,32 @@ name = "terminal"
version = "0.1.0"
dependencies = [
"alacritty_terminal",
"anyhow",
"db",
"dirs 4.0.0",
"futures 0.3.25",
"gpui",
"itertools",
"lazy_static",
"libc",
"mio-extras",
"ordered-float",
"procinfo",
"rand 0.8.5",
"serde",
"settings",
"shellexpand",
"smallvec",
"smol",
"theme",
"thiserror",
"util",
]
[[package]]
name = "terminal_view"
version = "0.1.0"
dependencies = [
"anyhow",
"client",
"context_menu",
@@ -6281,6 +6308,7 @@ dependencies = [
"shellexpand",
"smallvec",
"smol",
"terminal",
"theme",
"thiserror",
"util",
@@ -8101,7 +8129,7 @@ checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
[[package]]
name = "zed"
version = "0.67.0"
version = "0.68.0"
dependencies = [
"activity_indicator",
"anyhow",
@@ -8166,7 +8194,7 @@ dependencies = [
"smol",
"sum_tree",
"tempdir",
"terminal",
"terminal_view",
"text",
"theme",
"theme_selector",

View File

@@ -28,7 +28,7 @@ impl View for UpdateNotification {
fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
let theme = cx.global::<Settings>().theme.clone();
let theme = &theme.simple_message_notification;
let theme = &theme.update_notification;
let app_name = cx.global::<ReleaseChannel>().display_name();

View File

@@ -94,12 +94,18 @@ impl ActiveCall {
async fn handle_call_canceled(
this: ModelHandle<Self>,
_: TypedEnvelope<proto::CallCanceled>,
envelope: TypedEnvelope<proto::CallCanceled>,
_: Arc<Client>,
mut cx: AsyncAppContext,
) -> Result<()> {
this.update(&mut cx, |this, _| {
*this.incoming_call.0.borrow_mut() = None;
let mut incoming_call = this.incoming_call.0.borrow_mut();
if incoming_call
.as_ref()
.map_or(false, |call| call.room_id == envelope.payload.room_id)
{
incoming_call.take();
}
});
Ok(())
}

View File

@@ -4,7 +4,7 @@ use collections::HashMap;
use gpui::WeakModelHandle;
pub use live_kit_client::Frame;
use project::Project;
use std::sync::Arc;
use std::{fmt, sync::Arc};
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ParticipantLocation {
@@ -36,7 +36,7 @@ pub struct LocalParticipant {
pub active_project: Option<WeakModelHandle<Project>>,
}
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct RemoteParticipant {
pub user: Arc<User>,
pub projects: Vec<proto::ParticipantProject>,
@@ -49,6 +49,12 @@ pub struct RemoteVideoTrack {
pub(crate) live_kit_track: Arc<live_kit_client::RemoteVideoTrack>,
}
impl fmt::Debug for RemoteVideoTrack {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RemoteVideoTrack").finish()
}
}
impl RemoteVideoTrack {
pub fn frames(&self) -> async_broadcast::Receiver<Frame> {
self.live_kit_track.frames()

View File

@@ -5,14 +5,18 @@ use crate::{
use anyhow::{anyhow, Result};
use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
use collections::{BTreeMap, HashSet};
use futures::StreamExt;
use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task};
use futures::{FutureExt, StreamExt};
use gpui::{
AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task, WeakModelHandle,
};
use live_kit_client::{LocalTrackPublication, LocalVideoTrack, RemoteVideoTrackUpdate};
use postage::stream::Stream;
use project::Project;
use std::{mem, sync::Arc};
use std::{mem, sync::Arc, time::Duration};
use util::{post_inc, ResultExt};
pub const RECONNECT_TIMEOUT: Duration = client::RECEIVE_TIMEOUT;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event {
ParticipantLocationChanged {
@@ -46,6 +50,7 @@ pub struct Room {
user_store: ModelHandle<UserStore>,
subscriptions: Vec<client::Subscription>,
pending_room_update: Option<Task<()>>,
maintain_connection: Option<Task<Result<()>>>,
}
impl Entity for Room {
@@ -66,21 +71,6 @@ impl Room {
user_store: ModelHandle<UserStore>,
cx: &mut ModelContext<Self>,
) -> Self {
let mut client_status = client.status();
cx.spawn_weak(|this, mut cx| async move {
let is_connected = client_status
.next()
.await
.map_or(false, |s| s.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() {
if let Some(this) = this.upgrade(&cx) {
let _ = this.update(&mut cx, |this, cx| this.leave(cx));
}
}
})
.detach();
let live_kit_room = if let Some(connection_info) = live_kit_connection_info {
let room = live_kit_client::Room::new();
let mut status = room.status();
@@ -131,6 +121,9 @@ impl Room {
None
};
let maintain_connection =
cx.spawn_weak(|this, cx| Self::maintain_connection(this, client.clone(), cx));
Self {
id,
live_kit: live_kit_room,
@@ -145,6 +138,7 @@ impl Room {
pending_room_update: None,
client,
user_store,
maintain_connection: Some(maintain_connection),
}
}
@@ -241,10 +235,96 @@ impl Room {
self.participant_user_ids.clear();
self.subscriptions.clear();
self.live_kit.take();
self.pending_room_update.take();
self.maintain_connection.take();
self.client.send(proto::LeaveRoom {})?;
Ok(())
}
async fn maintain_connection(
this: WeakModelHandle<Self>,
client: Arc<Client>,
mut cx: AsyncAppContext,
) -> Result<()> {
let mut client_status = client.status();
loop {
let is_connected = client_status
.next()
.await
.map_or(false, |s| s.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() {
let room_id = this
.upgrade(&cx)
.ok_or_else(|| anyhow!("room was dropped"))?
.update(&mut cx, |this, cx| {
this.status = RoomStatus::Rejoining;
cx.notify();
this.id
});
// Wait for client to re-establish a connection to the server.
{
let mut reconnection_timeout = cx.background().timer(RECONNECT_TIMEOUT).fuse();
let client_reconnection = async {
let mut remaining_attempts = 3;
while remaining_attempts > 0 {
if let Some(status) = client_status.next().await {
if status.is_connected() {
let rejoin_room = async {
let response =
client.request(proto::JoinRoom { id: room_id }).await?;
let room_proto =
response.room.ok_or_else(|| anyhow!("invalid room"))?;
this.upgrade(&cx)
.ok_or_else(|| anyhow!("room was dropped"))?
.update(&mut cx, |this, cx| {
this.status = RoomStatus::Online;
this.apply_room_update(room_proto, cx)
})?;
anyhow::Ok(())
};
if rejoin_room.await.is_ok() {
return true;
} else {
remaining_attempts -= 1;
}
}
} else {
return false;
}
}
false
}
.fuse();
futures::pin_mut!(client_reconnection);
futures::select_biased! {
reconnected = client_reconnection => {
if reconnected {
// If we successfully joined the room, go back around the loop
// waiting for future connection status changes.
continue;
}
}
_ = reconnection_timeout => {}
}
}
// 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) {
let _ = this.update(&mut cx, |this, cx| this.leave(cx));
}
return Err(anyhow!(
"can't reconnect to room: client failed to re-establish connection"
));
}
}
}
pub fn id(&self) -> u64 {
self.id
}
@@ -325,9 +405,11 @@ impl Room {
}
if let Some(participants) = remote_participants.log_err() {
let mut participant_peer_ids = HashSet::default();
for (participant, user) in room.participants.into_iter().zip(participants) {
let peer_id = PeerId(participant.peer_id);
this.participant_user_ids.insert(participant.user_id);
participant_peer_ids.insert(peer_id);
let old_projects = this
.remote_participants
@@ -394,8 +476,8 @@ impl Room {
}
}
this.remote_participants.retain(|_, participant| {
if this.participant_user_ids.contains(&participant.user.id) {
this.remote_participants.retain(|peer_id, participant| {
if participant_peer_ids.contains(peer_id) {
true
} else {
for project in &participant.projects {
@@ -477,10 +559,12 @@ impl Room {
{
for participant in self.remote_participants.values() {
assert!(self.participant_user_ids.contains(&participant.user.id));
assert_ne!(participant.user.id, self.client.user_id().unwrap());
}
for participant in &self.pending_participants {
assert!(self.participant_user_ids.contains(&participant.id));
assert_ne!(participant.id, self.client.user_id().unwrap());
}
assert_eq!(
@@ -751,6 +835,7 @@ impl Default for ScreenTrack {
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum RoomStatus {
Online,
Rejoining,
Offline,
}

View File

@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab"
edition = "2021"
name = "collab"
version = "0.3.0"
version = "0.3.3"
[[bin]]
name = "collab"

View File

@@ -1,5 +1,5 @@
CREATE TABLE "users" (
"id" INTEGER PRIMARY KEY,
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"github_login" VARCHAR,
"admin" BOOLEAN,
"email_address" VARCHAR(255) DEFAULT NULL,
@@ -17,14 +17,14 @@ CREATE INDEX "index_users_on_email_address" ON "users" ("email_address");
CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id");
CREATE TABLE "access_tokens" (
"id" INTEGER PRIMARY KEY,
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER REFERENCES users (id),
"hash" VARCHAR(128)
);
CREATE INDEX "index_access_tokens_user_id" ON "access_tokens" ("user_id");
CREATE TABLE "contacts" (
"id" INTEGER PRIMARY KEY,
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id_a" INTEGER REFERENCES users (id) NOT NULL,
"user_id_b" INTEGER REFERENCES users (id) NOT NULL,
"a_to_b" BOOLEAN NOT NULL,
@@ -35,16 +35,17 @@ CREATE UNIQUE INDEX "index_contacts_user_ids" ON "contacts" ("user_id_a", "user_
CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b");
CREATE TABLE "rooms" (
"id" INTEGER PRIMARY KEY,
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"live_kit_room" VARCHAR NOT NULL
);
CREATE TABLE "projects" (
"id" INTEGER PRIMARY KEY,
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER REFERENCES rooms (id) NOT NULL,
"host_user_id" INTEGER REFERENCES users (id) NOT NULL,
"host_connection_id" INTEGER NOT NULL,
"host_connection_epoch" TEXT NOT NULL
"host_connection_epoch" TEXT NOT NULL,
"unregistered" BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX "index_projects_on_host_connection_epoch" ON "projects" ("host_connection_epoch");
@@ -99,7 +100,7 @@ CREATE TABLE "language_servers" (
CREATE INDEX "index_language_servers_on_project_id" ON "language_servers" ("project_id");
CREATE TABLE "project_collaborators" (
"id" INTEGER PRIMARY KEY,
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"connection_id" INTEGER NOT NULL,
"connection_epoch" TEXT NOT NULL,
@@ -110,13 +111,16 @@ CREATE TABLE "project_collaborators" (
CREATE INDEX "index_project_collaborators_on_project_id" ON "project_collaborators" ("project_id");
CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_and_replica_id" ON "project_collaborators" ("project_id", "replica_id");
CREATE INDEX "index_project_collaborators_on_connection_epoch" ON "project_collaborators" ("connection_epoch");
CREATE INDEX "index_project_collaborators_on_connection_id" ON "project_collaborators" ("connection_id");
CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_epoch" ON "project_collaborators" ("project_id", "connection_id", "connection_epoch");
CREATE TABLE "room_participants" (
"id" INTEGER PRIMARY KEY,
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER NOT NULL REFERENCES rooms (id),
"user_id" INTEGER NOT NULL REFERENCES users (id),
"answering_connection_id" INTEGER,
"answering_connection_epoch" TEXT,
"answering_connection_lost" BOOLEAN NOT NULL,
"location_kind" INTEGER,
"location_project_id" INTEGER,
"initial_project_id" INTEGER,
@@ -125,5 +129,8 @@ CREATE TABLE "room_participants" (
"calling_connection_epoch" TEXT NOT NULL
);
CREATE UNIQUE INDEX "index_room_participants_on_user_id" ON "room_participants" ("user_id");
CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id");
CREATE INDEX "index_room_participants_on_answering_connection_epoch" ON "room_participants" ("answering_connection_epoch");
CREATE INDEX "index_room_participants_on_calling_connection_epoch" ON "room_participants" ("calling_connection_epoch");
CREATE INDEX "index_room_participants_on_answering_connection_id" ON "room_participants" ("answering_connection_id");
CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_epoch" ON "room_participants" ("answering_connection_id", "answering_connection_epoch");

View File

@@ -6,8 +6,7 @@ CREATE TABLE IF NOT EXISTS "rooms" (
ALTER TABLE "projects"
ADD "room_id" INTEGER REFERENCES rooms (id),
ADD "host_connection_id" INTEGER,
ADD "host_connection_epoch" UUID,
DROP COLUMN "unregistered";
ADD "host_connection_epoch" UUID;
CREATE INDEX "index_projects_on_host_connection_epoch" ON "projects" ("host_connection_epoch");
CREATE TABLE "worktrees" (

View File

@@ -0,0 +1,7 @@
ALTER TABLE "room_participants"
ADD "answering_connection_lost" BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX "index_project_collaborators_on_connection_id" ON "project_collaborators" ("connection_id");
CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_epoch" ON "project_collaborators" ("project_id", "connection_id", "connection_epoch");
CREATE INDEX "index_room_participants_on_answering_connection_id" ON "room_participants" ("answering_connection_id");
CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_epoch" ON "room_participants" ("answering_connection_id", "answering_connection_epoch");

View File

@@ -0,0 +1 @@
CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id");

View File

@@ -21,6 +21,7 @@ use dashmap::DashMap;
use futures::StreamExt;
use hyper::StatusCode;
use rpc::{proto, ConnectionId};
use sea_orm::Condition;
pub use sea_orm::ConnectOptions;
use sea_orm::{
entity::prelude::*, ActiveValue, ConnectionTrait, DatabaseConnection, DatabaseTransaction,
@@ -47,7 +48,7 @@ pub struct Database {
background: Option<std::sync::Arc<gpui::executor::Background>>,
#[cfg(test)]
runtime: Option<tokio::runtime::Runtime>,
epoch: Uuid,
epoch: parking_lot::RwLock<Uuid>,
}
impl Database {
@@ -60,10 +61,20 @@ impl Database {
background: None,
#[cfg(test)]
runtime: None,
epoch: Uuid::new_v4(),
epoch: parking_lot::RwLock::new(Uuid::new_v4()),
})
}
#[cfg(test)]
pub fn reset(&self) {
self.rooms.clear();
*self.epoch.write() = Uuid::new_v4();
}
fn epoch(&self) -> Uuid {
*self.epoch.read()
}
pub async fn migrate(
&self,
migrations_path: &Path,
@@ -105,34 +116,14 @@ impl Database {
Ok(new_migrations)
}
pub async fn clear_stale_data(&self) -> Result<()> {
pub async fn delete_stale_projects(&self) -> Result<()> {
self.transaction(|tx| async move {
project_collaborator::Entity::delete_many()
.filter(project_collaborator::Column::ConnectionEpoch.ne(self.epoch))
.exec(&*tx)
.await?;
room_participant::Entity::delete_many()
.filter(
room_participant::Column::AnsweringConnectionEpoch
.ne(self.epoch)
.or(room_participant::Column::CallingConnectionEpoch.ne(self.epoch)),
)
.filter(project_collaborator::Column::ConnectionEpoch.ne(self.epoch()))
.exec(&*tx)
.await?;
project::Entity::delete_many()
.filter(project::Column::HostConnectionEpoch.ne(self.epoch))
.exec(&*tx)
.await?;
room::Entity::delete_many()
.filter(
room::Column::Id.not_in_subquery(
Query::select()
.column(room_participant::Column::RoomId)
.from(room_participant::Entity)
.distinct()
.to_owned(),
),
)
.filter(project::Column::HostConnectionEpoch.ne(self.epoch()))
.exec(&*tx)
.await?;
Ok(())
@@ -140,6 +131,74 @@ impl Database {
.await
}
pub async fn outdated_room_ids(&self) -> Result<Vec<RoomId>> {
self.transaction(|tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryAs {
RoomId,
}
Ok(room_participant::Entity::find()
.select_only()
.column(room_participant::Column::RoomId)
.distinct()
.filter(room_participant::Column::AnsweringConnectionEpoch.ne(self.epoch()))
.into_values::<_, QueryAs>()
.all(&*tx)
.await?)
})
.await
}
pub async fn refresh_room(&self, room_id: RoomId) -> Result<RoomGuard<RefreshedRoom>> {
self.room_transaction(|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())
.add(room_participant::Column::AnsweringConnectionEpoch.ne(self.epoch()));
let stale_participant_user_ids = room_participant::Entity::find()
.filter(stale_participant_filter.clone())
.all(&*tx)
.await?
.into_iter()
.map(|participant| participant.user_id)
.collect::<Vec<_>>();
// Delete participants who failed to reconnect.
room_participant::Entity::delete_many()
.filter(stale_participant_filter)
.exec(&*tx)
.await?;
let room = self.get_room(room_id, &tx).await?;
let mut canceled_calls_to_user_ids = Vec::new();
// Delete the room if it becomes empty and cancel pending calls.
if room.participants.is_empty() {
canceled_calls_to_user_ids.extend(
room.pending_participants
.iter()
.map(|pending_participant| UserId::from_proto(pending_participant.user_id)),
);
room_participant::Entity::delete_many()
.filter(room_participant::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,
},
))
})
.await
}
// users
pub async fn create_user(
@@ -1033,10 +1092,11 @@ impl Database {
room_id: ActiveValue::set(room_id),
user_id: ActiveValue::set(user_id),
answering_connection_id: ActiveValue::set(Some(connection_id.0 as i32)),
answering_connection_epoch: ActiveValue::set(Some(self.epoch)),
answering_connection_epoch: ActiveValue::set(Some(self.epoch())),
answering_connection_lost: ActiveValue::set(false),
calling_user_id: ActiveValue::set(user_id),
calling_connection_id: ActiveValue::set(connection_id.0 as i32),
calling_connection_epoch: ActiveValue::set(self.epoch),
calling_connection_epoch: ActiveValue::set(self.epoch()),
..Default::default()
}
.insert(&*tx)
@@ -1060,9 +1120,10 @@ impl Database {
room_participant::ActiveModel {
room_id: ActiveValue::set(room_id),
user_id: ActiveValue::set(called_user_id),
answering_connection_lost: ActiveValue::set(false),
calling_user_id: ActiveValue::set(calling_user_id),
calling_connection_id: ActiveValue::set(calling_connection_id.0 as i32),
calling_connection_epoch: ActiveValue::set(self.epoch),
calling_connection_epoch: ActiveValue::set(self.epoch()),
initial_project_id: ActiveValue::set(initial_project_id),
..Default::default()
}
@@ -1172,14 +1233,23 @@ impl Database {
self.room_transaction(|tx| async move {
let result = room_participant::Entity::update_many()
.filter(
room_participant::Column::RoomId
.eq(room_id)
.and(room_participant::Column::UserId.eq(user_id))
.and(room_participant::Column::AnsweringConnectionId.is_null()),
Condition::all()
.add(room_participant::Column::RoomId.eq(room_id))
.add(room_participant::Column::UserId.eq(user_id))
.add(
Condition::any()
.add(room_participant::Column::AnsweringConnectionId.is_null())
.add(room_participant::Column::AnsweringConnectionLost.eq(true))
.add(
room_participant::Column::AnsweringConnectionEpoch
.ne(self.epoch()),
),
),
)
.set(room_participant::ActiveModel {
answering_connection_id: ActiveValue::set(Some(connection_id.0 as i32)),
answering_connection_epoch: ActiveValue::set(Some(self.epoch)),
answering_connection_epoch: ActiveValue::set(Some(self.epoch())),
answering_connection_lost: ActiveValue::set(false),
..Default::default()
})
.exec(&*tx)
@@ -1197,7 +1267,7 @@ impl Database {
pub async fn leave_room(&self, connection_id: ConnectionId) -> Result<RoomGuard<LeftRoom>> {
self.room_transaction(|tx| async move {
let leaving_participant = room_participant::Entity::find()
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0))
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32))
.one(&*tx)
.await?;
@@ -1240,7 +1310,7 @@ impl Database {
project_collaborator::Column::ProjectId,
QueryProjectIds::ProjectId,
)
.filter(project_collaborator::Column::ConnectionId.eq(connection_id.0))
.filter(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32))
.into_values::<_, QueryProjectIds>()
.all(&*tx)
.await?;
@@ -1277,7 +1347,7 @@ impl Database {
// Leave projects.
project_collaborator::Entity::delete_many()
.filter(project_collaborator::Column::ConnectionId.eq(connection_id.0))
.filter(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32))
.exec(&*tx)
.await?;
@@ -1286,7 +1356,7 @@ impl Database {
.filter(
project::Column::RoomId
.eq(room_id)
.and(project::Column::HostConnectionId.eq(connection_id.0)),
.and(project::Column::HostConnectionId.eq(connection_id.0 as i32)),
)
.exec(&*tx)
.await?;
@@ -1344,11 +1414,9 @@ impl Database {
}
let result = room_participant::Entity::update_many()
.filter(
room_participant::Column::RoomId
.eq(room_id)
.and(room_participant::Column::AnsweringConnectionId.eq(connection_id.0)),
)
.filter(room_participant::Column::RoomId.eq(room_id).and(
room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32),
))
.set(room_participant::ActiveModel {
location_kind: ActiveValue::set(Some(location_kind)),
location_project_id: ActiveValue::set(location_project_id),
@@ -1367,6 +1435,66 @@ impl Database {
.await
}
pub async fn connection_lost(
&self,
connection_id: ConnectionId,
) -> Result<RoomGuard<Vec<LeftProject>>> {
self.room_transaction(|tx| async move {
let participant = room_participant::Entity::find()
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("not a participant in any room"))?;
let room_id = participant.room_id;
room_participant::Entity::update(room_participant::ActiveModel {
answering_connection_lost: ActiveValue::set(true),
..participant.into_active_model()
})
.exec(&*tx)
.await?;
let collaborator_on_projects = project_collaborator::Entity::find()
.find_also_related(project::Entity)
.filter(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32))
.all(&*tx)
.await?;
project_collaborator::Entity::delete_many()
.filter(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32))
.exec(&*tx)
.await?;
let mut left_projects = Vec::new();
for (_, project) in collaborator_on_projects {
if let Some(project) = project {
let collaborators = project
.find_related(project_collaborator::Entity)
.all(&*tx)
.await?;
let connection_ids = collaborators
.into_iter()
.map(|collaborator| ConnectionId(collaborator.connection_id as u32))
.collect();
left_projects.push(LeftProject {
id: project.id,
host_user_id: project.host_user_id,
host_connection_id: ConnectionId(project.host_connection_id as u32),
connection_ids,
});
}
}
project::Entity::delete_many()
.filter(project::Column::HostConnectionId.eq(connection_id.0 as i32))
.exec(&*tx)
.await?;
Ok((room_id, left_projects))
})
.await
}
fn build_incoming_call(
room: &proto::Room,
called_user_id: UserId,
@@ -1514,7 +1642,7 @@ impl Database {
) -> Result<RoomGuard<(ProjectId, proto::Room)>> {
self.room_transaction(|tx| async move {
let participant = room_participant::Entity::find()
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0))
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("could not find participant"))?;
@@ -1526,7 +1654,7 @@ impl Database {
room_id: ActiveValue::set(participant.room_id),
host_user_id: ActiveValue::set(participant.user_id),
host_connection_id: ActiveValue::set(connection_id.0 as i32),
host_connection_epoch: ActiveValue::set(self.epoch),
host_connection_epoch: ActiveValue::set(self.epoch()),
..Default::default()
}
.insert(&*tx)
@@ -1551,7 +1679,7 @@ impl Database {
project_collaborator::ActiveModel {
project_id: ActiveValue::set(project.id),
connection_id: ActiveValue::set(connection_id.0 as i32),
connection_epoch: ActiveValue::set(self.epoch),
connection_epoch: ActiveValue::set(self.epoch()),
user_id: ActiveValue::set(participant.user_id),
replica_id: ActiveValue::set(ReplicaId(0)),
is_host: ActiveValue::set(true),
@@ -1600,7 +1728,7 @@ impl Database {
) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
self.room_transaction(|tx| async move {
let project = project::Entity::find_by_id(project_id)
.filter(project::Column::HostConnectionId.eq(connection_id.0))
.filter(project::Column::HostConnectionId.eq(connection_id.0 as i32))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
@@ -1654,7 +1782,7 @@ impl Database {
// Ensure the update comes from the host.
let project = project::Entity::find_by_id(project_id)
.filter(project::Column::HostConnectionId.eq(connection_id.0))
.filter(project::Column::HostConnectionId.eq(connection_id.0 as i32))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("no such project"))?;
@@ -1837,7 +1965,7 @@ impl Database {
) -> Result<RoomGuard<(Project, ReplicaId)>> {
self.room_transaction(|tx| async move {
let participant = room_participant::Entity::find()
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0))
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32))
.one(&*tx)
.await?
.ok_or_else(|| anyhow!("must join a room first"))?;
@@ -1865,7 +1993,7 @@ impl Database {
let new_collaborator = project_collaborator::ActiveModel {
project_id: ActiveValue::set(project_id),
connection_id: ActiveValue::set(connection_id.0 as i32),
connection_epoch: ActiveValue::set(self.epoch),
connection_epoch: ActiveValue::set(self.epoch()),
user_id: ActiveValue::set(participant.user_id),
replica_id: ActiveValue::set(replica_id),
is_host: ActiveValue::set(false),
@@ -1974,7 +2102,7 @@ impl Database {
.filter(
project_collaborator::Column::ProjectId
.eq(project_id)
.and(project_collaborator::Column::ConnectionId.eq(connection_id.0)),
.and(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32)),
)
.exec(&*tx)
.await?;
@@ -2488,6 +2616,12 @@ pub struct LeftRoom {
pub canceled_calls_to_user_ids: Vec<UserId>,
}
pub struct RefreshedRoom {
pub room: proto::Room,
pub stale_participant_user_ids: Vec<UserId>,
pub canceled_calls_to_user_ids: Vec<UserId>,
}
pub struct Project {
pub collaborators: Vec<project_collaborator::Model>,
pub worktrees: BTreeMap<u64, Worktree>,

View File

@@ -10,6 +10,7 @@ pub struct Model {
pub user_id: UserId,
pub answering_connection_id: Option<i32>,
pub answering_connection_epoch: Option<Uuid>,
pub answering_connection_lost: bool,
pub location_kind: Option<i32>,
pub location_project_id: Option<ProjectId>,
pub initial_project_id: Option<ProjectId>,

View File

@@ -0,0 +1,36 @@
use std::{future::Future, time::Duration};
#[derive(Clone)]
pub enum Executor {
Production,
#[cfg(test)]
Deterministic(std::sync::Arc<gpui::executor::Background>),
}
impl Executor {
pub fn spawn_detached<F>(&self, future: F)
where
F: 'static + Send + Future<Output = ()>,
{
match self {
Executor::Production => {
tokio::spawn(future);
}
#[cfg(test)]
Executor::Deterministic(background) => {
background.spawn(future).detach();
}
}
}
pub fn sleep(&self, duration: Duration) -> impl Future<Output = ()> {
let this = self.clone();
async move {
match this {
Executor::Production => tokio::time::sleep(duration).await,
#[cfg(test)]
Executor::Deterministic(background) => background.timer(duration).await,
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ pub mod api;
pub mod auth;
pub mod db;
pub mod env;
pub mod executor;
#[cfg(test)]
mod integration_tests;
pub mod rpc;

View File

@@ -1,6 +1,6 @@
use anyhow::anyhow;
use axum::{routing::get, Router};
use collab::{db, env, AppState, Config, MigrateConfig, Result};
use collab::{db, env, executor::Executor, AppState, Config, MigrateConfig, Result};
use db::Database;
use std::{
env::args,
@@ -52,12 +52,12 @@ async fn main() -> Result<()> {
init_tracing(&config);
let state = AppState::new(config).await?;
state.db.clear_stale_data().await?;
let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port))
.expect("failed to bind TCP listener");
let rpc_server = collab::rpc::Server::new(state.clone());
let rpc_server = collab::rpc::Server::new(state.clone(), Executor::Production);
rpc_server.start().await?;
let app = collab::api::routes(rpc_server.clone(), state.clone())
.merge(collab::rpc::routes(rpc_server.clone()))

View File

@@ -3,6 +3,7 @@ mod connection_pool;
use crate::{
auth,
db::{self, Database, ProjectId, RoomId, User, UserId},
executor::Executor,
AppState, Result,
};
use anyhow::anyhow;
@@ -52,13 +53,12 @@ use std::{
},
time::Duration,
};
use tokio::{
sync::{Mutex, MutexGuard},
time::Sleep,
};
use tokio::sync::watch;
use tower::ServiceBuilder;
use tracing::{info_span, instrument, Instrument};
pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(60);
lazy_static! {
static ref METRIC_CONNECTIONS: IntGauge =
register_int_gauge!("connections", "number of connections").unwrap();
@@ -90,14 +90,14 @@ impl<R: RequestMessage> Response<R> {
struct Session {
user_id: UserId,
connection_id: ConnectionId,
db: Arc<Mutex<DbHandle>>,
db: Arc<tokio::sync::Mutex<DbHandle>>,
peer: Arc<Peer>,
connection_pool: Arc<Mutex<ConnectionPool>>,
connection_pool: Arc<parking_lot::Mutex<ConnectionPool>>,
live_kit_client: Option<Arc<dyn live_kit_server::api::Client>>,
}
impl Session {
async fn db(&self) -> MutexGuard<DbHandle> {
async fn db(&self) -> tokio::sync::MutexGuard<DbHandle> {
#[cfg(test)]
tokio::task::yield_now().await;
let guard = self.db.lock().await;
@@ -109,9 +109,7 @@ impl Session {
async fn connection_pool(&self) -> ConnectionPoolGuard<'_> {
#[cfg(test)]
tokio::task::yield_now().await;
let guard = self.connection_pool.lock().await;
#[cfg(test)]
tokio::task::yield_now().await;
let guard = self.connection_pool.lock();
ConnectionPoolGuard {
guard,
_not_send: PhantomData,
@@ -140,22 +138,15 @@ impl Deref for DbHandle {
pub struct Server {
peer: Arc<Peer>,
pub(crate) connection_pool: Arc<Mutex<ConnectionPool>>,
pub(crate) connection_pool: Arc<parking_lot::Mutex<ConnectionPool>>,
app_state: Arc<AppState>,
executor: Executor,
handlers: HashMap<TypeId, MessageHandler>,
teardown: watch::Sender<()>,
}
pub trait Executor: Send + Clone {
type Sleep: Send + Future;
fn spawn_detached<F: 'static + Send + Future<Output = ()>>(&self, future: F);
fn sleep(&self, duration: Duration) -> Self::Sleep;
}
#[derive(Clone)]
pub struct RealExecutor;
pub(crate) struct ConnectionPoolGuard<'a> {
guard: MutexGuard<'a, ConnectionPool>,
guard: parking_lot::MutexGuard<'a, ConnectionPool>,
_not_send: PhantomData<Rc<()>>,
}
@@ -176,12 +167,14 @@ where
}
impl Server {
pub fn new(app_state: Arc<AppState>) -> Arc<Self> {
pub fn new(app_state: Arc<AppState>, executor: Executor) -> Arc<Self> {
let mut server = Self {
peer: Peer::new(),
app_state,
executor,
connection_pool: Default::default(),
handlers: Default::default(),
teardown: watch::channel(()).0,
};
server
@@ -244,6 +237,99 @@ impl Server {
Arc::new(server)
}
pub async fn start(&self) -> Result<()> {
self.app_state.db.delete_stale_projects().await?;
let db = self.app_state.db.clone();
let peer = self.peer.clone();
let timeout = self.executor.sleep(RECONNECT_TIMEOUT);
let pool = self.connection_pool.clone();
let live_kit_client = self.app_state.live_kit_client.clone();
self.executor.spawn_detached(async move {
timeout.await;
if let Some(room_ids) = db.outdated_room_ids().await.trace_err() {
for room_id in room_ids {
let mut contacts_to_update = HashSet::default();
let mut canceled_calls_to_user_ids = Vec::new();
let mut live_kit_room = String::new();
let mut delete_live_kit_room = false;
if let Ok(mut refreshed_room) = db.refresh_room(room_id).await {
room_updated(&refreshed_room.room, &peer);
contacts_to_update
.extend(refreshed_room.stale_participant_user_ids.iter().copied());
contacts_to_update
.extend(refreshed_room.canceled_calls_to_user_ids.iter().copied());
canceled_calls_to_user_ids =
mem::take(&mut refreshed_room.canceled_calls_to_user_ids);
live_kit_room = mem::take(&mut refreshed_room.room.live_kit_room);
delete_live_kit_room = refreshed_room.room.participants.is_empty();
}
{
let pool = pool.lock();
for canceled_user_id in canceled_calls_to_user_ids {
for connection_id in pool.user_connection_ids(canceled_user_id) {
peer.send(
connection_id,
proto::CallCanceled {
room_id: room_id.to_proto(),
},
)
.trace_err();
}
}
}
for user_id in contacts_to_update {
let busy = db.is_user_busy(user_id).await.trace_err();
let contacts = db.get_contacts(user_id).await.trace_err();
if let Some((busy, contacts)) = busy.zip(contacts) {
let pool = pool.lock();
let updated_contact = contact_for_user(user_id, false, busy, &pool);
for contact in contacts {
if let db::Contact::Accepted {
user_id: contact_user_id,
..
} = contact
{
for contact_conn_id in pool.user_connection_ids(contact_user_id)
{
peer.send(
contact_conn_id,
proto::UpdateContacts {
contacts: vec![updated_contact.clone()],
remove_contacts: Default::default(),
incoming_requests: Default::default(),
remove_incoming_requests: Default::default(),
outgoing_requests: Default::default(),
remove_outgoing_requests: Default::default(),
},
)
.trace_err();
}
}
}
}
}
if let Some(live_kit) = live_kit_client.as_ref() {
if delete_live_kit_room {
live_kit.delete_room(live_kit_room).await.trace_err();
}
}
}
}
});
Ok(())
}
#[cfg(test)]
pub fn teardown(&self) {
self.peer.reset();
self.connection_pool.lock().reset();
let _ = self.teardown.send(());
}
fn add_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
where
F: 'static + Send + Sync + Fn(TypedEnvelope<M>, Session) -> Fut,
@@ -330,29 +416,25 @@ impl Server {
})
}
pub fn handle_connection<E: Executor>(
pub fn handle_connection(
self: &Arc<Self>,
connection: Connection,
address: String,
user: User,
mut send_connection_id: Option<oneshot::Sender<ConnectionId>>,
executor: E,
executor: Executor,
) -> impl Future<Output = Result<()>> {
let this = self.clone();
let user_id = user.id;
let login = user.github_login;
let span = info_span!("handle connection", %user_id, %login, %address);
let mut teardown = self.teardown.subscribe();
async move {
let (connection_id, handle_io, mut incoming_rx) = this
.peer
.add_connection(connection, {
let executor = executor.clone();
move |duration| {
let timer = executor.sleep(duration);
async move {
timer.await;
}
}
move |duration| executor.sleep(duration)
});
tracing::info!(%user_id, %login, %connection_id, %address, "connection opened");
@@ -374,7 +456,7 @@ impl Server {
).await?;
{
let mut pool = this.connection_pool.lock().await;
let mut pool = this.connection_pool.lock();
pool.add_connection(connection_id, user_id, user.admin);
this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?;
@@ -393,7 +475,7 @@ impl Server {
let session = Session {
user_id,
connection_id,
db: Arc::new(Mutex::new(DbHandle(this.app_state.db.clone()))),
db: Arc::new(tokio::sync::Mutex::new(DbHandle(this.app_state.db.clone()))),
peer: this.peer.clone(),
connection_pool: this.connection_pool.clone(),
live_kit_client: this.app_state.live_kit_client.clone()
@@ -416,6 +498,7 @@ impl Server {
let next_message = incoming_rx.next().fuse();
futures::pin_mut!(next_message);
futures::select_biased! {
_ = teardown.changed().fuse() => return Ok(()),
result = handle_io => {
if let Err(error) = result {
tracing::error!(?error, %user_id, %login, %connection_id, %address, "error handling I/O");
@@ -452,7 +535,7 @@ impl Server {
drop(foreground_message_handlers);
tracing::info!(%user_id, %login, %connection_id, %address, "signing out");
if let Err(error) = sign_out(session).await {
if let Err(error) = sign_out(session, teardown, executor).await {
tracing::error!(%user_id, %login, %connection_id, %address, ?error, "error signing out");
}
@@ -467,7 +550,7 @@ impl Server {
) -> Result<()> {
if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? {
if let Some(code) = &user.invite_code {
let pool = self.connection_pool.lock().await;
let pool = self.connection_pool.lock();
let invitee_contact = contact_for_user(invitee_id, true, false, &pool);
for connection_id in pool.user_connection_ids(inviter_id) {
self.peer.send(
@@ -493,7 +576,7 @@ impl Server {
pub async fn invite_count_updated(self: &Arc<Self>, user_id: UserId) -> Result<()> {
if let Some(user) = self.app_state.db.get_user_by_id(user_id).await? {
if let Some(invite_code) = &user.invite_code {
let pool = self.connection_pool.lock().await;
let pool = self.connection_pool.lock();
for connection_id in pool.user_connection_ids(user_id) {
self.peer.send(
connection_id,
@@ -514,7 +597,7 @@ impl Server {
pub async fn snapshot<'a>(self: &'a Arc<Self>) -> ServerSnapshot<'a> {
ServerSnapshot {
connection_pool: ConnectionPoolGuard {
guard: self.connection_pool.lock().await,
guard: self.connection_pool.lock(),
_not_send: PhantomData,
},
peer: &self.peer,
@@ -543,18 +626,6 @@ impl<'a> Drop for ConnectionPoolGuard<'a> {
}
}
impl Executor for RealExecutor {
type Sleep = Sleep;
fn spawn_detached<F: 'static + Send + Future<Output = ()>>(&self, future: F) {
tokio::task::spawn(future);
}
fn sleep(&self, duration: Duration) -> Self::Sleep {
tokio::time::sleep(duration)
}
}
fn broadcast<F>(
sender_id: ConnectionId,
receiver_ids: impl IntoIterator<Item = ConnectionId>,
@@ -636,7 +707,7 @@ pub async fn handle_websocket_request(
let connection = Connection::new(Box::pin(socket));
async move {
server
.handle_connection(connection, socket_address, user, None, RealExecutor)
.handle_connection(connection, socket_address, user, None, Executor::Production)
.await
.log_err();
}
@@ -647,7 +718,6 @@ pub async fn handle_metrics(Extension(server): Extension<Arc<Server>>) -> Result
let connections = server
.connection_pool
.lock()
.await
.connections()
.filter(|connection| !connection.admin)
.count();
@@ -665,30 +735,48 @@ pub async fn handle_metrics(Extension(server): Extension<Arc<Server>>) -> Result
Ok(encoded_metrics)
}
#[instrument(err)]
async fn sign_out(session: Session) -> Result<()> {
#[instrument(err, skip(executor))]
async fn sign_out(
session: Session,
mut teardown: watch::Receiver<()>,
executor: Executor,
) -> Result<()> {
session.peer.disconnect(session.connection_id);
let decline_calls = {
let mut pool = session.connection_pool().await;
pool.remove_connection(session.connection_id)?;
let mut connections = pool.user_connection_ids(session.user_id);
connections.next().is_none()
};
session
.connection_pool()
.await
.remove_connection(session.connection_id)?;
leave_room_for_session(&session).await.trace_err();
if decline_calls {
if let Some(room) = session
.db()
.await
.decline_call(None, session.user_id)
.await
.trace_err()
{
room_updated(&room, &session);
if let Some(mut left_projects) = session
.db()
.await
.connection_lost(session.connection_id)
.await
.trace_err()
{
for left_project in mem::take(&mut *left_projects) {
project_left(&left_project, &session);
}
}
update_user_contacts(session.user_id, &session).await?;
futures::select_biased! {
_ = executor.sleep(RECONNECT_TIMEOUT).fuse() => {
leave_room_for_session(&session).await.trace_err();
if !session
.connection_pool()
.await
.is_user_online(session.user_id)
{
let db = session.db().await;
if let Some(room) = db.decline_call(None, session.user_id).await.trace_err() {
room_updated(&room, &session.peer);
}
}
update_user_contacts(session.user_id, &session).await?;
}
_ = teardown.changed().fuse() => {}
}
Ok(())
}
@@ -750,17 +838,14 @@ async fn join_room(
response: Response<proto::JoinRoom>,
session: Session,
) -> Result<()> {
let room_id = RoomId::from_proto(request.id);
let room = {
let room = session
.db()
.await
.join_room(
RoomId::from_proto(request.id),
session.user_id,
session.connection_id,
)
.join_room(room_id, session.user_id, session.connection_id)
.await?;
room_updated(&room, &session);
room_updated(&room, &session.peer);
room.clone()
};
@@ -771,7 +856,12 @@ async fn join_room(
{
session
.peer
.send(connection_id, proto::CallCanceled {})
.send(
connection_id,
proto::CallCanceled {
room_id: room_id.to_proto(),
},
)
.trace_err();
}
@@ -835,7 +925,7 @@ async fn call(
initial_project_id,
)
.await?;
room_updated(&room, &session);
room_updated(&room, &session.peer);
mem::take(incoming_call)
};
update_user_contacts(called_user_id, &session).await?;
@@ -865,7 +955,7 @@ async fn call(
.await
.call_failed(room_id, called_user_id)
.await?;
room_updated(&room, &session);
room_updated(&room, &session.peer);
}
update_user_contacts(called_user_id, &session).await?;
@@ -885,7 +975,7 @@ async fn cancel_call(
.await
.cancel_call(Some(room_id), session.connection_id, called_user_id)
.await?;
room_updated(&room, &session);
room_updated(&room, &session.peer);
}
for connection_id in session
@@ -895,7 +985,12 @@ async fn cancel_call(
{
session
.peer
.send(connection_id, proto::CallCanceled {})
.send(
connection_id,
proto::CallCanceled {
room_id: room_id.to_proto(),
},
)
.trace_err();
}
response.send(proto::Ack {})?;
@@ -912,7 +1007,7 @@ async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<(
.await
.decline_call(Some(room_id), session.user_id)
.await?;
room_updated(&room, &session);
room_updated(&room, &session.peer);
}
for connection_id in session
@@ -922,7 +1017,12 @@ async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<(
{
session
.peer
.send(connection_id, proto::CallCanceled {})
.send(
connection_id,
proto::CallCanceled {
room_id: room_id.to_proto(),
},
)
.trace_err();
}
update_user_contacts(session.user_id, &session).await?;
@@ -943,7 +1043,7 @@ async fn update_participant_location(
.await
.update_room_participant_location(room_id, session.connection_id, location)
.await?;
room_updated(&room, &session);
room_updated(&room, &session.peer);
response.send(proto::Ack {})?;
Ok(())
}
@@ -965,7 +1065,7 @@ async fn share_project(
response.send(proto::ShareProjectResponse {
project_id: project_id.to_proto(),
})?;
room_updated(&room, &session);
room_updated(&room, &session.peer);
Ok(())
}
@@ -984,7 +1084,7 @@ async fn unshare_project(message: proto::UnshareProject, session: Session) -> Re
guest_connection_ids.iter().copied(),
|conn_id| session.peer.send(conn_id, message.clone()),
);
room_updated(&room, &session);
room_updated(&room, &session.peer);
Ok(())
}
@@ -1118,20 +1218,7 @@ async fn leave_project(request: proto::LeaveProject, session: Session) -> Result
host_connection_id = %project.host_connection_id,
"leave project"
);
broadcast(
sender_id,
project.connection_ids.iter().copied(),
|conn_id| {
session.peer.send(
conn_id,
proto::RemoveProjectCollaborator {
project_id: project_id.to_proto(),
peer_id: sender_id.0,
},
)
},
);
project_left(&project, &session);
Ok(())
}
@@ -1156,7 +1243,7 @@ async fn update_project(
.forward_send(session.connection_id, connection_id, request.clone())
},
);
room_updated(&room, &session);
room_updated(&room, &session.peer);
response.send(proto::Ack {})?;
Ok(())
@@ -1803,17 +1890,15 @@ fn contact_for_user(
}
}
fn room_updated(room: &proto::Room, session: &Session) {
fn room_updated(room: &proto::Room, peer: &Peer) {
for participant in &room.participants {
session
.peer
.send(
ConnectionId(participant.peer_id),
proto::RoomUpdated {
room: Some(room.clone()),
},
)
.trace_err();
peer.send(
ConnectionId(participant.peer_id),
proto::RoomUpdated {
room: Some(room.clone()),
},
)
.trace_err();
}
}
@@ -1854,6 +1939,7 @@ async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()>
async fn leave_room_for_session(session: &Session) -> Result<()> {
let mut contacts_to_update = HashSet::default();
let room_id;
let canceled_calls_to_user_ids;
let live_kit_room;
let delete_live_kit_room;
@@ -1862,43 +1948,11 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
contacts_to_update.insert(session.user_id);
for project in left_room.left_projects.values() {
for connection_id in &project.connection_ids {
if project.host_user_id == session.user_id {
session
.peer
.send(
*connection_id,
proto::UnshareProject {
project_id: project.id.to_proto(),
},
)
.trace_err();
} else {
session
.peer
.send(
*connection_id,
proto::RemoveProjectCollaborator {
project_id: project.id.to_proto(),
peer_id: session.connection_id.0,
},
)
.trace_err();
}
}
session
.peer
.send(
session.connection_id,
proto::UnshareProject {
project_id: project.id.to_proto(),
},
)
.trace_err();
project_left(project, session);
}
room_updated(&left_room.room, &session);
room_updated(&left_room.room, &session.peer);
room_id = RoomId::from_proto(left_room.room.id);
canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids);
live_kit_room = mem::take(&mut left_room.room.live_kit_room);
delete_live_kit_room = left_room.room.participants.is_empty();
@@ -1910,7 +1964,12 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
for connection_id in pool.user_connection_ids(canceled_user_id) {
session
.peer
.send(connection_id, proto::CallCanceled {})
.send(
connection_id,
proto::CallCanceled {
room_id: room_id.to_proto(),
},
)
.trace_err();
}
contacts_to_update.insert(canceled_user_id);
@@ -1935,6 +1994,43 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
Ok(())
}
fn project_left(project: &db::LeftProject, session: &Session) {
for connection_id in &project.connection_ids {
if project.host_user_id == session.user_id {
session
.peer
.send(
*connection_id,
proto::UnshareProject {
project_id: project.id.to_proto(),
},
)
.trace_err();
} else {
session
.peer
.send(
*connection_id,
proto::RemoveProjectCollaborator {
project_id: project.id.to_proto(),
peer_id: session.connection_id.0,
},
)
.trace_err();
}
}
session
.peer
.send(
session.connection_id,
proto::UnshareProject {
project_id: project.id.to_proto(),
},
)
.trace_err();
}
pub trait ResultExt {
type Ok;

View File

@@ -23,6 +23,11 @@ pub struct Connection {
}
impl ConnectionPool {
pub fn reset(&mut self) {
self.connections.clear();
self.connected_users.clear();
}
#[instrument(skip(self))]
pub fn add_connection(&mut self, connection_id: ConnectionId, user_id: UserId, admin: bool) {
self.connections

View File

@@ -54,7 +54,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
Default::default(),
0,
project,
app_state.default_item_factory,
app_state.dock_default_item_factory,
cx,
);
(app_state.initialize_workspace)(&mut workspace, &app_state, cx);

View File

@@ -199,10 +199,10 @@ macro_rules! query {
use $crate::anyhow::Context;
self.write(|connection| {
self.write(move |connection| {
let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
connection.select_row_bound::<($($arg_type),+), $return_type>(indoc! { $sql })?(($($arg),+))
connection.select_row_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
.context(::std::format!(
"Error in {}, select_row_bound failed to execute or parse for: {}",
::std::stringify!($id),

View File

@@ -139,9 +139,7 @@ impl<V: View> DragAndDrop<V> {
region_offset,
region,
}) => {
if (dbg!(event.position) - (dbg!(region.origin() + region_offset))).length()
> DEAD_ZONE
{
if (event.position - (region.origin() + region_offset)).length() > DEAD_ZONE {
this.currently_dragged = Some(State::Dragging {
window_id,
region_offset,

View File

@@ -184,7 +184,6 @@ actions!(
Paste,
Undo,
Redo,
NextScreen,
MoveUp,
PageUp,
MoveDown,
@@ -2422,7 +2421,7 @@ impl Editor {
let all_edits_within_excerpt = buffer.read_with(&cx, |buffer, _| {
let excerpt_range = excerpt_range.to_offset(buffer);
buffer
.edited_ranges_for_transaction(transaction)
.edited_ranges_for_transaction::<usize>(transaction)
.all(|range| {
excerpt_range.start <= range.start
&& excerpt_range.end >= range.end

View File

@@ -4983,9 +4983,11 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
|cx| build_editor(buffer.clone(), cx),
);
let is_still_following = Rc::new(RefCell::new(true));
let pending_update = Rc::new(RefCell::new(None));
follower.update(cx, {
let update = pending_update.clone();
let is_still_following = is_still_following.clone();
|_, cx| {
cx.subscribe(&leader, move |_, leader, event, cx| {
leader
@@ -4993,6 +4995,13 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
.add_event_to_update_proto(event, &mut *update.borrow_mut(), cx);
})
.detach();
cx.subscribe(&follower, move |_, _, event, cx| {
if Editor::should_unfollow_on_event(event, cx) {
*is_still_following.borrow_mut() = false;
}
})
.detach();
}
});
@@ -5006,6 +5015,7 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
.unwrap();
});
assert_eq!(follower.read(cx).selections.ranges(cx), vec![1..1]);
assert_eq!(*is_still_following.borrow(), true);
// Update the scroll position only
leader.update(cx, |leader, cx| {
@@ -5020,6 +5030,7 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
follower.update(cx, |follower, cx| follower.scroll_position(cx)),
vec2f(1.5, 3.5)
);
assert_eq!(*is_still_following.borrow(), true);
// Update the selections and scroll position
leader.update(cx, |leader, cx| {
@@ -5036,6 +5047,7 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
assert!(follower.scroll_manager.has_autoscroll_request());
});
assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0]);
assert_eq!(*is_still_following.borrow(), true);
// Creating a pending selection that precedes another selection
leader.update(cx, |leader, cx| {
@@ -5048,6 +5060,7 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
.unwrap();
});
assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0, 1..1]);
assert_eq!(*is_still_following.borrow(), true);
// Extend the pending selection so that it surrounds another selection
leader.update(cx, |leader, cx| {
@@ -5059,6 +5072,19 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
.unwrap();
});
assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..2]);
// Scrolling locally breaks the follow
follower.update(cx, |follower, cx| {
let top_anchor = follower.buffer().read(cx).read(cx).anchor_after(0);
follower.set_scroll_anchor(
ScrollAnchor {
top_anchor,
offset: vec2f(0.0, 0.5),
},
cx,
);
});
assert_eq!(*is_still_following.borrow(), false);
}
#[test]

View File

@@ -88,7 +88,7 @@ impl FollowableItem for Editor {
}
if let Some(anchor) = state.scroll_top_anchor {
editor.set_scroll_anchor_internal(
editor.set_scroll_anchor_remote(
ScrollAnchor {
top_anchor: Anchor {
buffer_id: Some(state.buffer_id as usize),
@@ -98,7 +98,6 @@ impl FollowableItem for Editor {
},
offset: vec2f(state.scroll_x, state.scroll_y),
},
false,
cx,
);
}
@@ -213,7 +212,7 @@ impl FollowableItem for Editor {
self.set_selections_from_remote(selections, cx);
self.request_autoscroll_remotely(Autoscroll::newest(), cx);
} else if let Some(anchor) = message.scroll_top_anchor {
self.set_scroll_anchor(
self.set_scroll_anchor_remote(
ScrollAnchor {
top_anchor: Anchor {
buffer_id: Some(buffer_id),

View File

@@ -284,17 +284,17 @@ impl Editor {
}
pub fn set_scroll_anchor(&mut self, scroll_anchor: ScrollAnchor, cx: &mut ViewContext<Self>) {
self.set_scroll_anchor_internal(scroll_anchor, true, cx);
hide_hover(self, cx);
self.scroll_manager.set_anchor(scroll_anchor, true, cx);
}
pub(crate) fn set_scroll_anchor_internal(
pub(crate) fn set_scroll_anchor_remote(
&mut self,
scroll_anchor: ScrollAnchor,
local: bool,
cx: &mut ViewContext<Self>,
) {
hide_hover(self, cx);
self.scroll_manager.set_anchor(scroll_anchor, local, cx);
self.scroll_manager.set_anchor(scroll_anchor, false, cx);
}
pub fn scroll_screen(&mut self, amount: &ScrollAmount, cx: &mut ViewContext<Self>) {

View File

@@ -64,15 +64,15 @@ impl Editor {
return None;
}
self.context_menu.as_mut()?;
if self.mouse_context_menu.read(cx).visible() {
return None;
}
if matches!(self.mode, EditorMode::SingleLine) {
cx.propagate_action();
return None;
}
self.request_autoscroll(Autoscroll::Next, cx);
Some(())
}

View File

@@ -1431,8 +1431,8 @@ impl MutableAppContext {
true
}
// Returns an iterator over all of the view ids from the passed view up to the root of the window
// Includes the passed view itself
/// Returns an iterator over all of the view ids from the passed view up to the root of the window
/// Includes the passed view itself
fn ancestors(&self, window_id: usize, mut view_id: usize) -> impl Iterator<Item = usize> + '_ {
std::iter::once(view_id)
.into_iter()
@@ -3695,6 +3695,7 @@ impl<'a, T: View> ViewContext<'a, T> {
return false;
}
self.ancestors(view.window_id, view.view_id)
.skip(1) // Skip self id
.any(|parent| parent == self.view_id)
}

View File

@@ -32,6 +32,7 @@ lsp = { path = "../lsp" }
rpc = { path = "../rpc" }
settings = { path = "../settings" }
sum_tree = { path = "../sum_tree" }
terminal = { path = "../terminal" }
util = { path = "../util" }
aho-corasick = "0.7"
anyhow = "1.0.57"

View File

@@ -62,6 +62,7 @@ use std::{
},
time::Instant,
};
use terminal::{Terminal, TerminalBuilder};
use thiserror::Error;
use util::{defer, post_inc, ResultExt, TryFutureExt as _};
@@ -1193,6 +1194,34 @@ impl Project {
!self.is_local()
}
pub fn create_terminal(
&mut self,
working_directory: Option<PathBuf>,
window_id: usize,
cx: &mut ModelContext<Self>,
) -> Result<ModelHandle<Terminal>> {
if self.is_remote() {
return Err(anyhow!(
"creating terminals as a guest is not supported yet"
));
} else {
let settings = cx.global::<Settings>();
let shell = settings.terminal_shell();
let envs = settings.terminal_env();
let scroll = settings.terminal_scroll();
TerminalBuilder::new(
working_directory.clone(),
shell,
envs,
settings.terminal_overrides.blinking.clone(),
scroll,
window_id,
)
.map(|builder| cx.add_model(|cx| builder.subscribe(cx)))
}
}
pub fn create_buffer(
&mut self,
text: &str,

View File

@@ -212,7 +212,9 @@ message IncomingCall {
optional ParticipantProject initial_project = 4;
}
message CallCanceled {}
message CallCanceled {
uint64 room_id = 1;
}
message CancelCall {
uint64 room_id = 1;

View File

@@ -6,4 +6,4 @@ pub use conn::Connection;
pub use peer::*;
mod macros;
pub const PROTOCOL_VERSION: u32 = 40;
pub const PROTOCOL_VERSION: u32 = 41;

View File

@@ -199,7 +199,7 @@ impl Default for Shell {
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AlternateScroll {
On,
@@ -221,6 +221,12 @@ pub enum WorkingDirectory {
Always { directory: String },
}
impl Default for WorkingDirectory {
fn default() -> Self {
Self::CurrentProjectDirectory
}
}
#[derive(PartialEq, Eq, Debug, Default, Copy, Clone, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum DockAnchor {
@@ -473,6 +479,32 @@ impl Settings {
})
}
fn terminal_setting<F, R: Default + Clone>(&self, f: F) -> R
where
F: Fn(&TerminalSettings) -> Option<&R>,
{
f(&self.terminal_overrides)
.or_else(|| f(&self.terminal_defaults))
.cloned()
.unwrap_or_else(|| R::default())
}
pub fn terminal_scroll(&self) -> AlternateScroll {
self.terminal_setting(|terminal_setting| terminal_setting.alternate_scroll.as_ref())
}
pub fn terminal_shell(&self) -> Shell {
self.terminal_setting(|terminal_setting| terminal_setting.shell.as_ref())
}
pub fn terminal_env(&self) -> HashMap<String, String> {
self.terminal_setting(|terminal_setting| terminal_setting.env.as_ref())
}
pub fn terminal_strategy(&self) -> WorkingDirectory {
self.terminal_setting(|terminal_setting| terminal_setting.working_directory.as_ref())
}
#[cfg(any(test, feature = "test-support"))]
pub fn test(cx: &gpui::AppContext) -> Settings {
Settings {

View File

@@ -7,17 +7,13 @@ edition = "2021"
path = "src/terminal.rs"
doctest = false
[dependencies]
context_menu = { path = "../context_menu" }
editor = { path = "../editor" }
language = { path = "../language" }
gpui = { path = "../gpui" }
project = { path = "../project" }
settings = { path = "../settings" }
db = { path = "../db" }
theme = { path = "../theme" }
util = { path = "../util" }
workspace = { path = "../workspace" }
db = { path = "../db" }
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "a51dbe25d67e84d6ed4261e640d3954fbdd9be45" }
procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false }
smallvec = { version = "1.6", features = ["union"] }
@@ -34,11 +30,5 @@ thiserror = "1.0"
lazy_static = "1.4.0"
serde = { version = "1.0", features = ["derive"] }
[dev-dependencies]
gpui = { path = "../gpui", features = ["test-support"] }
client = { path = "../client", features = ["test-support"]}
project = { path = "../project", features = ["test-support"]}
workspace = { path = "../workspace", features = ["test-support"] }
rand = "0.8.5"

View File

@@ -1,8 +1,5 @@
pub mod mappings;
mod persistence;
pub mod terminal_container_view;
pub mod terminal_element;
pub mod terminal_view;
pub use alacritty_terminal;
use alacritty_terminal::{
ansi::{ClearMode, Handler},
@@ -33,11 +30,9 @@ use mappings::mouse::{
alt_scroll, grid_point, mouse_button_report, mouse_moved_report, mouse_side, scroll_report,
};
use persistence::TERMINAL_CONNECTION;
use procinfo::LocalProcessInfo;
use settings::{AlternateScroll, Settings, Shell, TerminalBlink};
use util::ResultExt;
use workspace::{ItemId, WorkspaceId};
use std::{
cmp::min,
@@ -57,8 +52,7 @@ use gpui::{
geometry::vector::{vec2f, Vector2F},
keymap::Keystroke,
scene::{MouseDown, MouseDrag, MouseScrollWheel, MouseUp},
AppContext, ClipboardItem, Entity, ModelContext, MouseButton, MouseMovedEvent,
MutableAppContext, Task,
ClipboardItem, Entity, ModelContext, MouseButton, MouseMovedEvent, Task,
};
use crate::mappings::{
@@ -67,12 +61,6 @@ use crate::mappings::{
};
use lazy_static::lazy_static;
///Initialize and register all of our action handlers
pub fn init(cx: &mut MutableAppContext) {
terminal_view::init(cx);
terminal_container_view::init(cx);
}
///Scrolling is unbearably sluggish by default. Alacritty supports a configurable
///Scroll multiplier that is set to 3 by default. This will be removed when I
///Implement scroll bars.
@@ -128,10 +116,10 @@ impl EventListener for ZedListener {
#[derive(Clone, Copy, Debug)]
pub struct TerminalSize {
cell_width: f32,
line_height: f32,
height: f32,
width: f32,
pub cell_width: f32,
pub line_height: f32,
pub height: f32,
pub width: f32,
}
impl TerminalSize {
@@ -210,7 +198,7 @@ impl Dimensions for TerminalSize {
#[derive(Error, Debug)]
pub struct TerminalError {
pub directory: Option<PathBuf>,
pub shell: Option<Shell>,
pub shell: Shell,
pub source: std::io::Error,
}
@@ -238,24 +226,20 @@ impl TerminalError {
})
}
pub fn shell_to_string(&self) -> Option<String> {
self.shell.as_ref().map(|shell| match shell {
pub fn shell_to_string(&self) -> String {
match &self.shell {
Shell::System => "<system shell>".to_string(),
Shell::Program(p) => p.to_string(),
Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
})
}
}
pub fn fmt_shell(&self) -> String {
self.shell
.clone()
.map(|shell| match shell {
Shell::System => "<system defined shell>".to_string(),
Shell::Program(s) => s,
Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
})
.unwrap_or_else(|| "<none specified, using system defined shell>".to_string())
match &self.shell {
Shell::System => "<system defined shell>".to_string(),
Shell::Program(s) => s.to_string(),
Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
}
}
}
@@ -280,20 +264,18 @@ pub struct TerminalBuilder {
impl TerminalBuilder {
pub fn new(
working_directory: Option<PathBuf>,
shell: Option<Shell>,
env: Option<HashMap<String, String>>,
shell: Shell,
mut env: HashMap<String, String>,
blink_settings: Option<TerminalBlink>,
alternate_scroll: &AlternateScroll,
alternate_scroll: AlternateScroll,
window_id: usize,
item_id: ItemId,
workspace_id: WorkspaceId,
) -> Result<TerminalBuilder> {
let pty_config = {
let alac_shell = shell.clone().and_then(|shell| match shell {
let alac_shell = match shell.clone() {
Shell::System => None,
Shell::Program(program) => Some(Program::Just(program)),
Shell::WithArguments { program, args } => Some(Program::WithArgs { program, args }),
});
};
PtyConfig {
shell: alac_shell,
@@ -302,10 +284,9 @@ impl TerminalBuilder {
}
};
let mut env = env.unwrap_or_default();
//TODO: Properly set the current locale,
env.insert("LC_ALL".to_string(), "en_US.UTF-8".to_string());
env.insert("ZED_TERM".to_string(), true.to_string());
let alac_scrolling = Scrolling::default();
// alac_scrolling.set_history((BACK_BUFFER_SIZE * 2) as u32);
@@ -391,8 +372,6 @@ impl TerminalBuilder {
last_mouse_position: None,
next_link_id: 0,
selection_phase: SelectionPhase::Ended,
workspace_id,
item_id,
};
Ok(TerminalBuilder {
@@ -464,9 +443,9 @@ impl TerminalBuilder {
}
#[derive(Debug, Clone)]
struct IndexedCell {
point: Point,
cell: Cell,
pub struct IndexedCell {
pub point: Point,
pub cell: Cell,
}
impl Deref for IndexedCell {
@@ -478,17 +457,18 @@ impl Deref for IndexedCell {
}
}
// TODO: Un-pub
#[derive(Clone)]
pub struct TerminalContent {
cells: Vec<IndexedCell>,
mode: TermMode,
display_offset: usize,
selection_text: Option<String>,
selection: Option<SelectionRange>,
cursor: RenderableCursor,
cursor_char: char,
size: TerminalSize,
last_hovered_hyperlink: Option<(String, RangeInclusive<Point>, usize)>,
pub cells: Vec<IndexedCell>,
pub mode: TermMode,
pub display_offset: usize,
pub selection_text: Option<String>,
pub selection: Option<SelectionRange>,
pub cursor: RenderableCursor,
pub cursor_char: char,
pub size: TerminalSize,
pub last_hovered_hyperlink: Option<(String, RangeInclusive<Point>, usize)>,
}
impl Default for TerminalContent {
@@ -525,19 +505,17 @@ pub struct Terminal {
/// This is only used for terminal hyperlink checking
last_mouse_position: Option<Vector2F>,
pub matches: Vec<RangeInclusive<Point>>,
last_content: TerminalContent,
pub last_content: TerminalContent,
last_synced: Instant,
sync_task: Option<Task<()>>,
selection_head: Option<Point>,
breadcrumb_text: String,
pub selection_head: Option<Point>,
pub breadcrumb_text: String,
shell_pid: u32,
shell_fd: u32,
foreground_process_info: Option<LocalProcessInfo>,
pub foreground_process_info: Option<LocalProcessInfo>,
scroll_px: f32,
next_link_id: usize,
selection_phase: SelectionPhase,
workspace_id: WorkspaceId,
item_id: ItemId,
}
impl Terminal {
@@ -578,20 +556,6 @@ impl Terminal {
if self.update_process_info() {
cx.emit(Event::TitleChanged);
if let Some(foreground_info) = &self.foreground_process_info {
let cwd = foreground_info.cwd.clone();
let item_id = self.item_id;
let workspace_id = self.workspace_id;
cx.background()
.spawn(async move {
TERMINAL_CONNECTION
.save_working_directory(item_id, workspace_id, cwd)
.await
.log_err();
})
.detach();
}
}
}
AlacTermEvent::ColorRequest(idx, fun_ptr) => {
@@ -1194,42 +1158,13 @@ impl Terminal {
}
}
pub fn set_workspace_id(&mut self, id: WorkspaceId, cx: &AppContext) {
let old_workspace_id = self.workspace_id;
let item_id = self.item_id;
cx.background()
.spawn(async move {
TERMINAL_CONNECTION
.update_workspace_id(id, old_workspace_id, item_id)
.await
.log_err()
})
.detach();
self.workspace_id = id;
}
pub fn find_matches(
&mut self,
query: project::search::SearchQuery,
searcher: RegexSearch,
cx: &mut ModelContext<Self>,
) -> Task<Vec<RangeInclusive<Point>>> {
let term = self.term.clone();
cx.background().spawn(async move {
let searcher = match query {
project::search::SearchQuery::Text { query, .. } => {
RegexSearch::new(query.as_ref())
}
project::search::SearchQuery::Regex { query, .. } => {
RegexSearch::new(query.as_ref())
}
};
if searcher.is_err() {
return Vec::new();
}
let searcher = searcher.unwrap();
let term = term.lock();
all_search_matches(&term, &searcher).collect()
@@ -1326,14 +1261,14 @@ fn open_uri(uri: &str) -> Result<(), std::io::Error> {
#[cfg(test)]
mod tests {
use alacritty_terminal::{
index::{Column, Line, Point},
term::cell::Cell,
};
use gpui::geometry::vector::vec2f;
use rand::{thread_rng, Rng};
use rand::{rngs::ThreadRng, thread_rng, Rng};
use crate::content_index_for_mouse;
use self::terminal_test_context::TerminalTestContext;
pub mod terminal_test_context;
use crate::{content_index_for_mouse, IndexedCell, TerminalContent, TerminalSize};
#[test]
fn test_mouse_to_cell() {
@@ -1350,7 +1285,7 @@ mod tests {
width: cell_size * (viewport_cells as f32),
};
let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng);
let (content, cells) = create_terminal_content(size, &mut rng);
for i in 0..(viewport_cells - 1) {
let i = i as usize;
@@ -1386,7 +1321,7 @@ mod tests {
width: 100.,
};
let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng);
let (content, cells) = create_terminal_content(size, &mut rng);
assert_eq!(
content.cells[content_index_for_mouse(vec2f(-10., -10.), &content)].c,
@@ -1397,4 +1332,37 @@ mod tests {
cells[9][9]
);
}
fn create_terminal_content(
size: TerminalSize,
rng: &mut ThreadRng,
) -> (TerminalContent, Vec<Vec<char>>) {
let mut ic = Vec::new();
let mut cells = Vec::new();
for row in 0..((size.height() / size.line_height()) as usize) {
let mut row_vec = Vec::new();
for col in 0..((size.width() / size.cell_width()) as usize) {
let cell_char = rng.gen();
ic.push(IndexedCell {
point: Point::new(Line(row as i32), Column(col)),
cell: Cell {
c: cell_char,
..Default::default()
},
});
row_vec.push(cell_char)
}
cells.push(row_vec)
}
(
TerminalContent {
cells: ic,
size,
..Default::default()
},
cells,
)
}
}

View File

@@ -1,711 +0,0 @@
use crate::persistence::TERMINAL_CONNECTION;
use crate::terminal_view::TerminalView;
use crate::{Event, TerminalBuilder, TerminalError};
use alacritty_terminal::index::Point;
use dirs::home_dir;
use gpui::{
actions, elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MutableAppContext, Task,
View, ViewContext, ViewHandle, WeakViewHandle,
};
use util::{truncate_and_trailoff, ResultExt};
use workspace::searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle};
use workspace::{
item::{Item, ItemEvent},
ToolbarItemLocation, Workspace,
};
use workspace::{register_deserializable_item, Pane, WorkspaceId};
use project::{LocalWorktree, Project, ProjectPath};
use settings::{AlternateScroll, Settings, WorkingDirectory};
use smallvec::SmallVec;
use std::ops::RangeInclusive;
use std::path::{Path, PathBuf};
use crate::terminal_element::TerminalElement;
actions!(terminal, [DeployModal]);
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(TerminalContainer::deploy);
register_deserializable_item::<TerminalContainer>(cx);
}
//Make terminal view an enum, that can give you views for the error and non-error states
//Take away all the result unwrapping in the current TerminalView by making it 'infallible'
//Bubble up to deploy(_modal)() calls
pub enum TerminalContainerContent {
Connected(ViewHandle<TerminalView>),
Error(ViewHandle<ErrorView>),
}
impl TerminalContainerContent {
fn handle(&self) -> AnyViewHandle {
match self {
Self::Connected(handle) => handle.into(),
Self::Error(handle) => handle.into(),
}
}
}
pub struct TerminalContainer {
pub content: TerminalContainerContent,
associated_directory: Option<PathBuf>,
}
pub struct ErrorView {
error: TerminalError,
}
impl Entity for TerminalContainer {
type Event = Event;
}
impl Entity for ErrorView {
type Event = Event;
}
impl TerminalContainer {
///Create a new Terminal in the current working directory or the user's home directory
pub fn deploy(
workspace: &mut Workspace,
_: &workspace::NewTerminal,
cx: &mut ViewContext<Workspace>,
) {
let strategy = cx
.global::<Settings>()
.terminal_overrides
.working_directory
.clone()
.unwrap_or(WorkingDirectory::CurrentProjectDirectory);
let working_directory = get_working_directory(workspace, cx, strategy);
let view = cx.add_view(|cx| {
TerminalContainer::new(working_directory, false, workspace.database_id(), cx)
});
workspace.add_item(Box::new(view), cx);
}
///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
pub fn new(
working_directory: Option<PathBuf>,
modal: bool,
workspace_id: WorkspaceId,
cx: &mut ViewContext<Self>,
) -> Self {
let settings = cx.global::<Settings>();
let shell = settings.terminal_overrides.shell.clone();
let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
//TODO: move this pattern to settings
let scroll = settings
.terminal_overrides
.alternate_scroll
.as_ref()
.unwrap_or(
settings
.terminal_defaults
.alternate_scroll
.as_ref()
.unwrap_or_else(|| &AlternateScroll::On),
);
let content = match TerminalBuilder::new(
working_directory.clone(),
shell,
envs,
settings.terminal_overrides.blinking.clone(),
scroll,
cx.window_id(),
cx.view_id(),
workspace_id,
) {
Ok(terminal) => {
let terminal = cx.add_model(|cx| terminal.subscribe(cx));
let view = cx.add_view(|cx| TerminalView::from_terminal(terminal, modal, cx));
cx.subscribe(&view, |_this, _content, event, cx| cx.emit(*event))
.detach();
TerminalContainerContent::Connected(view)
}
Err(error) => {
let view = cx.add_view(|_| ErrorView {
error: error.downcast::<TerminalError>().unwrap(),
});
TerminalContainerContent::Error(view)
}
};
TerminalContainer {
content,
associated_directory: working_directory,
}
}
fn connected(&self) -> Option<ViewHandle<TerminalView>> {
match &self.content {
TerminalContainerContent::Connected(vh) => Some(vh.clone()),
TerminalContainerContent::Error(_) => None,
}
}
}
impl View for TerminalContainer {
fn ui_name() -> &'static str {
"Terminal"
}
fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
match &self.content {
TerminalContainerContent::Connected(connected) => ChildView::new(connected, cx),
TerminalContainerContent::Error(error) => ChildView::new(error, cx),
}
.boxed()
}
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
if cx.is_self_focused() {
cx.focus(self.content.handle());
}
}
}
impl View for ErrorView {
fn ui_name() -> &'static str {
"Terminal Error"
}
fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
let settings = cx.global::<Settings>();
let style = TerminalElement::make_text_style(cx.font_cache(), settings);
//TODO:
//We want markdown style highlighting so we can format the program and working directory with ``
//We want a max-width of 75% with word-wrap
//We want to be able to select the text
//Want to be able to scroll if the error message is massive somehow (resiliency)
let program_text = {
match self.error.shell_to_string() {
Some(shell_txt) => format!("Shell Program: `{}`", shell_txt),
None => "No program specified".to_string(),
}
};
let directory_text = {
match self.error.directory.as_ref() {
Some(path) => format!("Working directory: `{}`", path.to_string_lossy()),
None => "No working directory specified".to_string(),
}
};
let error_text = self.error.source.to_string();
Flex::column()
.with_child(
Text::new("Failed to open the terminal.".to_string(), style.clone())
.contained()
.boxed(),
)
.with_child(Text::new(program_text, style.clone()).contained().boxed())
.with_child(Text::new(directory_text, style.clone()).contained().boxed())
.with_child(Text::new(error_text, style).contained().boxed())
.aligned()
.boxed()
}
}
impl Item for TerminalContainer {
fn tab_content(
&self,
_detail: Option<usize>,
tab_theme: &theme::Tab,
cx: &gpui::AppContext,
) -> ElementBox {
let title = match &self.content {
TerminalContainerContent::Connected(connected) => connected
.read(cx)
.handle()
.read(cx)
.foreground_process_info
.as_ref()
.map(|fpi| {
format!(
"{}{}",
truncate_and_trailoff(
&fpi.cwd
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default(),
25
),
truncate_and_trailoff(
&{
format!(
"{}{}",
fpi.name,
if fpi.argv.len() >= 1 {
format!(" {}", (&fpi.argv[1..]).join(" "))
} else {
"".to_string()
}
)
},
25
)
)
})
.unwrap_or_else(|| "Terminal".to_string()),
TerminalContainerContent::Error(_) => "Terminal".to_string(),
};
Flex::row()
.with_child(
Label::new(title, tab_theme.label.clone())
.aligned()
.contained()
.boxed(),
)
.boxed()
}
fn clone_on_split(
&self,
workspace_id: WorkspaceId,
cx: &mut ViewContext<Self>,
) -> Option<Self> {
//From what I can tell, there's no way to tell the current working
//Directory of the terminal from outside the shell. There might be
//solutions to this, but they are non-trivial and require more IPC
Some(TerminalContainer::new(
self.associated_directory.clone(),
false,
workspace_id,
cx,
))
}
fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
None
}
fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
SmallVec::new()
}
fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
false
}
fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
fn can_save(&self, _cx: &gpui::AppContext) -> bool {
false
}
fn save(
&mut self,
_project: gpui::ModelHandle<Project>,
_cx: &mut ViewContext<Self>,
) -> gpui::Task<gpui::anyhow::Result<()>> {
unreachable!("save should not have been called");
}
fn save_as(
&mut self,
_project: gpui::ModelHandle<Project>,
_abs_path: std::path::PathBuf,
_cx: &mut ViewContext<Self>,
) -> gpui::Task<gpui::anyhow::Result<()>> {
unreachable!("save_as should not have been called");
}
fn reload(
&mut self,
_project: gpui::ModelHandle<Project>,
_cx: &mut ViewContext<Self>,
) -> gpui::Task<gpui::anyhow::Result<()>> {
gpui::Task::ready(Ok(()))
}
fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
if let TerminalContainerContent::Connected(connected) = &self.content {
connected.read(cx).has_bell()
} else {
false
}
}
fn has_conflict(&self, _cx: &AppContext) -> bool {
false
}
fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(handle.clone()))
}
fn to_item_events(event: &Self::Event) -> Vec<ItemEvent> {
match event {
Event::BreadcrumbsChanged => vec![ItemEvent::UpdateBreadcrumbs],
Event::TitleChanged | Event::Wakeup => vec![ItemEvent::UpdateTab],
Event::CloseTerminal => vec![ItemEvent::CloseItem],
_ => vec![],
}
}
fn breadcrumb_location(&self) -> ToolbarItemLocation {
if self.connected().is_some() {
ToolbarItemLocation::PrimaryLeft { flex: None }
} else {
ToolbarItemLocation::Hidden
}
}
fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<ElementBox>> {
let connected = self.connected()?;
Some(vec![Text::new(
connected
.read(cx)
.terminal()
.read(cx)
.breadcrumb_text
.to_string(),
theme.breadcrumbs.text.clone(),
)
.boxed()])
}
fn serialized_item_kind() -> Option<&'static str> {
Some("Terminal")
}
fn deserialize(
_project: ModelHandle<Project>,
_workspace: WeakViewHandle<Workspace>,
workspace_id: workspace::WorkspaceId,
item_id: workspace::ItemId,
cx: &mut ViewContext<Pane>,
) -> Task<anyhow::Result<ViewHandle<Self>>> {
let working_directory = TERMINAL_CONNECTION.get_working_directory(item_id, workspace_id);
Task::ready(Ok(cx.add_view(|cx| {
TerminalContainer::new(
working_directory.log_err().flatten(),
false,
workspace_id,
cx,
)
})))
}
fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
if let Some(connected) = self.connected() {
let id = workspace.database_id();
let terminal_handle = connected.read(cx).terminal().clone();
terminal_handle.update(cx, |terminal, cx| terminal.set_workspace_id(id, cx))
}
}
}
impl SearchableItem for TerminalContainer {
type Match = RangeInclusive<Point>;
fn supported_options() -> SearchOptions {
SearchOptions {
case: false,
word: false,
regex: false,
}
}
/// Convert events raised by this item into search-relevant events (if applicable)
fn to_search_event(event: &Self::Event) -> Option<SearchEvent> {
match event {
Event::Wakeup => Some(SearchEvent::MatchesInvalidated),
Event::SelectionsChanged => Some(SearchEvent::ActiveMatchChanged),
_ => None,
}
}
/// Clear stored matches
fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
if let TerminalContainerContent::Connected(connected) = &self.content {
let terminal = connected.read(cx).terminal().clone();
terminal.update(cx, |term, _| term.matches.clear())
}
}
/// Store matches returned from find_matches somewhere for rendering
fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
if let TerminalContainerContent::Connected(connected) = &self.content {
let terminal = connected.read(cx).terminal().clone();
terminal.update(cx, |term, _| term.matches = matches)
}
}
/// Return the selection content to pre-load into this search
fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
if let TerminalContainerContent::Connected(connected) = &self.content {
let terminal = connected.read(cx).terminal().clone();
terminal
.read(cx)
.last_content
.selection_text
.clone()
.unwrap_or_default()
} else {
Default::default()
}
}
/// Focus match at given index into the Vec of matches
fn activate_match(&mut self, index: usize, _: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
if let TerminalContainerContent::Connected(connected) = &self.content {
let terminal = connected.read(cx).terminal().clone();
terminal.update(cx, |term, _| term.activate_match(index));
cx.notify();
}
}
/// Get all of the matches for this query, should be done on the background
fn find_matches(
&mut self,
query: project::search::SearchQuery,
cx: &mut ViewContext<Self>,
) -> Task<Vec<Self::Match>> {
if let TerminalContainerContent::Connected(connected) = &self.content {
let terminal = connected.read(cx).terminal().clone();
terminal.update(cx, |term, cx| term.find_matches(query, cx))
} else {
Task::ready(Vec::new())
}
}
/// Reports back to the search toolbar what the active match should be (the selection)
fn active_match_index(
&mut self,
matches: Vec<Self::Match>,
cx: &mut ViewContext<Self>,
) -> Option<usize> {
let connected = self.connected();
// Selection head might have a value if there's a selection that isn't
// associated with a match. Therefore, if there are no matches, we should
// report None, no matter the state of the terminal
let res = if matches.len() > 0 && connected.is_some() {
if let Some(selection_head) = connected
.unwrap()
.read(cx)
.terminal()
.read(cx)
.selection_head
{
// If selection head is contained in a match. Return that match
if let Some(ix) = matches
.iter()
.enumerate()
.find(|(_, search_match)| {
search_match.contains(&selection_head)
|| search_match.start() > &selection_head
})
.map(|(ix, _)| ix)
{
Some(ix)
} else {
// If no selection after selection head, return the last match
Some(matches.len().saturating_sub(1))
}
} else {
// Matches found but no active selection, return the first last one (closest to cursor)
Some(matches.len().saturating_sub(1))
}
} else {
None
};
res
}
}
///Get's the working directory for the given workspace, respecting the user's settings.
pub fn get_working_directory(
workspace: &Workspace,
cx: &AppContext,
strategy: WorkingDirectory,
) -> Option<PathBuf> {
let res = match strategy {
WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
.or_else(|| first_project_directory(workspace, cx)),
WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
WorkingDirectory::AlwaysHome => None,
WorkingDirectory::Always { directory } => {
shellexpand::full(&directory) //TODO handle this better
.ok()
.map(|dir| Path::new(&dir.to_string()).to_path_buf())
.filter(|dir| dir.is_dir())
}
};
res.or_else(home_dir)
}
///Get's the first project's home directory, or the home directory
fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
workspace
.worktrees(cx)
.next()
.and_then(|worktree_handle| worktree_handle.read(cx).as_local())
.and_then(get_path_from_wt)
}
///Gets the intuitively correct working directory from the given workspace
///If there is an active entry for this project, returns that entry's worktree root.
///If there's no active entry but there is a worktree, returns that worktrees root.
///If either of these roots are files, or if there are any other query failures,
/// returns the user's home directory
fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
let project = workspace.project().read(cx);
project
.active_entry()
.and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
.or_else(|| workspace.worktrees(cx).next())
.and_then(|worktree_handle| worktree_handle.read(cx).as_local())
.and_then(get_path_from_wt)
}
fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
wt.root_entry()
.filter(|re| re.is_dir())
.map(|_| wt.abs_path().to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use std::path::Path;
use crate::tests::terminal_test_context::TerminalTestContext;
///Working directory calculation tests
///No Worktrees in project -> home_dir()
#[gpui::test]
async fn no_worktree(cx: &mut TestAppContext) {
//Setup variables
let mut cx = TerminalTestContext::new(cx);
let (project, workspace) = cx.blank_workspace().await;
//Test
cx.cx.read(|cx| {
let workspace = workspace.read(cx);
let active_entry = project.read(cx).active_entry();
//Make sure enviroment is as expeted
assert!(active_entry.is_none());
assert!(workspace.worktrees(cx).next().is_none());
let res = current_project_directory(workspace, cx);
assert_eq!(res, None);
let res = first_project_directory(workspace, cx);
assert_eq!(res, None);
});
}
///No active entry, but a worktree, worktree is a file -> home_dir()
#[gpui::test]
async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
//Setup variables
let mut cx = TerminalTestContext::new(cx);
let (project, workspace) = cx.blank_workspace().await;
cx.create_file_wt(project.clone(), "/root.txt").await;
cx.cx.read(|cx| {
let workspace = workspace.read(cx);
let active_entry = project.read(cx).active_entry();
//Make sure enviroment is as expeted
assert!(active_entry.is_none());
assert!(workspace.worktrees(cx).next().is_some());
let res = current_project_directory(workspace, cx);
assert_eq!(res, None);
let res = first_project_directory(workspace, cx);
assert_eq!(res, None);
});
}
//No active entry, but a worktree, worktree is a folder -> worktree_folder
#[gpui::test]
async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
//Setup variables
let mut cx = TerminalTestContext::new(cx);
let (project, workspace) = cx.blank_workspace().await;
let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root/").await;
//Test
cx.cx.update(|cx| {
let workspace = workspace.read(cx);
let active_entry = project.read(cx).active_entry();
assert!(active_entry.is_none());
assert!(workspace.worktrees(cx).next().is_some());
let res = current_project_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
let res = first_project_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
});
}
//Active entry with a work tree, worktree is a file -> home_dir()
#[gpui::test]
async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
//Setup variables
let mut cx = TerminalTestContext::new(cx);
let (project, workspace) = cx.blank_workspace().await;
let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await;
cx.insert_active_entry_for(wt2, entry2, project.clone());
//Test
cx.cx.update(|cx| {
let workspace = workspace.read(cx);
let active_entry = project.read(cx).active_entry();
assert!(active_entry.is_some());
let res = current_project_directory(workspace, cx);
assert_eq!(res, None);
let res = first_project_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
});
}
//Active entry, with a worktree, worktree is a folder -> worktree_folder
#[gpui::test]
async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
//Setup variables
let mut cx = TerminalTestContext::new(cx);
let (project, workspace) = cx.blank_workspace().await;
let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
let (wt2, entry2) = cx.create_folder_wt(project.clone(), "/root2/").await;
cx.insert_active_entry_for(wt2, entry2, project.clone());
//Test
cx.cx.update(|cx| {
let workspace = workspace.read(cx);
let active_entry = project.read(cx).active_entry();
assert!(active_entry.is_some());
let res = current_project_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
let res = first_project_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
});
}
}

View File

@@ -1,471 +0,0 @@
use std::{ops::RangeInclusive, time::Duration};
use alacritty_terminal::{index::Point, term::TermMode};
use context_menu::{ContextMenu, ContextMenuItem};
use gpui::{
actions,
elements::{AnchorCorner, ChildView, ParentElement, Stack},
geometry::vector::Vector2F,
impl_actions, impl_internal_actions,
keymap::Keystroke,
AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task,
View, ViewContext, ViewHandle,
};
use serde::Deserialize;
use settings::{Settings, TerminalBlink};
use smol::Timer;
use util::ResultExt;
use workspace::pane;
use crate::{terminal_element::TerminalElement, Event, Terminal};
const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
///Event to transmit the scroll from the element to the view
#[derive(Clone, Debug, PartialEq)]
pub struct ScrollTerminal(pub i32);
#[derive(Clone, PartialEq)]
pub struct DeployContextMenu {
pub position: Vector2F,
}
#[derive(Clone, Default, Deserialize, PartialEq)]
pub struct SendText(String);
#[derive(Clone, Default, Deserialize, PartialEq)]
pub struct SendKeystroke(String);
actions!(
terminal,
[Clear, Copy, Paste, ShowCharacterPalette, SearchTest]
);
impl_actions!(terminal, [SendText, SendKeystroke]);
impl_internal_actions!(project_panel, [DeployContextMenu]);
pub fn init(cx: &mut MutableAppContext) {
//Useful terminal views
cx.add_action(TerminalView::send_text);
cx.add_action(TerminalView::send_keystroke);
cx.add_action(TerminalView::deploy_context_menu);
cx.add_action(TerminalView::copy);
cx.add_action(TerminalView::paste);
cx.add_action(TerminalView::clear);
cx.add_action(TerminalView::show_character_palette);
}
///A terminal view, maintains the PTY's file handles and communicates with the terminal
pub struct TerminalView {
terminal: ModelHandle<Terminal>,
has_new_content: bool,
//Currently using iTerm bell, show bell emoji in tab until input is received
has_bell: bool,
// Only for styling purposes. Doesn't effect behavior
modal: bool,
context_menu: ViewHandle<ContextMenu>,
blink_state: bool,
blinking_on: bool,
blinking_paused: bool,
blink_epoch: usize,
}
impl Entity for TerminalView {
type Event = Event;
}
impl TerminalView {
pub fn from_terminal(
terminal: ModelHandle<Terminal>,
modal: bool,
cx: &mut ViewContext<Self>,
) -> Self {
cx.observe(&terminal, |_, _, cx| cx.notify()).detach();
cx.subscribe(&terminal, |this, _, event, cx| match event {
Event::Wakeup => {
if !cx.is_self_focused() {
this.has_new_content = true;
cx.notify();
}
cx.emit(Event::Wakeup);
}
Event::Bell => {
this.has_bell = true;
cx.emit(Event::Wakeup);
}
Event::BlinkChanged => this.blinking_on = !this.blinking_on,
_ => cx.emit(*event),
})
.detach();
Self {
terminal,
has_new_content: true,
has_bell: false,
modal,
context_menu: cx.add_view(ContextMenu::new),
blink_state: true,
blinking_on: false,
blinking_paused: false,
blink_epoch: 0,
}
}
pub fn handle(&self) -> ModelHandle<Terminal> {
self.terminal.clone()
}
pub fn has_new_content(&self) -> bool {
self.has_new_content
}
pub fn has_bell(&self) -> bool {
self.has_bell
}
pub fn clear_bel(&mut self, cx: &mut ViewContext<TerminalView>) {
self.has_bell = false;
cx.emit(Event::Wakeup);
}
pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
let menu_entries = vec![
ContextMenuItem::item("Clear", Clear),
ContextMenuItem::item("Close", pane::CloseActiveItem),
];
self.context_menu.update(cx, |menu, cx| {
menu.show(action.position, AnchorCorner::TopLeft, menu_entries, cx)
});
cx.notify();
}
fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext<Self>) {
if !self
.terminal
.read(cx)
.last_content
.mode
.contains(TermMode::ALT_SCREEN)
{
cx.show_character_palette();
} else {
self.terminal.update(cx, |term, cx| {
term.try_keystroke(
&Keystroke::parse("ctrl-cmd-space").unwrap(),
cx.global::<Settings>()
.terminal_overrides
.option_as_meta
.unwrap_or(false),
)
});
}
}
fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
self.terminal.update(cx, |term, _| term.clear());
cx.notify();
}
pub fn should_show_cursor(
&self,
focused: bool,
cx: &mut gpui::RenderContext<'_, Self>,
) -> bool {
//Don't blink the cursor when not focused, blinking is disabled, or paused
if !focused
|| !self.blinking_on
|| self.blinking_paused
|| self
.terminal
.read(cx)
.last_content
.mode
.contains(TermMode::ALT_SCREEN)
{
return true;
}
let setting = {
let settings = cx.global::<Settings>();
settings
.terminal_overrides
.blinking
.clone()
.unwrap_or(TerminalBlink::TerminalControlled)
};
match setting {
//If the user requested to never blink, don't blink it.
TerminalBlink::Off => true,
//If the terminal is controlling it, check terminal mode
TerminalBlink::TerminalControlled | TerminalBlink::On => self.blink_state,
}
}
fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
if epoch == self.blink_epoch && !self.blinking_paused {
self.blink_state = !self.blink_state;
cx.notify();
let epoch = self.next_blink_epoch();
cx.spawn(|this, mut cx| {
let this = this.downgrade();
async move {
Timer::after(CURSOR_BLINK_INTERVAL).await;
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx));
}
}
})
.detach();
}
}
pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext<Self>) {
self.blink_state = true;
cx.notify();
let epoch = self.next_blink_epoch();
cx.spawn(|this, mut cx| {
let this = this.downgrade();
async move {
Timer::after(CURSOR_BLINK_INTERVAL).await;
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx))
}
}
})
.detach();
}
pub fn find_matches(
&mut self,
query: project::search::SearchQuery,
cx: &mut ViewContext<Self>,
) -> Task<Vec<RangeInclusive<Point>>> {
self.terminal
.update(cx, |term, cx| term.find_matches(query, cx))
}
pub fn terminal(&self) -> &ModelHandle<Terminal> {
&self.terminal
}
fn next_blink_epoch(&mut self) -> usize {
self.blink_epoch += 1;
self.blink_epoch
}
fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
if epoch == self.blink_epoch {
self.blinking_paused = false;
self.blink_cursors(epoch, cx);
}
}
///Attempt to paste the clipboard into the terminal
fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
self.terminal.update(cx, |term, _| term.copy())
}
///Attempt to paste the clipboard into the terminal
fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
if let Some(item) = cx.read_from_clipboard() {
self.terminal
.update(cx, |terminal, _cx| terminal.paste(item.text()));
}
}
fn send_text(&mut self, text: &SendText, cx: &mut ViewContext<Self>) {
self.clear_bel(cx);
self.terminal.update(cx, |term, _| {
term.input(text.0.to_string());
});
}
fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext<Self>) {
if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
self.clear_bel(cx);
self.terminal.update(cx, |term, cx| {
term.try_keystroke(
&keystroke,
cx.global::<Settings>()
.terminal_overrides
.option_as_meta
.unwrap_or(false),
);
});
}
}
}
impl View for TerminalView {
fn ui_name() -> &'static str {
"Terminal"
}
fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
let terminal_handle = self.terminal.clone().downgrade();
let self_id = cx.view_id();
let focused = cx
.focused_view_id(cx.window_id())
.filter(|view_id| *view_id == self_id)
.is_some();
Stack::new()
.with_child(
TerminalElement::new(
cx.handle(),
terminal_handle,
focused,
self.should_show_cursor(focused, cx),
)
.contained()
.boxed(),
)
.with_child(ChildView::new(&self.context_menu, cx).boxed())
.boxed()
}
fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
self.has_new_content = false;
self.terminal.read(cx).focus_in();
self.blink_cursors(self.blink_epoch, cx);
cx.notify();
}
fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
self.terminal.update(cx, |terminal, _| {
terminal.focus_out();
});
cx.notify();
}
fn key_down(&mut self, event: &gpui::KeyDownEvent, cx: &mut ViewContext<Self>) -> bool {
self.clear_bel(cx);
self.pause_cursor_blinking(cx);
self.terminal.update(cx, |term, cx| {
term.try_keystroke(
&event.keystroke,
cx.global::<Settings>()
.terminal_overrides
.option_as_meta
.unwrap_or(false),
)
})
}
//IME stuff
fn selected_text_range(&self, cx: &AppContext) -> Option<std::ops::Range<usize>> {
if self
.terminal
.read(cx)
.last_content
.mode
.contains(TermMode::ALT_SCREEN)
{
None
} else {
Some(0..0)
}
}
fn replace_text_in_range(
&mut self,
_: Option<std::ops::Range<usize>>,
text: &str,
cx: &mut ViewContext<Self>,
) {
self.terminal.update(cx, |terminal, _| {
terminal.input(text.into());
});
}
fn keymap_context(&self, cx: &gpui::AppContext) -> gpui::keymap::Context {
let mut context = Self::default_keymap_context();
if self.modal {
context.set.insert("ModalTerminal".into());
}
let mode = self.terminal.read(cx).last_content.mode;
context.map.insert(
"screen".to_string(),
(if mode.contains(TermMode::ALT_SCREEN) {
"alt"
} else {
"normal"
})
.to_string(),
);
if mode.contains(TermMode::APP_CURSOR) {
context.set.insert("DECCKM".to_string());
}
if mode.contains(TermMode::APP_KEYPAD) {
context.set.insert("DECPAM".to_string());
}
//Note the ! here
if !mode.contains(TermMode::APP_KEYPAD) {
context.set.insert("DECPNM".to_string());
}
if mode.contains(TermMode::SHOW_CURSOR) {
context.set.insert("DECTCEM".to_string());
}
if mode.contains(TermMode::LINE_WRAP) {
context.set.insert("DECAWM".to_string());
}
if mode.contains(TermMode::ORIGIN) {
context.set.insert("DECOM".to_string());
}
if mode.contains(TermMode::INSERT) {
context.set.insert("IRM".to_string());
}
//LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
context.set.insert("LNM".to_string());
}
if mode.contains(TermMode::FOCUS_IN_OUT) {
context.set.insert("report_focus".to_string());
}
if mode.contains(TermMode::ALTERNATE_SCROLL) {
context.set.insert("alternate_scroll".to_string());
}
if mode.contains(TermMode::BRACKETED_PASTE) {
context.set.insert("bracketed_paste".to_string());
}
if mode.intersects(TermMode::MOUSE_MODE) {
context.set.insert("any_mouse_reporting".to_string());
}
{
let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
"click"
} else if mode.contains(TermMode::MOUSE_DRAG) {
"drag"
} else if mode.contains(TermMode::MOUSE_MOTION) {
"motion"
} else {
"off"
};
context
.map
.insert("mouse_reporting".to_string(), mouse_reporting.to_string());
}
{
let format = if mode.contains(TermMode::SGR_MOUSE) {
"sgr"
} else if mode.contains(TermMode::UTF8_MOUSE) {
"utf8"
} else {
"normal"
};
context
.map
.insert("mouse_format".to_string(), format.to_string());
}
context
}
}

View File

@@ -1,143 +0,0 @@
use std::{path::Path, time::Duration};
use alacritty_terminal::{
index::{Column, Line, Point},
term::cell::Cell,
};
use gpui::{ModelHandle, TestAppContext, ViewHandle};
use project::{Entry, Project, ProjectPath, Worktree};
use rand::{rngs::ThreadRng, Rng};
use workspace::{AppState, Workspace};
use crate::{IndexedCell, TerminalContent, TerminalSize};
pub struct TerminalTestContext<'a> {
pub cx: &'a mut TestAppContext,
}
impl<'a> TerminalTestContext<'a> {
pub fn new(cx: &'a mut TestAppContext) -> Self {
cx.set_condition_duration(Some(Duration::from_secs(5)));
TerminalTestContext { cx }
}
///Creates a worktree with 1 file: /root.txt
pub async fn blank_workspace(&mut self) -> (ModelHandle<Project>, ViewHandle<Workspace>) {
let params = self.cx.update(AppState::test);
let project = Project::test(params.fs.clone(), [], self.cx).await;
let (_, workspace) = self.cx.add_window(|cx| {
Workspace::new(
Default::default(),
0,
project.clone(),
|_, _| unimplemented!(),
cx,
)
});
(project, workspace)
}
///Creates a worktree with 1 folder: /root{suffix}/
pub async fn create_folder_wt(
&mut self,
project: ModelHandle<Project>,
path: impl AsRef<Path>,
) -> (ModelHandle<Worktree>, Entry) {
self.create_wt(project, true, path).await
}
///Creates a worktree with 1 file: /root{suffix}.txt
pub async fn create_file_wt(
&mut self,
project: ModelHandle<Project>,
path: impl AsRef<Path>,
) -> (ModelHandle<Worktree>, Entry) {
self.create_wt(project, false, path).await
}
async fn create_wt(
&mut self,
project: ModelHandle<Project>,
is_dir: bool,
path: impl AsRef<Path>,
) -> (ModelHandle<Worktree>, Entry) {
let (wt, _) = project
.update(self.cx, |project, cx| {
project.find_or_create_local_worktree(path, true, cx)
})
.await
.unwrap();
let entry = self
.cx
.update(|cx| {
wt.update(cx, |wt, cx| {
wt.as_local()
.unwrap()
.create_entry(Path::new(""), is_dir, cx)
})
})
.await
.unwrap();
(wt, entry)
}
pub fn insert_active_entry_for(
&mut self,
wt: ModelHandle<Worktree>,
entry: Entry,
project: ModelHandle<Project>,
) {
self.cx.update(|cx| {
let p = ProjectPath {
worktree_id: wt.read(cx).id(),
path: entry.path,
};
project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
});
}
pub fn create_terminal_content(
size: TerminalSize,
rng: &mut ThreadRng,
) -> (TerminalContent, Vec<Vec<char>>) {
let mut ic = Vec::new();
let mut cells = Vec::new();
for row in 0..((size.height() / size.line_height()) as usize) {
let mut row_vec = Vec::new();
for col in 0..((size.width() / size.cell_width()) as usize) {
let cell_char = rng.gen();
ic.push(IndexedCell {
point: Point::new(Line(row as i32), Column(col)),
cell: Cell {
c: cell_char,
..Default::default()
},
});
row_vec.push(cell_char)
}
cells.push(row_vec)
}
(
TerminalContent {
cells: ic,
size,
..Default::default()
},
cells,
)
}
}
impl<'a> Drop for TerminalTestContext<'a> {
fn drop(&mut self) {
self.cx.set_condition_duration(None);
}
}

View File

@@ -0,0 +1,44 @@
[package]
name = "terminal_view"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/terminal_view.rs"
doctest = false
[dependencies]
context_menu = { path = "../context_menu" }
editor = { path = "../editor" }
language = { path = "../language" }
gpui = { path = "../gpui" }
project = { path = "../project" }
settings = { path = "../settings" }
theme = { path = "../theme" }
util = { path = "../util" }
workspace = { path = "../workspace" }
db = { path = "../db" }
procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false }
terminal = { path = "../terminal" }
smallvec = { version = "1.6", features = ["union"] }
smol = "1.2.5"
mio-extras = "2.0.6"
futures = "0.3"
ordered-float = "2.1.1"
itertools = "0.10"
dirs = "4.0.0"
shellexpand = "2.1.0"
libc = "0.2"
anyhow = "1"
thiserror = "1.0"
lazy_static = "1.4.0"
serde = { version = "1.0", features = ["derive"] }
[dev-dependencies]
gpui = { path = "../gpui", features = ["test-support"] }
client = { path = "../client", features = ["test-support"]}
project = { path = "../project", features = ["test-support"]}
workspace = { path = "../workspace", features = ["test-support"] }
rand = "0.8.5"

View File

@@ -1,11 +1,10 @@
use std::path::PathBuf;
use db::{define_connection, query, sqlez_macros::sql};
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
define_connection! {
pub static ref TERMINAL_CONNECTION: TerminalDb<WorkspaceDb> =
pub static ref TERMINAL_DB: TerminalDb<WorkspaceDb> =
&[sql!(
CREATE TABLE terminals (
workspace_id INTEGER,
@@ -13,7 +12,7 @@ define_connection! {
working_directory BLOB,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
ON DELETE CASCADE
) STRICT;
)];
}
@@ -43,10 +42,10 @@ impl TerminalDb {
}
query! {
pub fn get_working_directory(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
SELECT working_directory
FROM terminals
pub async fn take_working_directory(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
DELETE FROM terminals
WHERE item_id = ? AND workspace_id = ?
RETURNING working_directory
}
}
}

View File

@@ -1,9 +1,3 @@
use alacritty_terminal::{
ansi::{Color as AnsiColor, Color::Named, CursorShape as AlacCursorShape, NamedColor},
grid::Dimensions,
index::Point,
term::{cell::Flags, TermMode},
};
use editor::{Cursor, HighlightedRange, HighlightedRangeLine};
use gpui::{
color::Color,
@@ -22,17 +16,23 @@ use itertools::Itertools;
use language::CursorShape;
use ordered_float::OrderedFloat;
use settings::Settings;
use terminal::{
alacritty_terminal::{
ansi::{Color as AnsiColor, Color::Named, CursorShape as AlacCursorShape, NamedColor},
grid::Dimensions,
index::Point,
term::{cell::Flags, TermMode},
},
mappings::colors::convert_color,
IndexedCell, Terminal, TerminalContent, TerminalSize,
};
use theme::TerminalStyle;
use util::ResultExt;
use std::{fmt::Debug, ops::RangeInclusive};
use std::{mem, ops::Range};
use crate::{
mappings::colors::convert_color,
terminal_view::{DeployContextMenu, TerminalView},
IndexedCell, Terminal, TerminalContent, TerminalSize,
};
use crate::{DeployContextMenu, TerminalView};
///The information generated during layout that is nescessary for painting
pub struct LayoutState {
@@ -299,7 +299,7 @@ impl TerminalElement {
///Convert the Alacritty cell styles to GPUI text styles and background color
fn cell_style(
indexed: &IndexedCell,
fg: AnsiColor,
fg: terminal::alacritty_terminal::ansi::Color,
style: &TerminalStyle,
text_style: &TextStyle,
font_cache: &FontCache,

File diff suppressed because it is too large Load Diff

View File

@@ -126,18 +126,21 @@ impl DockPosition {
}
}
pub type DefaultItemFactory =
fn(&mut Workspace, &mut ViewContext<Workspace>) -> Box<dyn ItemHandle>;
pub type DockDefaultItemFactory =
fn(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Option<Box<dyn ItemHandle>>;
pub struct Dock {
position: DockPosition,
panel_sizes: HashMap<DockAnchor, f32>,
pane: ViewHandle<Pane>,
default_item_factory: DefaultItemFactory,
default_item_factory: DockDefaultItemFactory,
}
impl Dock {
pub fn new(default_item_factory: DefaultItemFactory, cx: &mut ViewContext<Workspace>) -> Self {
pub fn new(
default_item_factory: DockDefaultItemFactory,
cx: &mut ViewContext<Workspace>,
) -> Self {
let position = DockPosition::Hidden(cx.global::<Settings>().default_dock_anchor);
let pane = cx.add_view(|cx| Pane::new(Some(position.anchor()), cx));
@@ -192,9 +195,11 @@ impl Dock {
// Ensure that the pane has at least one item or construct a default item to put in it
let pane = workspace.dock.pane.clone();
if pane.read(cx).items().next().is_none() {
let item_to_add = (workspace.dock.default_item_factory)(workspace, cx);
// Adding the item focuses the pane by default
Pane::add_item(workspace, &pane, item_to_add, true, true, None, cx);
if let Some(item_to_add) = (workspace.dock.default_item_factory)(workspace, cx) {
Pane::add_item(workspace, &pane, item_to_add, true, true, None, cx);
} else {
workspace.dock.position = workspace.dock.position.hide();
}
} else {
cx.focus(pane);
}
@@ -453,20 +458,77 @@ impl StatusItemView for ToggleDockButton {
#[cfg(test)]
mod tests {
use std::ops::{Deref, DerefMut};
use std::{
ops::{Deref, DerefMut},
path::PathBuf,
};
use gpui::{AppContext, TestAppContext, UpdateView, ViewContext};
use project::{FakeFs, Project};
use settings::Settings;
use super::*;
use crate::{item::test::TestItem, sidebar::Sidebar, ItemHandle, Workspace};
use crate::{
dock,
item::test::TestItem,
persistence::model::{
SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
},
register_deserializable_item,
sidebar::Sidebar,
ItemHandle, Workspace,
};
pub fn default_item_factory(
_workspace: &mut Workspace,
cx: &mut ViewContext<Workspace>,
) -> Box<dyn ItemHandle> {
Box::new(cx.add_view(|_| TestItem::new()))
) -> Option<Box<dyn ItemHandle>> {
Some(Box::new(cx.add_view(|_| TestItem::new())))
}
#[gpui::test]
async fn test_dock_workspace_infinite_loop(cx: &mut TestAppContext) {
cx.foreground().forbid_parking();
Settings::test_async(cx);
cx.update(|cx| {
register_deserializable_item::<TestItem>(cx);
});
let serialized_workspace = SerializedWorkspace {
id: 0,
location: Vec::<PathBuf>::new().into(),
dock_position: dock::DockPosition::Shown(DockAnchor::Expanded),
center_group: SerializedPaneGroup::Pane(SerializedPane {
active: false,
children: vec![],
}),
dock_pane: SerializedPane {
active: true,
children: vec![SerializedItem {
active: true,
item_id: 0,
kind: "test".into(),
}],
},
left_sidebar_open: false,
};
let fs = FakeFs::new(cx.background());
let project = Project::test(fs, [], cx).await;
let (_, _workspace) = cx.add_window(|cx| {
Workspace::new(
Some(serialized_workspace),
0,
project.clone(),
default_item_factory,
cx,
)
});
cx.foreground().run_until_parked();
//Should terminate
}
#[gpui::test]

View File

@@ -681,6 +681,7 @@ pub(crate) mod test {
use super::{Item, ItemEvent};
pub struct TestItem {
pub workspace_id: WorkspaceId,
pub state: String,
pub label: String,
pub save_count: usize,
@@ -716,6 +717,7 @@ pub(crate) mod test {
nav_history: None,
tab_descriptions: None,
tab_detail: Default::default(),
workspace_id: self.workspace_id,
}
}
}
@@ -736,9 +738,16 @@ pub(crate) mod test {
nav_history: None,
tab_descriptions: None,
tab_detail: Default::default(),
workspace_id: 0,
}
}
pub fn new_deserialized(id: WorkspaceId) -> Self {
let mut this = Self::new();
this.workspace_id = id;
this
}
pub fn with_label(mut self, state: &str) -> Self {
self.label = state.to_string();
self
@@ -893,11 +902,12 @@ pub(crate) mod test {
fn deserialize(
_project: ModelHandle<Project>,
_workspace: WeakViewHandle<Workspace>,
_workspace_id: WorkspaceId,
workspace_id: WorkspaceId,
_item_id: ItemId,
_cx: &mut ViewContext<Pane>,
cx: &mut ViewContext<Pane>,
) -> Task<anyhow::Result<ViewHandle<Self>>> {
unreachable!("Cannot deserialize test item")
let view = cx.add_view(|_cx| Self::new_deserialized(workspace_id));
Task::Ready(Some(anyhow::Ok(view)))
}
}

View File

@@ -161,8 +161,8 @@ pub mod simple_message_notification {
pub struct MessageNotification {
message: String,
click_action: Box<dyn Action>,
click_message: String,
click_action: Option<Box<dyn Action>>,
click_message: Option<String>,
}
pub enum MessageNotificationEvent {
@@ -174,6 +174,14 @@ pub mod simple_message_notification {
}
impl MessageNotification {
pub fn new_messsage<S: AsRef<str>>(message: S) -> MessageNotification {
Self {
message: message.as_ref().to_string(),
click_action: None,
click_message: None,
}
}
pub fn new<S1: AsRef<str>, A: Action, S2: AsRef<str>>(
message: S1,
click_action: A,
@@ -181,8 +189,8 @@ pub mod simple_message_notification {
) -> Self {
Self {
message: message.as_ref().to_string(),
click_action: Box::new(click_action) as Box<dyn Action>,
click_message: click_message.as_ref().to_string(),
click_action: Some(Box::new(click_action) as Box<dyn Action>),
click_message: Some(click_message.as_ref().to_string()),
}
}
@@ -198,12 +206,15 @@ pub mod simple_message_notification {
fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
let theme = cx.global::<Settings>().theme.clone();
let theme = &theme.update_notification;
let theme = &theme.simple_message_notification;
enum MessageNotificationTag {}
let click_action = self.click_action.boxed_clone();
let click_message = self.click_message.clone();
let click_action = self
.click_action
.as_ref()
.map(|action| action.boxed_clone());
let click_message = self.click_message.as_ref().map(|message| message.clone());
let message = self.message.clone();
MouseEventHandler::<MessageNotificationTag>::new(0, cx, |state, cx| {
@@ -251,20 +262,28 @@ pub mod simple_message_notification {
)
.boxed(),
)
.with_child({
.with_children({
let style = theme.action_message.style_for(state, false);
Text::new(click_message, style.text.clone())
.contained()
.with_style(style.container)
.boxed()
if let Some(click_message) = click_message {
Some(
Text::new(click_message, style.text.clone())
.contained()
.with_style(style.container)
.boxed(),
)
} else {
None
}
.into_iter()
})
.contained()
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, cx| {
cx.dispatch_any_action(click_action.boxed_clone())
if let Some(click_action) = click_action.as_ref() {
cx.dispatch_any_action(click_action.boxed_clone())
}
})
.boxed()
}
@@ -278,3 +297,38 @@ pub mod simple_message_notification {
}
}
}
pub trait NotifyResultExt {
type Ok;
fn notify_err(
self,
workspace: &mut Workspace,
cx: &mut ViewContext<Workspace>,
) -> Option<Self::Ok>;
}
impl<T, E> NotifyResultExt for Result<T, E>
where
E: std::fmt::Debug,
{
type Ok = T;
fn notify_err(self, workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Option<T> {
match self {
Ok(value) => Some(value),
Err(err) => {
workspace.show_notification(0, cx, |cx| {
cx.add_view(|_cx| {
simple_message_notification::MessageNotification::new_messsage(format!(
"Error: {:?}",
err,
))
})
});
None
}
}
}
}

View File

@@ -8,7 +8,7 @@ use anyhow::{anyhow, bail, Context, Result};
use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql};
use gpui::Axis;
use util::{iife, unzip_option, ResultExt};
use util::{ unzip_option, ResultExt};
use crate::dock::DockPosition;
use crate::WorkspaceId;
@@ -96,22 +96,16 @@ impl WorkspaceDb {
WorkspaceLocation,
bool,
DockPosition,
) = iife!({
if worktree_roots.len() == 0 {
self.select_row(sql!(
SELECT workspace_id, workspace_location, left_sidebar_open, dock_visible, dock_anchor
FROM workspaces
ORDER BY timestamp DESC LIMIT 1))?()?
} else {
self.select_row_bound(sql!(
SELECT workspace_id, workspace_location, left_sidebar_open, dock_visible, dock_anchor
FROM workspaces
WHERE workspace_location = ?))?(&workspace_location)?
}
) =
self.select_row_bound(sql!{
SELECT workspace_id, workspace_location, left_sidebar_open, dock_visible, dock_anchor
FROM workspaces
WHERE workspace_location = ?
})
.and_then(|mut prepared_statement| (prepared_statement)(&workspace_location))
.context("No workspaces found")
})
.warn_on_err()
.flatten()?;
.warn_on_err()
.flatten()?;
Some(SerializedWorkspace {
id: workspace_id,
@@ -205,11 +199,21 @@ impl WorkspaceDb {
}
}
query! {
pub fn last_workspace() -> Result<Option<WorkspaceLocation>> {
SELECT workspace_location
FROM workspaces
WHERE workspace_location IS NOT NULL
ORDER BY timestamp DESC
LIMIT 1
}
}
fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
self.get_pane_group(workspace_id, None)?
Ok(self.get_pane_group(workspace_id, None)?
.into_iter()
.next()
.context("No center pane group")
.unwrap_or_else(|| SerializedPaneGroup::Pane(SerializedPane { active: true, children: vec![] })))
}
fn get_pane_group(
@@ -263,7 +267,7 @@ impl WorkspaceDb {
// Filter out panes and pane groups which don't have any children or items
.filter(|pane_group| match pane_group {
Ok(SerializedPaneGroup::Group { children, .. }) => !children.is_empty(),
Ok(SerializedPaneGroup::Pane(pane)) => !pane.children.is_empty(),
Ok(SerializedPaneGroup::Pane(pane)) => !pane.children.is_empty(),
_ => true,
})
.collect::<Result<_>>()
@@ -371,6 +375,15 @@ impl WorkspaceDb {
Ok(())
}
query!{
pub async fn update_timestamp(workspace_id: WorkspaceId) -> Result<()> {
UPDATE workspaces
SET timestamp = CURRENT_TIMESTAMP
WHERE workspace_id = ?
}
}
}
#[cfg(test)]

View File

@@ -106,7 +106,6 @@ impl SerializedPaneGroup {
.await
{
members.push(new_member);
current_active_pane = current_active_pane.or(active_pane);
}
}
@@ -115,6 +114,10 @@ impl SerializedPaneGroup {
return None;
}
if members.len() == 1 {
return Some((members.remove(0), current_active_pane));
}
Some((
Member::Axis(PaneAxis {
axis: *axis,
@@ -130,9 +133,10 @@ impl SerializedPaneGroup {
.deserialize_to(project, &pane, workspace_id, workspace, cx)
.await;
if pane.read_with(cx, |pane, _| pane.items().next().is_some()) {
if pane.read_with(cx, |pane, _| pane.items_len() != 0) {
Some((Member::Pane(pane.clone()), active.then(|| pane)))
} else {
workspace.update(cx, |workspace, cx| workspace.remove_pane(pane, cx));
None
}
}

View File

@@ -27,7 +27,7 @@ use anyhow::{anyhow, Context, Result};
use call::ActiveCall;
use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
use collections::{hash_map, HashMap, HashSet};
use dock::{DefaultItemFactory, Dock, ToggleDockButton};
use dock::{Dock, DockDefaultItemFactory, ToggleDockButton};
use drag_and_drop::DragAndDrop;
use fs::{self, Fs};
use futures::{channel::oneshot, FutureExt, StreamExt};
@@ -176,6 +176,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
}
}
});
cx.add_global_action({
let app_state = Arc::downgrade(&app_state);
move |_: &NewWindow, cx: &mut MutableAppContext| {
@@ -375,7 +376,7 @@ pub struct AppState {
pub fs: Arc<dyn fs::Fs>,
pub build_window_options: fn() -> WindowOptions<'static>,
pub initialize_workspace: fn(&mut Workspace, &Arc<AppState>, &mut ViewContext<Workspace>),
pub default_item_factory: DefaultItemFactory,
pub dock_default_item_factory: DockDefaultItemFactory,
}
impl AppState {
@@ -401,7 +402,7 @@ impl AppState {
user_store,
initialize_workspace: |_, _, _| {},
build_window_options: Default::default,
default_item_factory: |_, _| unimplemented!(),
dock_default_item_factory: |_, _| unimplemented!(),
})
}
}
@@ -515,7 +516,7 @@ impl Workspace {
serialized_workspace: Option<SerializedWorkspace>,
workspace_id: WorkspaceId,
project: ModelHandle<Project>,
dock_default_factory: DefaultItemFactory,
dock_default_factory: DockDefaultItemFactory,
cx: &mut ViewContext<Self>,
) -> Self {
cx.observe_fullscreen(|_, _, cx| cx.notify()).detach();
@@ -703,7 +704,7 @@ impl Workspace {
serialized_workspace,
workspace_id,
project_handle,
app_state.default_item_factory,
app_state.dock_default_item_factory,
cx,
);
(app_state.initialize_workspace)(&mut workspace, &app_state, cx);
@@ -2181,7 +2182,11 @@ impl Workspace {
}
pub fn on_window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
if !active {
if active {
cx.background()
.spawn(persistence::DB.update_timestamp(self.database_id()))
.detach();
} else {
for pane in &self.panes {
pane.update(cx, |pane, cx| {
if let Some(item) = pane.active_item() {
@@ -2295,6 +2300,9 @@ impl Workspace {
}
if let Some(location) = self.location(cx) {
// Load bearing special case:
// - with_local_workspace() relies on this to not have other stuff open
// when you open your log
if !location.paths().is_empty() {
let dock_pane = serialize_pane_handle(self.dock.pane(), cx);
let center_group = build_serialized_pane_group(&self.center.root, cx);
@@ -2322,9 +2330,14 @@ impl Workspace {
) {
cx.spawn(|mut cx| async move {
if let Some(workspace) = workspace.upgrade(&cx) {
let (project, dock_pane_handle) = workspace.read_with(&cx, |workspace, _| {
(workspace.project().clone(), workspace.dock_pane().clone())
});
let (project, dock_pane_handle, old_center_pane) =
workspace.read_with(&cx, |workspace, _| {
(
workspace.project().clone(),
workspace.dock_pane().clone(),
workspace.last_active_center_pane.clone(),
)
});
serialized_workspace
.dock_pane
@@ -2360,18 +2373,26 @@ impl Workspace {
cx.focus(workspace.panes.last().unwrap().clone());
}
} else {
cx.focus_self();
let old_center_handle = old_center_pane.and_then(|weak| weak.upgrade(cx));
if let Some(old_center_handle) = old_center_handle {
cx.focus(old_center_handle)
} else {
cx.focus_self()
}
}
// Note, if this is moved after 'set_dock_position'
// it causes an infinite loop.
if workspace.left_sidebar().read(cx).is_open()
!= serialized_workspace.left_sidebar_open
{
workspace.toggle_sidebar(SidebarSide::Left, cx);
}
// Dock::set_dock_position(workspace, serialized_workspace.dock_position, cx);
// Note that without after_window, the focus_self() and
// the focus the dock generates start generating alternating
// focus due to the deferred execution each triggering each other
cx.after_window_update(move |workspace, cx| {
Dock::set_dock_position(workspace, serialized_workspace.dock_position, cx);
});
cx.notify();
});
@@ -2537,7 +2558,7 @@ impl View for Workspace {
} else {
for pane in self.panes() {
let view = view.clone();
if pane.update(cx, |_, cx| cx.is_child(view)) {
if pane.update(cx, |_, cx| view.id() == cx.view_id() || cx.is_child(view)) {
self.handle_pane_focused(pane.clone(), cx);
break;
}
@@ -2613,6 +2634,10 @@ pub fn activate_workspace_for_project(
None
}
pub fn last_opened_workspace_paths() -> Option<WorkspaceLocation> {
DB.last_workspace().log_err().flatten()
}
#[allow(clippy::type_complexity)]
pub fn open_paths(
abs_paths: &[PathBuf],
@@ -2694,8 +2719,8 @@ mod tests {
pub fn default_item_factory(
_workspace: &mut Workspace,
_cx: &mut ViewContext<Workspace>,
) -> Box<dyn ItemHandle> {
unimplemented!();
) -> Option<Box<dyn ItemHandle>> {
unimplemented!()
}
#[gpui::test]

View File

@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
description = "The fast, collaborative code editor."
edition = "2021"
name = "zed"
version = "0.67.0"
version = "0.68.0"
[lib]
name = "zed"
@@ -48,7 +48,7 @@ rpc = { path = "../rpc" }
settings = { path = "../settings" }
sum_tree = { path = "../sum_tree" }
text = { path = "../text" }
terminal = { path = "../terminal" }
terminal_view = { path = "../terminal_view" }
theme = { path = "../theme" }
theme_selector = { path = "../theme_selector" }
theme_testbench = { path = "../theme_testbench" }

View File

@@ -32,13 +32,15 @@ use settings::{
use smol::process::Command;
use std::fs::OpenOptions;
use std::{env, ffi::OsStr, panic, path::PathBuf, sync::Arc, thread, time::Duration};
use terminal::terminal_container_view::{get_working_directory, TerminalContainer};
use terminal_view::{get_working_directory, TerminalView};
use fs::RealFs;
use settings::watched_json::{watch_keymap_file, watch_settings_file, WatchedJsonFile};
use theme::ThemeRegistry;
use util::{channel::RELEASE_CHANNEL, paths, ResultExt, TryFutureExt};
use workspace::{self, item::ItemHandle, AppState, NewFile, OpenPaths, Workspace};
use workspace::{
self, item::ItemHandle, notifications::NotifyResultExt, AppState, NewFile, OpenPaths, Workspace,
};
use zed::{self, build_window_options, initialize_workspace, languages, menus};
fn main() {
@@ -119,7 +121,7 @@ fn main() {
diagnostics::init(cx);
search::init(cx);
vim::init(cx);
terminal::init(cx);
terminal_view::init(cx);
theme_testbench::init(cx);
cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx))
@@ -150,7 +152,7 @@ fn main() {
fs,
build_window_options,
initialize_workspace,
default_item_factory,
dock_default_item_factory,
});
auto_update::init(http, client::ZED_SERVER_URL.clone(), cx);
@@ -167,7 +169,7 @@ fn main() {
cx.platform().activate(true);
let paths = collect_path_args();
if paths.is_empty() {
cx.dispatch_global_action(NewFile);
restore_or_create_workspace(cx);
} else {
cx.dispatch_global_action(OpenPaths { paths });
}
@@ -176,7 +178,7 @@ fn main() {
cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
.detach();
} else {
cx.dispatch_global_action(NewFile);
restore_or_create_workspace(cx);
}
cx.spawn(|cx| async move {
while let Some(connection) = cli_connections_rx.next().await {
@@ -200,6 +202,16 @@ fn main() {
});
}
fn restore_or_create_workspace(cx: &mut gpui::MutableAppContext) {
if let Some(location) = workspace::last_opened_workspace_paths() {
cx.dispatch_global_action(OpenPaths {
paths: location.paths().as_ref().clone(),
})
} else {
cx.dispatch_global_action(NewFile);
}
}
fn init_paths() {
std::fs::create_dir_all(&*util::paths::CONFIG_DIR).expect("could not create config path");
std::fs::create_dir_all(&*util::paths::LANGUAGES_DIR).expect("could not create languages path");
@@ -581,10 +593,10 @@ async fn handle_cli_connection(
}
}
pub fn default_item_factory(
pub fn dock_default_item_factory(
workspace: &mut Workspace,
cx: &mut ViewContext<Workspace>,
) -> Box<dyn ItemHandle> {
) -> Option<Box<dyn ItemHandle>> {
let strategy = cx
.global::<Settings>()
.terminal_overrides
@@ -594,8 +606,15 @@ pub fn default_item_factory(
let working_directory = get_working_directory(workspace, cx, strategy);
let terminal_handle = cx.add_view(|cx| {
TerminalContainer::new(working_directory, false, workspace.database_id(), cx)
});
Box::new(terminal_handle)
let window_id = cx.window_id();
let terminal = workspace
.project()
.update(cx, |project, cx| {
project.create_terminal(working_directory, window_id, cx)
})
.notify_err(workspace, cx)?;
let terminal_view = cx.add_view(|cx| TerminalView::new(terminal, workspace.database_id(), cx));
Some(Box::new(terminal_view))
}

View File

@@ -7,11 +7,11 @@ export default function simpleMessageNotification(colorScheme: ColorScheme): Obj
let layer = colorScheme.middle;
return {
message: {
...text(layer, "sans", { size: "md" }),
...text(layer, "sans", { size: "xs" }),
margin: { left: headerPadding, right: headerPadding },
},
actionMessage: {
...text(layer, "sans", { size: "md" }),
...text(layer, "sans", { size: "xs" }),
margin: { left: headerPadding, top: 6, bottom: 6 },
hover: {
color: foreground(layer, "hovered"),