Merge branch 'main' into Z-2819
This commit is contained in:
commit
6137d88a54
97 changed files with 5803 additions and 4755 deletions
|
@ -13,7 +13,7 @@ gpui = { path = "../gpui" }
|
|||
collections = { path = "../collections" }
|
||||
util = { path = "../util" }
|
||||
|
||||
rodio = "0.17.1"
|
||||
rodio ={version = "0.17.1", default-features=false, features = ["wav"]}
|
||||
|
||||
log.workspace = true
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,9 +1,8 @@
|
|||
use super::*;
|
||||
use gpui::executor::{Background, Deterministic};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(test)]
|
||||
use pretty_assertions::{assert_eq, assert_ne};
|
||||
use std::sync::Arc;
|
||||
use test_db::TestDb;
|
||||
|
||||
macro_rules! test_both_dbs {
|
||||
($postgres_test_name:ident, $sqlite_test_name:ident, $db:ident, $body:block) => {
|
125
crates/collab/src/db/ids.rs
Normal file
125
crates/collab/src/db/ids.rs
Normal file
|
@ -0,0 +1,125 @@
|
|||
use crate::Result;
|
||||
use sea_orm::DbErr;
|
||||
use sea_query::{Value, ValueTypeErr};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
macro_rules! id_type {
|
||||
($name:ident) => {
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub i32);
|
||||
|
||||
impl $name {
|
||||
#[allow(unused)]
|
||||
pub const MAX: Self = Self(i32::MAX);
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn from_proto(value: u64) -> Self {
|
||||
Self(value as i32)
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn to_proto(self) -> u64 {
|
||||
self.0 as u64
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$name> for sea_query::Value {
|
||||
fn from(value: $name) -> Self {
|
||||
sea_query::Value::Int(Some(value.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl sea_orm::TryGetable for $name {
|
||||
fn try_get(
|
||||
res: &sea_orm::QueryResult,
|
||||
pre: &str,
|
||||
col: &str,
|
||||
) -> Result<Self, sea_orm::TryGetError> {
|
||||
Ok(Self(i32::try_get(res, pre, col)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl sea_query::ValueType for $name {
|
||||
fn try_from(v: Value) -> Result<Self, sea_query::ValueTypeErr> {
|
||||
Ok(Self(value_to_integer(v)?))
|
||||
}
|
||||
|
||||
fn type_name() -> String {
|
||||
stringify!($name).into()
|
||||
}
|
||||
|
||||
fn array_type() -> sea_query::ArrayType {
|
||||
sea_query::ArrayType::Int
|
||||
}
|
||||
|
||||
fn column_type() -> sea_query::ColumnType {
|
||||
sea_query::ColumnType::Integer(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl sea_orm::TryFromU64 for $name {
|
||||
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
|
||||
Ok(Self(n.try_into().map_err(|_| {
|
||||
DbErr::ConvertFromU64(concat!(
|
||||
"error converting ",
|
||||
stringify!($name),
|
||||
" to u64"
|
||||
))
|
||||
})?))
|
||||
}
|
||||
}
|
||||
|
||||
impl sea_query::Nullable for $name {
|
||||
fn null() -> Value {
|
||||
Value::Int(None)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn value_to_integer(v: Value) -> Result<i32, ValueTypeErr> {
|
||||
match v {
|
||||
Value::TinyInt(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
|
||||
Value::SmallInt(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
|
||||
Value::Int(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
|
||||
Value::BigInt(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
|
||||
Value::TinyUnsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
|
||||
Value::SmallUnsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
|
||||
Value::Unsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
|
||||
Value::BigUnsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr),
|
||||
_ => Err(ValueTypeErr),
|
||||
}
|
||||
}
|
||||
|
||||
id_type!(AccessTokenId);
|
||||
id_type!(ChannelId);
|
||||
id_type!(ChannelMemberId);
|
||||
id_type!(ContactId);
|
||||
id_type!(FollowerId);
|
||||
id_type!(RoomId);
|
||||
id_type!(RoomParticipantId);
|
||||
id_type!(ProjectId);
|
||||
id_type!(ProjectCollaboratorId);
|
||||
id_type!(ReplicaId);
|
||||
id_type!(ServerId);
|
||||
id_type!(SignupId);
|
||||
id_type!(UserId);
|
10
crates/collab/src/db/queries.rs
Normal file
10
crates/collab/src/db/queries.rs
Normal file
|
@ -0,0 +1,10 @@
|
|||
use super::*;
|
||||
|
||||
pub mod access_tokens;
|
||||
pub mod channels;
|
||||
pub mod contacts;
|
||||
pub mod projects;
|
||||
pub mod rooms;
|
||||
pub mod servers;
|
||||
pub mod signups;
|
||||
pub mod users;
|
53
crates/collab/src/db/queries/access_tokens.rs
Normal file
53
crates/collab/src/db/queries/access_tokens.rs
Normal file
|
@ -0,0 +1,53 @@
|
|||
use super::*;
|
||||
|
||||
impl Database {
|
||||
pub async fn create_access_token(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
access_token_hash: &str,
|
||||
max_access_token_count: usize,
|
||||
) -> Result<AccessTokenId> {
|
||||
self.transaction(|tx| async {
|
||||
let tx = tx;
|
||||
|
||||
let token = access_token::ActiveModel {
|
||||
user_id: ActiveValue::set(user_id),
|
||||
hash: ActiveValue::set(access_token_hash.into()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
access_token::Entity::delete_many()
|
||||
.filter(
|
||||
access_token::Column::Id.in_subquery(
|
||||
Query::select()
|
||||
.column(access_token::Column::Id)
|
||||
.from(access_token::Entity)
|
||||
.and_where(access_token::Column::UserId.eq(user_id))
|
||||
.order_by(access_token::Column::Id, sea_orm::Order::Desc)
|
||||
.limit(10000)
|
||||
.offset(max_access_token_count as u64)
|
||||
.to_owned(),
|
||||
),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(token.id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_access_token(
|
||||
&self,
|
||||
access_token_id: AccessTokenId,
|
||||
) -> Result<access_token::Model> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(access_token::Entity::find_by_id(access_token_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such access token"))?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
697
crates/collab/src/db/queries/channels.rs
Normal file
697
crates/collab/src/db/queries/channels.rs
Normal file
|
@ -0,0 +1,697 @@
|
|||
use super::*;
|
||||
|
||||
impl Database {
|
||||
pub async fn create_root_channel(
|
||||
&self,
|
||||
name: &str,
|
||||
live_kit_room: &str,
|
||||
creator_id: UserId,
|
||||
) -> Result<ChannelId> {
|
||||
self.create_channel(name, None, live_kit_room, creator_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_channel(
|
||||
&self,
|
||||
name: &str,
|
||||
parent: Option<ChannelId>,
|
||||
live_kit_room: &str,
|
||||
creator_id: UserId,
|
||||
) -> Result<ChannelId> {
|
||||
let name = Self::sanitize_channel_name(name)?;
|
||||
self.transaction(move |tx| async move {
|
||||
if let Some(parent) = parent {
|
||||
self.check_user_is_channel_admin(parent, creator_id, &*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let channel = channel::ActiveModel {
|
||||
name: ActiveValue::Set(name.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
let channel_paths_stmt;
|
||||
if let Some(parent) = parent {
|
||||
let sql = r#"
|
||||
INSERT INTO channel_paths
|
||||
(id_path, channel_id)
|
||||
SELECT
|
||||
id_path || $1 || '/', $2
|
||||
FROM
|
||||
channel_paths
|
||||
WHERE
|
||||
channel_id = $3
|
||||
"#;
|
||||
channel_paths_stmt = Statement::from_sql_and_values(
|
||||
self.pool.get_database_backend(),
|
||||
sql,
|
||||
[
|
||||
channel.id.to_proto().into(),
|
||||
channel.id.to_proto().into(),
|
||||
parent.to_proto().into(),
|
||||
],
|
||||
);
|
||||
tx.execute(channel_paths_stmt).await?;
|
||||
} else {
|
||||
channel_path::Entity::insert(channel_path::ActiveModel {
|
||||
channel_id: ActiveValue::Set(channel.id),
|
||||
id_path: ActiveValue::Set(format!("/{}/", channel.id)),
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
channel_member::ActiveModel {
|
||||
channel_id: ActiveValue::Set(channel.id),
|
||||
user_id: ActiveValue::Set(creator_id),
|
||||
accepted: ActiveValue::Set(true),
|
||||
admin: ActiveValue::Set(true),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
room::ActiveModel {
|
||||
channel_id: ActiveValue::Set(Some(channel.id)),
|
||||
live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(channel.id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_channel(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
) -> Result<(Vec<ChannelId>, Vec<UserId>)> {
|
||||
self.transaction(move |tx| async move {
|
||||
self.check_user_is_channel_admin(channel_id, user_id, &*tx)
|
||||
.await?;
|
||||
|
||||
// Don't remove descendant channels that have additional parents.
|
||||
let mut channels_to_remove = self.get_channel_descendants([channel_id], &*tx).await?;
|
||||
{
|
||||
let mut channels_to_keep = channel_path::Entity::find()
|
||||
.filter(
|
||||
channel_path::Column::ChannelId
|
||||
.is_in(
|
||||
channels_to_remove
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&id| id != channel_id),
|
||||
)
|
||||
.and(
|
||||
channel_path::Column::IdPath
|
||||
.not_like(&format!("%/{}/%", channel_id)),
|
||||
),
|
||||
)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(row) = channels_to_keep.next().await {
|
||||
let row = row?;
|
||||
channels_to_remove.remove(&row.channel_id);
|
||||
}
|
||||
}
|
||||
|
||||
let channel_ancestors = self.get_channel_ancestors(channel_id, &*tx).await?;
|
||||
let members_to_notify: Vec<UserId> = channel_member::Entity::find()
|
||||
.filter(channel_member::Column::ChannelId.is_in(channel_ancestors))
|
||||
.select_only()
|
||||
.column(channel_member::Column::UserId)
|
||||
.distinct()
|
||||
.into_values::<_, QueryUserIds>()
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
channel::Entity::delete_many()
|
||||
.filter(channel::Column::Id.is_in(channels_to_remove.keys().copied()))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok((channels_to_remove.into_keys().collect(), members_to_notify))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn invite_channel_member(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
invitee_id: UserId,
|
||||
inviter_id: UserId,
|
||||
is_admin: bool,
|
||||
) -> Result<()> {
|
||||
self.transaction(move |tx| async move {
|
||||
self.check_user_is_channel_admin(channel_id, inviter_id, &*tx)
|
||||
.await?;
|
||||
|
||||
channel_member::ActiveModel {
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
user_id: ActiveValue::Set(invitee_id),
|
||||
accepted: ActiveValue::Set(false),
|
||||
admin: ActiveValue::Set(is_admin),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn sanitize_channel_name(name: &str) -> Result<&str> {
|
||||
let new_name = name.trim().trim_start_matches('#');
|
||||
if new_name == "" {
|
||||
Err(anyhow!("channel name can't be blank"))?;
|
||||
}
|
||||
Ok(new_name)
|
||||
}
|
||||
|
||||
pub async fn rename_channel(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
new_name: &str,
|
||||
) -> Result<String> {
|
||||
self.transaction(move |tx| async move {
|
||||
let new_name = Self::sanitize_channel_name(new_name)?.to_string();
|
||||
|
||||
self.check_user_is_channel_admin(channel_id, user_id, &*tx)
|
||||
.await?;
|
||||
|
||||
channel::ActiveModel {
|
||||
id: ActiveValue::Unchanged(channel_id),
|
||||
name: ActiveValue::Set(new_name.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
.update(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(new_name)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn respond_to_channel_invite(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
accept: bool,
|
||||
) -> Result<()> {
|
||||
self.transaction(move |tx| async move {
|
||||
let rows_affected = if accept {
|
||||
channel_member::Entity::update_many()
|
||||
.set(channel_member::ActiveModel {
|
||||
accepted: ActiveValue::Set(accept),
|
||||
..Default::default()
|
||||
})
|
||||
.filter(
|
||||
channel_member::Column::ChannelId
|
||||
.eq(channel_id)
|
||||
.and(channel_member::Column::UserId.eq(user_id))
|
||||
.and(channel_member::Column::Accepted.eq(false)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?
|
||||
.rows_affected
|
||||
} else {
|
||||
channel_member::ActiveModel {
|
||||
channel_id: ActiveValue::Unchanged(channel_id),
|
||||
user_id: ActiveValue::Unchanged(user_id),
|
||||
..Default::default()
|
||||
}
|
||||
.delete(&*tx)
|
||||
.await?
|
||||
.rows_affected
|
||||
};
|
||||
|
||||
if rows_affected == 0 {
|
||||
Err(anyhow!("no such invitation"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_channel_member(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
member_id: UserId,
|
||||
remover_id: UserId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
self.check_user_is_channel_admin(channel_id, remover_id, &*tx)
|
||||
.await?;
|
||||
|
||||
let result = channel_member::Entity::delete_many()
|
||||
.filter(
|
||||
channel_member::Column::ChannelId
|
||||
.eq(channel_id)
|
||||
.and(channel_member::Column::UserId.eq(member_id)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected == 0 {
|
||||
Err(anyhow!("no such member"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_channel_invites_for_user(&self, user_id: UserId) -> Result<Vec<Channel>> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel_invites = channel_member::Entity::find()
|
||||
.filter(
|
||||
channel_member::Column::UserId
|
||||
.eq(user_id)
|
||||
.and(channel_member::Column::Accepted.eq(false)),
|
||||
)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let channels = channel::Entity::find()
|
||||
.filter(
|
||||
channel::Column::Id.is_in(
|
||||
channel_invites
|
||||
.into_iter()
|
||||
.map(|channel_member| channel_member.channel_id),
|
||||
),
|
||||
)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let channels = channels
|
||||
.into_iter()
|
||||
.map(|channel| Channel {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
parent_id: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(channels)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_channels_for_user(&self, user_id: UserId) -> Result<ChannelsForUser> {
|
||||
self.transaction(|tx| async move {
|
||||
let tx = tx;
|
||||
|
||||
let channel_memberships = channel_member::Entity::find()
|
||||
.filter(
|
||||
channel_member::Column::UserId
|
||||
.eq(user_id)
|
||||
.and(channel_member::Column::Accepted.eq(true)),
|
||||
)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let parents_by_child_id = self
|
||||
.get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx)
|
||||
.await?;
|
||||
|
||||
let channels_with_admin_privileges = channel_memberships
|
||||
.iter()
|
||||
.filter_map(|membership| membership.admin.then_some(membership.channel_id))
|
||||
.collect();
|
||||
|
||||
let mut channels = Vec::with_capacity(parents_by_child_id.len());
|
||||
{
|
||||
let mut rows = channel::Entity::find()
|
||||
.filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied()))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
channels.push(Channel {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
parent_id: parents_by_child_id.get(&row.id).copied().flatten(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryUserIdsAndChannelIds {
|
||||
ChannelId,
|
||||
UserId,
|
||||
}
|
||||
|
||||
let mut channel_participants: HashMap<ChannelId, Vec<UserId>> = HashMap::default();
|
||||
{
|
||||
let mut rows = room_participant::Entity::find()
|
||||
.inner_join(room::Entity)
|
||||
.filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id)))
|
||||
.select_only()
|
||||
.column(room::Column::ChannelId)
|
||||
.column(room_participant::Column::UserId)
|
||||
.into_values::<_, QueryUserIdsAndChannelIds>()
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row: (ChannelId, UserId) = row?;
|
||||
channel_participants.entry(row.0).or_default().push(row.1)
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ChannelsForUser {
|
||||
channels,
|
||||
channel_participants,
|
||||
channels_with_admin_privileges,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_channel_members(&self, id: ChannelId) -> Result<Vec<UserId>> {
|
||||
self.transaction(|tx| async move { self.get_channel_members_internal(id, &*tx).await })
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_channel_member_admin(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
from: UserId,
|
||||
for_user: UserId,
|
||||
admin: bool,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
self.check_user_is_channel_admin(channel_id, from, &*tx)
|
||||
.await?;
|
||||
|
||||
let result = channel_member::Entity::update_many()
|
||||
.filter(
|
||||
channel_member::Column::ChannelId
|
||||
.eq(channel_id)
|
||||
.and(channel_member::Column::UserId.eq(for_user)),
|
||||
)
|
||||
.set(channel_member::ActiveModel {
|
||||
admin: ActiveValue::set(admin),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected == 0 {
|
||||
Err(anyhow!("no such member"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_channel_member_details(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
) -> Result<Vec<proto::ChannelMember>> {
|
||||
self.transaction(|tx| async move {
|
||||
self.check_user_is_channel_admin(channel_id, user_id, &*tx)
|
||||
.await?;
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryMemberDetails {
|
||||
UserId,
|
||||
Admin,
|
||||
IsDirectMember,
|
||||
Accepted,
|
||||
}
|
||||
|
||||
let tx = tx;
|
||||
let ancestor_ids = self.get_channel_ancestors(channel_id, &*tx).await?;
|
||||
let mut stream = channel_member::Entity::find()
|
||||
.distinct()
|
||||
.filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied()))
|
||||
.select_only()
|
||||
.column(channel_member::Column::UserId)
|
||||
.column(channel_member::Column::Admin)
|
||||
.column_as(
|
||||
channel_member::Column::ChannelId.eq(channel_id),
|
||||
QueryMemberDetails::IsDirectMember,
|
||||
)
|
||||
.column(channel_member::Column::Accepted)
|
||||
.order_by_asc(channel_member::Column::UserId)
|
||||
.into_values::<_, QueryMemberDetails>()
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut rows = Vec::<proto::ChannelMember>::new();
|
||||
while let Some(row) = stream.next().await {
|
||||
let (user_id, is_admin, is_direct_member, is_invite_accepted): (
|
||||
UserId,
|
||||
bool,
|
||||
bool,
|
||||
bool,
|
||||
) = row?;
|
||||
let kind = match (is_direct_member, is_invite_accepted) {
|
||||
(true, true) => proto::channel_member::Kind::Member,
|
||||
(true, false) => proto::channel_member::Kind::Invitee,
|
||||
(false, true) => proto::channel_member::Kind::AncestorMember,
|
||||
(false, false) => continue,
|
||||
};
|
||||
let user_id = user_id.to_proto();
|
||||
let kind = kind.into();
|
||||
if let Some(last_row) = rows.last_mut() {
|
||||
if last_row.user_id == user_id {
|
||||
if is_direct_member {
|
||||
last_row.kind = kind;
|
||||
last_row.admin = is_admin;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rows.push(proto::ChannelMember {
|
||||
user_id,
|
||||
kind,
|
||||
admin: is_admin,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(rows)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_channel_members_internal(
|
||||
&self,
|
||||
id: ChannelId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<UserId>> {
|
||||
let ancestor_ids = self.get_channel_ancestors(id, tx).await?;
|
||||
let user_ids = channel_member::Entity::find()
|
||||
.distinct()
|
||||
.filter(
|
||||
channel_member::Column::ChannelId
|
||||
.is_in(ancestor_ids.iter().copied())
|
||||
.and(channel_member::Column::Accepted.eq(true)),
|
||||
)
|
||||
.select_only()
|
||||
.column(channel_member::Column::UserId)
|
||||
.into_values::<_, QueryUserIds>()
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
Ok(user_ids)
|
||||
}
|
||||
|
||||
pub async fn check_user_is_channel_member(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
|
||||
channel_member::Entity::find()
|
||||
.filter(
|
||||
channel_member::Column::ChannelId
|
||||
.is_in(channel_ids)
|
||||
.and(channel_member::Column::UserId.eq(user_id)),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_user_is_channel_admin(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
|
||||
channel_member::Entity::find()
|
||||
.filter(
|
||||
channel_member::Column::ChannelId
|
||||
.is_in(channel_ids)
|
||||
.and(channel_member::Column::UserId.eq(user_id))
|
||||
.and(channel_member::Column::Admin.eq(true)),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("user is not a channel admin or channel does not exist"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_channel_ancestors(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<ChannelId>> {
|
||||
let paths = channel_path::Entity::find()
|
||||
.filter(channel_path::Column::ChannelId.eq(channel_id))
|
||||
.all(tx)
|
||||
.await?;
|
||||
let mut channel_ids = Vec::new();
|
||||
for path in paths {
|
||||
for id in path.id_path.trim_matches('/').split('/') {
|
||||
if let Ok(id) = id.parse() {
|
||||
let id = ChannelId::from_proto(id);
|
||||
if let Err(ix) = channel_ids.binary_search(&id) {
|
||||
channel_ids.insert(ix, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(channel_ids)
|
||||
}
|
||||
|
||||
async fn get_channel_descendants(
|
||||
&self,
|
||||
channel_ids: impl IntoIterator<Item = ChannelId>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<HashMap<ChannelId, Option<ChannelId>>> {
|
||||
let mut values = String::new();
|
||||
for id in channel_ids {
|
||||
if !values.is_empty() {
|
||||
values.push_str(", ");
|
||||
}
|
||||
write!(&mut values, "({})", id).unwrap();
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
return Ok(HashMap::default());
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
descendant_paths.*
|
||||
FROM
|
||||
channel_paths parent_paths, channel_paths descendant_paths
|
||||
WHERE
|
||||
parent_paths.channel_id IN ({values}) AND
|
||||
descendant_paths.id_path LIKE (parent_paths.id_path || '%')
|
||||
"#
|
||||
);
|
||||
|
||||
let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
|
||||
|
||||
let mut parents_by_child_id = HashMap::default();
|
||||
let mut paths = channel_path::Entity::find()
|
||||
.from_raw_sql(stmt)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
|
||||
while let Some(path) = paths.next().await {
|
||||
let path = path?;
|
||||
let ids = path.id_path.trim_matches('/').split('/');
|
||||
let mut parent_id = None;
|
||||
for id in ids {
|
||||
if let Ok(id) = id.parse() {
|
||||
let id = ChannelId::from_proto(id);
|
||||
if id == path.channel_id {
|
||||
break;
|
||||
}
|
||||
parent_id = Some(id);
|
||||
}
|
||||
}
|
||||
parents_by_child_id.insert(path.channel_id, parent_id);
|
||||
}
|
||||
|
||||
Ok(parents_by_child_id)
|
||||
}
|
||||
|
||||
/// Returns the channel with the given ID and:
|
||||
/// - true if the user is a member
|
||||
/// - false if the user hasn't accepted the invitation yet
|
||||
pub async fn get_channel(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
) -> Result<Option<(Channel, bool)>> {
|
||||
self.transaction(|tx| async move {
|
||||
let tx = tx;
|
||||
|
||||
let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?;
|
||||
|
||||
if let Some(channel) = channel {
|
||||
if self
|
||||
.check_user_is_channel_member(channel_id, user_id, &*tx)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let channel_membership = channel_member::Entity::find()
|
||||
.filter(
|
||||
channel_member::Column::ChannelId
|
||||
.eq(channel_id)
|
||||
.and(channel_member::Column::UserId.eq(user_id)),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
|
||||
let is_accepted = channel_membership
|
||||
.map(|membership| membership.accepted)
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Some((
|
||||
Channel {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
parent_id: None,
|
||||
},
|
||||
is_accepted,
|
||||
)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn room_id_for_channel(&self, channel_id: ChannelId) -> Result<RoomId> {
|
||||
self.transaction(|tx| async move {
|
||||
let tx = tx;
|
||||
let room = channel::Model {
|
||||
id: channel_id,
|
||||
..Default::default()
|
||||
}
|
||||
.find_related(room::Entity)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("invalid channel"))?;
|
||||
Ok(room.id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryUserIds {
|
||||
UserId,
|
||||
}
|
298
crates/collab/src/db/queries/contacts.rs
Normal file
298
crates/collab/src/db/queries/contacts.rs
Normal file
|
@ -0,0 +1,298 @@
|
|||
use super::*;
|
||||
|
||||
impl Database {
|
||||
pub async fn get_contacts(&self, user_id: UserId) -> Result<Vec<Contact>> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct ContactWithUserBusyStatuses {
|
||||
user_id_a: UserId,
|
||||
user_id_b: UserId,
|
||||
a_to_b: bool,
|
||||
accepted: bool,
|
||||
should_notify: bool,
|
||||
user_a_busy: bool,
|
||||
user_b_busy: bool,
|
||||
}
|
||||
|
||||
self.transaction(|tx| async move {
|
||||
let user_a_participant = Alias::new("user_a_participant");
|
||||
let user_b_participant = Alias::new("user_b_participant");
|
||||
let mut db_contacts = contact::Entity::find()
|
||||
.column_as(
|
||||
Expr::tbl(user_a_participant.clone(), room_participant::Column::Id)
|
||||
.is_not_null(),
|
||||
"user_a_busy",
|
||||
)
|
||||
.column_as(
|
||||
Expr::tbl(user_b_participant.clone(), room_participant::Column::Id)
|
||||
.is_not_null(),
|
||||
"user_b_busy",
|
||||
)
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(user_id)
|
||||
.or(contact::Column::UserIdB.eq(user_id)),
|
||||
)
|
||||
.join_as(
|
||||
JoinType::LeftJoin,
|
||||
contact::Relation::UserARoomParticipant.def(),
|
||||
user_a_participant,
|
||||
)
|
||||
.join_as(
|
||||
JoinType::LeftJoin,
|
||||
contact::Relation::UserBRoomParticipant.def(),
|
||||
user_b_participant,
|
||||
)
|
||||
.into_model::<ContactWithUserBusyStatuses>()
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
while let Some(db_contact) = db_contacts.next().await {
|
||||
let db_contact = db_contact?;
|
||||
if db_contact.user_id_a == user_id {
|
||||
if db_contact.accepted {
|
||||
contacts.push(Contact::Accepted {
|
||||
user_id: db_contact.user_id_b,
|
||||
should_notify: db_contact.should_notify && db_contact.a_to_b,
|
||||
busy: db_contact.user_b_busy,
|
||||
});
|
||||
} else if db_contact.a_to_b {
|
||||
contacts.push(Contact::Outgoing {
|
||||
user_id: db_contact.user_id_b,
|
||||
})
|
||||
} else {
|
||||
contacts.push(Contact::Incoming {
|
||||
user_id: db_contact.user_id_b,
|
||||
should_notify: db_contact.should_notify,
|
||||
});
|
||||
}
|
||||
} else if db_contact.accepted {
|
||||
contacts.push(Contact::Accepted {
|
||||
user_id: db_contact.user_id_a,
|
||||
should_notify: db_contact.should_notify && !db_contact.a_to_b,
|
||||
busy: db_contact.user_a_busy,
|
||||
});
|
||||
} else if db_contact.a_to_b {
|
||||
contacts.push(Contact::Incoming {
|
||||
user_id: db_contact.user_id_a,
|
||||
should_notify: db_contact.should_notify,
|
||||
});
|
||||
} else {
|
||||
contacts.push(Contact::Outgoing {
|
||||
user_id: db_contact.user_id_a,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
contacts.sort_unstable_by_key(|contact| contact.user_id());
|
||||
|
||||
Ok(contacts)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn is_user_busy(&self, user_id: UserId) -> Result<bool> {
|
||||
self.transaction(|tx| async move {
|
||||
let participant = room_participant::Entity::find()
|
||||
.filter(room_participant::Column::UserId.eq(user_id))
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
Ok(participant.is_some())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn has_contact(&self, user_id_1: UserId, user_id_2: UserId) -> Result<bool> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b) = if user_id_1 < user_id_2 {
|
||||
(user_id_1, user_id_2)
|
||||
} else {
|
||||
(user_id_2, user_id_1)
|
||||
};
|
||||
|
||||
Ok(contact::Entity::find()
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b))
|
||||
.and(contact::Column::Accepted.eq(true)),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.is_some())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
|
||||
(sender_id, receiver_id, true)
|
||||
} else {
|
||||
(receiver_id, sender_id, false)
|
||||
};
|
||||
|
||||
let rows_affected = contact::Entity::insert(contact::ActiveModel {
|
||||
user_id_a: ActiveValue::set(id_a),
|
||||
user_id_b: ActiveValue::set(id_b),
|
||||
a_to_b: ActiveValue::set(a_to_b),
|
||||
accepted: ActiveValue::set(false),
|
||||
should_notify: ActiveValue::set(true),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([contact::Column::UserIdA, contact::Column::UserIdB])
|
||||
.values([
|
||||
(contact::Column::Accepted, true.into()),
|
||||
(contact::Column::ShouldNotify, false.into()),
|
||||
])
|
||||
.action_and_where(
|
||||
contact::Column::Accepted.eq(false).and(
|
||||
contact::Column::AToB
|
||||
.eq(a_to_b)
|
||||
.and(contact::Column::UserIdA.eq(id_b))
|
||||
.or(contact::Column::AToB
|
||||
.ne(a_to_b)
|
||||
.and(contact::Column::UserIdA.eq(id_a))),
|
||||
),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_without_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
if rows_affected == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("contact already requested"))?
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns a bool indicating whether the removed contact had originally accepted or not
|
||||
///
|
||||
/// Deletes the contact identified by the requester and responder ids, and then returns
|
||||
/// whether the deleted contact had originally accepted or was a pending contact request.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `requester_id` - The user that initiates this request
|
||||
/// * `responder_id` - The user that will be removed
|
||||
pub async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<bool> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b) = if responder_id < requester_id {
|
||||
(responder_id, requester_id)
|
||||
} else {
|
||||
(requester_id, responder_id)
|
||||
};
|
||||
|
||||
let contact = contact::Entity::find()
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b)),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such contact"))?;
|
||||
|
||||
contact::Entity::delete_by_id(contact.id).exec(&*tx).await?;
|
||||
Ok(contact.accepted)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn dismiss_contact_notification(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
contact_user_id: UserId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b, a_to_b) = if user_id < contact_user_id {
|
||||
(user_id, contact_user_id, true)
|
||||
} else {
|
||||
(contact_user_id, user_id, false)
|
||||
};
|
||||
|
||||
let result = contact::Entity::update_many()
|
||||
.set(contact::ActiveModel {
|
||||
should_notify: ActiveValue::set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b))
|
||||
.and(
|
||||
contact::Column::AToB
|
||||
.eq(a_to_b)
|
||||
.and(contact::Column::Accepted.eq(true))
|
||||
.or(contact::Column::AToB
|
||||
.ne(a_to_b)
|
||||
.and(contact::Column::Accepted.eq(false))),
|
||||
),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
Err(anyhow!("no such contact request"))?
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn respond_to_contact_request(
|
||||
&self,
|
||||
responder_id: UserId,
|
||||
requester_id: UserId,
|
||||
accept: bool,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b, a_to_b) = if responder_id < requester_id {
|
||||
(responder_id, requester_id, false)
|
||||
} else {
|
||||
(requester_id, responder_id, true)
|
||||
};
|
||||
let rows_affected = if accept {
|
||||
let result = contact::Entity::update_many()
|
||||
.set(contact::ActiveModel {
|
||||
accepted: ActiveValue::set(true),
|
||||
should_notify: ActiveValue::set(true),
|
||||
..Default::default()
|
||||
})
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b))
|
||||
.and(contact::Column::AToB.eq(a_to_b)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
result.rows_affected
|
||||
} else {
|
||||
let result = contact::Entity::delete_many()
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b))
|
||||
.and(contact::Column::AToB.eq(a_to_b))
|
||||
.and(contact::Column::Accepted.eq(false)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
result.rows_affected
|
||||
};
|
||||
|
||||
if rows_affected == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("no such contact request"))?
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
926
crates/collab/src/db/queries/projects.rs
Normal file
926
crates/collab/src/db/queries/projects.rs
Normal file
|
@ -0,0 +1,926 @@
|
|||
use super::*;
|
||||
|
||||
impl Database {
|
||||
pub async fn project_count_excluding_admins(&self) -> Result<usize> {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryAs {
|
||||
Count,
|
||||
}
|
||||
|
||||
self.transaction(|tx| async move {
|
||||
Ok(project::Entity::find()
|
||||
.select_only()
|
||||
.column_as(project::Column::Id.count(), QueryAs::Count)
|
||||
.inner_join(user::Entity)
|
||||
.filter(user::Column::Admin.eq(false))
|
||||
.into_values::<_, QueryAs>()
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.unwrap_or(0i64) as usize)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn share_project(
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
connection: ConnectionId,
|
||||
worktrees: &[proto::WorktreeMetadata],
|
||||
) -> Result<RoomGuard<(ProjectId, proto::Room)>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let participant = room_participant::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(
|
||||
room_participant::Column::AnsweringConnectionId
|
||||
.eq(connection.id as i32),
|
||||
)
|
||||
.add(
|
||||
room_participant::Column::AnsweringConnectionServerId
|
||||
.eq(connection.owner_id as i32),
|
||||
),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("could not find participant"))?;
|
||||
if participant.room_id != room_id {
|
||||
return Err(anyhow!("shared project on unexpected room"))?;
|
||||
}
|
||||
|
||||
let project = project::ActiveModel {
|
||||
room_id: ActiveValue::set(participant.room_id),
|
||||
host_user_id: ActiveValue::set(participant.user_id),
|
||||
host_connection_id: ActiveValue::set(Some(connection.id as i32)),
|
||||
host_connection_server_id: ActiveValue::set(Some(ServerId(
|
||||
connection.owner_id as i32,
|
||||
))),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
if !worktrees.is_empty() {
|
||||
worktree::Entity::insert_many(worktrees.iter().map(|worktree| {
|
||||
worktree::ActiveModel {
|
||||
id: ActiveValue::set(worktree.id as i64),
|
||||
project_id: ActiveValue::set(project.id),
|
||||
abs_path: ActiveValue::set(worktree.abs_path.clone()),
|
||||
root_name: ActiveValue::set(worktree.root_name.clone()),
|
||||
visible: ActiveValue::set(worktree.visible),
|
||||
scan_id: ActiveValue::set(0),
|
||||
completed_scan_id: ActiveValue::set(0),
|
||||
}
|
||||
}))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
project_collaborator::ActiveModel {
|
||||
project_id: ActiveValue::set(project.id),
|
||||
connection_id: ActiveValue::set(connection.id as i32),
|
||||
connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
|
||||
user_id: ActiveValue::set(participant.user_id),
|
||||
replica_id: ActiveValue::set(ReplicaId(0)),
|
||||
is_host: ActiveValue::set(true),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
let room = self.get_room(room_id, &tx).await?;
|
||||
Ok((project.id, room))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unshare_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
|
||||
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("project not found"))?;
|
||||
if project.host_connection()? == connection {
|
||||
project::Entity::delete(project.into_active_model())
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
let room = self.get_room(room_id, &tx).await?;
|
||||
Ok((room, guest_connection_ids))
|
||||
} else {
|
||||
Err(anyhow!("cannot unshare a project hosted by another user"))?
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection: ConnectionId,
|
||||
worktrees: &[proto::WorktreeMetadata],
|
||||
) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(project::Column::HostConnectionId.eq(connection.id as i32))
|
||||
.add(
|
||||
project::Column::HostConnectionServerId.eq(connection.owner_id as i32),
|
||||
),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
|
||||
self.update_project_worktrees(project.id, worktrees, &tx)
|
||||
.await?;
|
||||
|
||||
let guest_connection_ids = self.project_guest_connection_ids(project.id, &tx).await?;
|
||||
let room = self.get_room(project.room_id, &tx).await?;
|
||||
Ok((room, guest_connection_ids))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(in crate::db) async fn update_project_worktrees(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
worktrees: &[proto::WorktreeMetadata],
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
if !worktrees.is_empty() {
|
||||
worktree::Entity::insert_many(worktrees.iter().map(|worktree| worktree::ActiveModel {
|
||||
id: ActiveValue::set(worktree.id as i64),
|
||||
project_id: ActiveValue::set(project_id),
|
||||
abs_path: ActiveValue::set(worktree.abs_path.clone()),
|
||||
root_name: ActiveValue::set(worktree.root_name.clone()),
|
||||
visible: ActiveValue::set(worktree.visible),
|
||||
scan_id: ActiveValue::set(0),
|
||||
completed_scan_id: ActiveValue::set(0),
|
||||
}))
|
||||
.on_conflict(
|
||||
OnConflict::columns([worktree::Column::ProjectId, worktree::Column::Id])
|
||||
.update_column(worktree::Column::RootName)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
worktree::Entity::delete_many()
|
||||
.filter(worktree::Column::ProjectId.eq(project_id).and(
|
||||
worktree::Column::Id.is_not_in(worktrees.iter().map(|worktree| worktree.id as i64)),
|
||||
))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_worktree(
|
||||
&self,
|
||||
update: &proto::UpdateWorktree,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<Vec<ConnectionId>>> {
|
||||
let project_id = ProjectId::from_proto(update.project_id);
|
||||
let worktree_id = update.worktree_id as i64;
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
// Ensure the update comes from the host.
|
||||
let _project = project::Entity::find_by_id(project_id)
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(project::Column::HostConnectionId.eq(connection.id as i32))
|
||||
.add(
|
||||
project::Column::HostConnectionServerId.eq(connection.owner_id as i32),
|
||||
),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
|
||||
// Update metadata.
|
||||
worktree::Entity::update(worktree::ActiveModel {
|
||||
id: ActiveValue::set(worktree_id),
|
||||
project_id: ActiveValue::set(project_id),
|
||||
root_name: ActiveValue::set(update.root_name.clone()),
|
||||
scan_id: ActiveValue::set(update.scan_id as i64),
|
||||
completed_scan_id: if update.is_last_update {
|
||||
ActiveValue::set(update.scan_id as i64)
|
||||
} else {
|
||||
ActiveValue::default()
|
||||
},
|
||||
abs_path: ActiveValue::set(update.abs_path.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
if !update.updated_entries.is_empty() {
|
||||
worktree_entry::Entity::insert_many(update.updated_entries.iter().map(|entry| {
|
||||
let mtime = entry.mtime.clone().unwrap_or_default();
|
||||
worktree_entry::ActiveModel {
|
||||
project_id: ActiveValue::set(project_id),
|
||||
worktree_id: ActiveValue::set(worktree_id),
|
||||
id: ActiveValue::set(entry.id as i64),
|
||||
is_dir: ActiveValue::set(entry.is_dir),
|
||||
path: ActiveValue::set(entry.path.clone()),
|
||||
inode: ActiveValue::set(entry.inode as i64),
|
||||
mtime_seconds: ActiveValue::set(mtime.seconds as i64),
|
||||
mtime_nanos: ActiveValue::set(mtime.nanos as i32),
|
||||
is_symlink: ActiveValue::set(entry.is_symlink),
|
||||
is_ignored: ActiveValue::set(entry.is_ignored),
|
||||
is_external: ActiveValue::set(entry.is_external),
|
||||
git_status: ActiveValue::set(entry.git_status.map(|status| status as i64)),
|
||||
is_deleted: ActiveValue::set(false),
|
||||
scan_id: ActiveValue::set(update.scan_id as i64),
|
||||
}
|
||||
}))
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
worktree_entry::Column::ProjectId,
|
||||
worktree_entry::Column::WorktreeId,
|
||||
worktree_entry::Column::Id,
|
||||
])
|
||||
.update_columns([
|
||||
worktree_entry::Column::IsDir,
|
||||
worktree_entry::Column::Path,
|
||||
worktree_entry::Column::Inode,
|
||||
worktree_entry::Column::MtimeSeconds,
|
||||
worktree_entry::Column::MtimeNanos,
|
||||
worktree_entry::Column::IsSymlink,
|
||||
worktree_entry::Column::IsIgnored,
|
||||
worktree_entry::Column::GitStatus,
|
||||
worktree_entry::Column::ScanId,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !update.removed_entries.is_empty() {
|
||||
worktree_entry::Entity::update_many()
|
||||
.filter(
|
||||
worktree_entry::Column::ProjectId
|
||||
.eq(project_id)
|
||||
.and(worktree_entry::Column::WorktreeId.eq(worktree_id))
|
||||
.and(
|
||||
worktree_entry::Column::Id
|
||||
.is_in(update.removed_entries.iter().map(|id| *id as i64)),
|
||||
),
|
||||
)
|
||||
.set(worktree_entry::ActiveModel {
|
||||
is_deleted: ActiveValue::Set(true),
|
||||
scan_id: ActiveValue::Set(update.scan_id as i64),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !update.updated_repositories.is_empty() {
|
||||
worktree_repository::Entity::insert_many(update.updated_repositories.iter().map(
|
||||
|repository| worktree_repository::ActiveModel {
|
||||
project_id: ActiveValue::set(project_id),
|
||||
worktree_id: ActiveValue::set(worktree_id),
|
||||
work_directory_id: ActiveValue::set(repository.work_directory_id as i64),
|
||||
scan_id: ActiveValue::set(update.scan_id as i64),
|
||||
branch: ActiveValue::set(repository.branch.clone()),
|
||||
is_deleted: ActiveValue::set(false),
|
||||
},
|
||||
))
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
worktree_repository::Column::ProjectId,
|
||||
worktree_repository::Column::WorktreeId,
|
||||
worktree_repository::Column::WorkDirectoryId,
|
||||
])
|
||||
.update_columns([
|
||||
worktree_repository::Column::ScanId,
|
||||
worktree_repository::Column::Branch,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !update.removed_repositories.is_empty() {
|
||||
worktree_repository::Entity::update_many()
|
||||
.filter(
|
||||
worktree_repository::Column::ProjectId
|
||||
.eq(project_id)
|
||||
.and(worktree_repository::Column::WorktreeId.eq(worktree_id))
|
||||
.and(
|
||||
worktree_repository::Column::WorkDirectoryId
|
||||
.is_in(update.removed_repositories.iter().map(|id| *id as i64)),
|
||||
),
|
||||
)
|
||||
.set(worktree_repository::ActiveModel {
|
||||
is_deleted: ActiveValue::Set(true),
|
||||
scan_id: ActiveValue::Set(update.scan_id as i64),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
|
||||
Ok(connection_ids)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_diagnostic_summary(
|
||||
&self,
|
||||
update: &proto::UpdateDiagnosticSummary,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<Vec<ConnectionId>>> {
|
||||
let project_id = ProjectId::from_proto(update.project_id);
|
||||
let worktree_id = update.worktree_id as i64;
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let summary = update
|
||||
.summary
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("invalid summary"))?;
|
||||
|
||||
// Ensure the update comes from the host.
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
if project.host_connection()? != connection {
|
||||
return Err(anyhow!("can't update a project hosted by someone else"))?;
|
||||
}
|
||||
|
||||
// Update summary.
|
||||
worktree_diagnostic_summary::Entity::insert(worktree_diagnostic_summary::ActiveModel {
|
||||
project_id: ActiveValue::set(project_id),
|
||||
worktree_id: ActiveValue::set(worktree_id),
|
||||
path: ActiveValue::set(summary.path.clone()),
|
||||
language_server_id: ActiveValue::set(summary.language_server_id as i64),
|
||||
error_count: ActiveValue::set(summary.error_count as i32),
|
||||
warning_count: ActiveValue::set(summary.warning_count as i32),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
worktree_diagnostic_summary::Column::ProjectId,
|
||||
worktree_diagnostic_summary::Column::WorktreeId,
|
||||
worktree_diagnostic_summary::Column::Path,
|
||||
])
|
||||
.update_columns([
|
||||
worktree_diagnostic_summary::Column::LanguageServerId,
|
||||
worktree_diagnostic_summary::Column::ErrorCount,
|
||||
worktree_diagnostic_summary::Column::WarningCount,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
|
||||
Ok(connection_ids)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn start_language_server(
|
||||
&self,
|
||||
update: &proto::StartLanguageServer,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<Vec<ConnectionId>>> {
|
||||
let project_id = ProjectId::from_proto(update.project_id);
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let server = update
|
||||
.server
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("invalid language server"))?;
|
||||
|
||||
// Ensure the update comes from the host.
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
if project.host_connection()? != connection {
|
||||
return Err(anyhow!("can't update a project hosted by someone else"))?;
|
||||
}
|
||||
|
||||
// Add the newly-started language server.
|
||||
language_server::Entity::insert(language_server::ActiveModel {
|
||||
project_id: ActiveValue::set(project_id),
|
||||
id: ActiveValue::set(server.id as i64),
|
||||
name: ActiveValue::set(server.name.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
language_server::Column::ProjectId,
|
||||
language_server::Column::Id,
|
||||
])
|
||||
.update_column(language_server::Column::Name)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
|
||||
Ok(connection_ids)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_worktree_settings(
|
||||
&self,
|
||||
update: &proto::UpdateWorktreeSettings,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<Vec<ConnectionId>>> {
|
||||
let project_id = ProjectId::from_proto(update.project_id);
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
// Ensure the update comes from the host.
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
if project.host_connection()? != connection {
|
||||
return Err(anyhow!("can't update a project hosted by someone else"))?;
|
||||
}
|
||||
|
||||
if let Some(content) = &update.content {
|
||||
worktree_settings_file::Entity::insert(worktree_settings_file::ActiveModel {
|
||||
project_id: ActiveValue::Set(project_id),
|
||||
worktree_id: ActiveValue::Set(update.worktree_id as i64),
|
||||
path: ActiveValue::Set(update.path.clone()),
|
||||
content: ActiveValue::Set(content.clone()),
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
worktree_settings_file::Column::ProjectId,
|
||||
worktree_settings_file::Column::WorktreeId,
|
||||
worktree_settings_file::Column::Path,
|
||||
])
|
||||
.update_column(worktree_settings_file::Column::Content)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
} else {
|
||||
worktree_settings_file::Entity::delete(worktree_settings_file::ActiveModel {
|
||||
project_id: ActiveValue::Set(project_id),
|
||||
worktree_id: ActiveValue::Set(update.worktree_id as i64),
|
||||
path: ActiveValue::Set(update.path.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
|
||||
Ok(connection_ids)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn join_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<(Project, ReplicaId)>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let participant = room_participant::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(
|
||||
room_participant::Column::AnsweringConnectionId
|
||||
.eq(connection.id as i32),
|
||||
)
|
||||
.add(
|
||||
room_participant::Column::AnsweringConnectionServerId
|
||||
.eq(connection.owner_id as i32),
|
||||
),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("must join a room first"))?;
|
||||
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
if project.room_id != participant.room_id {
|
||||
return Err(anyhow!("no such project"))?;
|
||||
}
|
||||
|
||||
let mut collaborators = project
|
||||
.find_related(project_collaborator::Entity)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
let replica_ids = collaborators
|
||||
.iter()
|
||||
.map(|c| c.replica_id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut replica_id = ReplicaId(1);
|
||||
while replica_ids.contains(&replica_id) {
|
||||
replica_id.0 += 1;
|
||||
}
|
||||
let new_collaborator = project_collaborator::ActiveModel {
|
||||
project_id: ActiveValue::set(project_id),
|
||||
connection_id: ActiveValue::set(connection.id as i32),
|
||||
connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
|
||||
user_id: ActiveValue::set(participant.user_id),
|
||||
replica_id: ActiveValue::set(replica_id),
|
||||
is_host: ActiveValue::set(false),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
collaborators.push(new_collaborator);
|
||||
|
||||
let db_worktrees = project.find_related(worktree::Entity).all(&*tx).await?;
|
||||
let mut worktrees = db_worktrees
|
||||
.into_iter()
|
||||
.map(|db_worktree| {
|
||||
(
|
||||
db_worktree.id as u64,
|
||||
Worktree {
|
||||
id: db_worktree.id as u64,
|
||||
abs_path: db_worktree.abs_path,
|
||||
root_name: db_worktree.root_name,
|
||||
visible: db_worktree.visible,
|
||||
entries: Default::default(),
|
||||
repository_entries: Default::default(),
|
||||
diagnostic_summaries: Default::default(),
|
||||
settings_files: Default::default(),
|
||||
scan_id: db_worktree.scan_id as u64,
|
||||
completed_scan_id: db_worktree.completed_scan_id as u64,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
// Populate worktree entries.
|
||||
{
|
||||
let mut db_entries = worktree_entry::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(worktree_entry::Column::ProjectId.eq(project_id))
|
||||
.add(worktree_entry::Column::IsDeleted.eq(false)),
|
||||
)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(db_entry) = db_entries.next().await {
|
||||
let db_entry = db_entry?;
|
||||
if let Some(worktree) = worktrees.get_mut(&(db_entry.worktree_id as u64)) {
|
||||
worktree.entries.push(proto::Entry {
|
||||
id: db_entry.id as u64,
|
||||
is_dir: db_entry.is_dir,
|
||||
path: db_entry.path,
|
||||
inode: db_entry.inode as u64,
|
||||
mtime: Some(proto::Timestamp {
|
||||
seconds: db_entry.mtime_seconds as u64,
|
||||
nanos: db_entry.mtime_nanos as u32,
|
||||
}),
|
||||
is_symlink: db_entry.is_symlink,
|
||||
is_ignored: db_entry.is_ignored,
|
||||
is_external: db_entry.is_external,
|
||||
git_status: db_entry.git_status.map(|status| status as i32),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate repository entries.
|
||||
{
|
||||
let mut db_repository_entries = worktree_repository::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(worktree_repository::Column::ProjectId.eq(project_id))
|
||||
.add(worktree_repository::Column::IsDeleted.eq(false)),
|
||||
)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(db_repository_entry) = db_repository_entries.next().await {
|
||||
let db_repository_entry = db_repository_entry?;
|
||||
if let Some(worktree) =
|
||||
worktrees.get_mut(&(db_repository_entry.worktree_id as u64))
|
||||
{
|
||||
worktree.repository_entries.insert(
|
||||
db_repository_entry.work_directory_id as u64,
|
||||
proto::RepositoryEntry {
|
||||
work_directory_id: db_repository_entry.work_directory_id as u64,
|
||||
branch: db_repository_entry.branch,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate worktree diagnostic summaries.
|
||||
{
|
||||
let mut db_summaries = worktree_diagnostic_summary::Entity::find()
|
||||
.filter(worktree_diagnostic_summary::Column::ProjectId.eq(project_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(db_summary) = db_summaries.next().await {
|
||||
let db_summary = db_summary?;
|
||||
if let Some(worktree) = worktrees.get_mut(&(db_summary.worktree_id as u64)) {
|
||||
worktree
|
||||
.diagnostic_summaries
|
||||
.push(proto::DiagnosticSummary {
|
||||
path: db_summary.path,
|
||||
language_server_id: db_summary.language_server_id as u64,
|
||||
error_count: db_summary.error_count as u32,
|
||||
warning_count: db_summary.warning_count as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate worktree settings files
|
||||
{
|
||||
let mut db_settings_files = worktree_settings_file::Entity::find()
|
||||
.filter(worktree_settings_file::Column::ProjectId.eq(project_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(db_settings_file) = db_settings_files.next().await {
|
||||
let db_settings_file = db_settings_file?;
|
||||
if let Some(worktree) =
|
||||
worktrees.get_mut(&(db_settings_file.worktree_id as u64))
|
||||
{
|
||||
worktree.settings_files.push(WorktreeSettingsFile {
|
||||
path: db_settings_file.path,
|
||||
content: db_settings_file.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate language servers.
|
||||
let language_servers = project
|
||||
.find_related(language_server::Entity)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let project = Project {
|
||||
collaborators: collaborators
|
||||
.into_iter()
|
||||
.map(|collaborator| ProjectCollaborator {
|
||||
connection_id: collaborator.connection(),
|
||||
user_id: collaborator.user_id,
|
||||
replica_id: collaborator.replica_id,
|
||||
is_host: collaborator.is_host,
|
||||
})
|
||||
.collect(),
|
||||
worktrees,
|
||||
language_servers: language_servers
|
||||
.into_iter()
|
||||
.map(|language_server| proto::LanguageServer {
|
||||
id: language_server.id as u64,
|
||||
name: language_server.name,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
Ok((project, replica_id as ReplicaId))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn leave_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<(proto::Room, LeftProject)>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let result = project_collaborator::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(project_collaborator::Column::ProjectId.eq(project_id))
|
||||
.add(project_collaborator::Column::ConnectionId.eq(connection.id as i32))
|
||||
.add(
|
||||
project_collaborator::Column::ConnectionServerId
|
||||
.eq(connection.owner_id as i32),
|
||||
),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
Err(anyhow!("not a collaborator on this project"))?;
|
||||
}
|
||||
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
let collaborators = project
|
||||
.find_related(project_collaborator::Entity)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
let connection_ids = collaborators
|
||||
.into_iter()
|
||||
.map(|collaborator| collaborator.connection())
|
||||
.collect();
|
||||
|
||||
follower::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::any()
|
||||
.add(
|
||||
Condition::all()
|
||||
.add(follower::Column::ProjectId.eq(project_id))
|
||||
.add(
|
||||
follower::Column::LeaderConnectionServerId
|
||||
.eq(connection.owner_id),
|
||||
)
|
||||
.add(follower::Column::LeaderConnectionId.eq(connection.id)),
|
||||
)
|
||||
.add(
|
||||
Condition::all()
|
||||
.add(follower::Column::ProjectId.eq(project_id))
|
||||
.add(
|
||||
follower::Column::FollowerConnectionServerId
|
||||
.eq(connection.owner_id),
|
||||
)
|
||||
.add(follower::Column::FollowerConnectionId.eq(connection.id)),
|
||||
),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let room = self.get_room(project.room_id, &tx).await?;
|
||||
let left_project = LeftProject {
|
||||
id: project_id,
|
||||
host_user_id: project.host_user_id,
|
||||
host_connection_id: project.host_connection()?,
|
||||
connection_ids,
|
||||
};
|
||||
Ok((room, left_project))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn project_collaborators(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection_id: ConnectionId,
|
||||
) -> Result<RoomGuard<Vec<ProjectCollaborator>>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let collaborators = project_collaborator::Entity::find()
|
||||
.filter(project_collaborator::Column::ProjectId.eq(project_id))
|
||||
.all(&*tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|collaborator| ProjectCollaborator {
|
||||
connection_id: collaborator.connection(),
|
||||
user_id: collaborator.user_id,
|
||||
replica_id: collaborator.replica_id,
|
||||
is_host: collaborator.is_host,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if collaborators
|
||||
.iter()
|
||||
.any(|collaborator| collaborator.connection_id == connection_id)
|
||||
{
|
||||
Ok(collaborators)
|
||||
} else {
|
||||
Err(anyhow!("no such project"))?
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn project_connection_ids(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection_id: ConnectionId,
|
||||
) -> Result<RoomGuard<HashSet<ConnectionId>>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let mut collaborators = project_collaborator::Entity::find()
|
||||
.filter(project_collaborator::Column::ProjectId.eq(project_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut connection_ids = HashSet::default();
|
||||
while let Some(collaborator) = collaborators.next().await {
|
||||
let collaborator = collaborator?;
|
||||
connection_ids.insert(collaborator.connection());
|
||||
}
|
||||
|
||||
if connection_ids.contains(&connection_id) {
|
||||
Ok(connection_ids)
|
||||
} else {
|
||||
Err(anyhow!("no such project"))?
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn project_guest_connection_ids(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<ConnectionId>> {
|
||||
let mut collaborators = project_collaborator::Entity::find()
|
||||
.filter(
|
||||
project_collaborator::Column::ProjectId
|
||||
.eq(project_id)
|
||||
.and(project_collaborator::Column::IsHost.eq(false)),
|
||||
)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
|
||||
let mut guest_connection_ids = Vec::new();
|
||||
while let Some(collaborator) = collaborators.next().await {
|
||||
let collaborator = collaborator?;
|
||||
guest_connection_ids.push(collaborator.connection());
|
||||
}
|
||||
Ok(guest_connection_ids)
|
||||
}
|
||||
|
||||
pub async fn room_id_for_project(&self, project_id: ProjectId) -> Result<RoomId> {
|
||||
self.transaction(|tx| async move {
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("project {} not found", project_id))?;
|
||||
Ok(project.room_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn follow(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
leader_connection: ConnectionId,
|
||||
follower_connection: ConnectionId,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
follower::ActiveModel {
|
||||
room_id: ActiveValue::set(room_id),
|
||||
project_id: ActiveValue::set(project_id),
|
||||
leader_connection_server_id: ActiveValue::set(ServerId(
|
||||
leader_connection.owner_id as i32,
|
||||
)),
|
||||
leader_connection_id: ActiveValue::set(leader_connection.id as i32),
|
||||
follower_connection_server_id: ActiveValue::set(ServerId(
|
||||
follower_connection.owner_id as i32,
|
||||
)),
|
||||
follower_connection_id: ActiveValue::set(follower_connection.id as i32),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
let room = self.get_room(room_id, &*tx).await?;
|
||||
Ok(room)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unfollow(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
leader_connection: ConnectionId,
|
||||
follower_connection: ConnectionId,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
follower::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(follower::Column::ProjectId.eq(project_id))
|
||||
.add(
|
||||
follower::Column::LeaderConnectionServerId
|
||||
.eq(leader_connection.owner_id),
|
||||
)
|
||||
.add(follower::Column::LeaderConnectionId.eq(leader_connection.id))
|
||||
.add(
|
||||
follower::Column::FollowerConnectionServerId
|
||||
.eq(follower_connection.owner_id),
|
||||
)
|
||||
.add(follower::Column::FollowerConnectionId.eq(follower_connection.id)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let room = self.get_room(room_id, &*tx).await?;
|
||||
Ok(room)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
1073
crates/collab/src/db/queries/rooms.rs
Normal file
1073
crates/collab/src/db/queries/rooms.rs
Normal file
File diff suppressed because it is too large
Load diff
81
crates/collab/src/db/queries/servers.rs
Normal file
81
crates/collab/src/db/queries/servers.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
use super::*;
|
||||
|
||||
impl Database {
|
||||
pub async fn create_server(&self, environment: &str) -> Result<ServerId> {
|
||||
self.transaction(|tx| async move {
|
||||
let server = server::ActiveModel {
|
||||
environment: ActiveValue::set(environment.into()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
Ok(server.id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn stale_room_ids(
|
||||
&self,
|
||||
environment: &str,
|
||||
new_server_id: ServerId,
|
||||
) -> Result<Vec<RoomId>> {
|
||||
self.transaction(|tx| async move {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryAs {
|
||||
RoomId,
|
||||
}
|
||||
|
||||
let stale_server_epochs = self
|
||||
.stale_server_ids(environment, new_server_id, &tx)
|
||||
.await?;
|
||||
Ok(room_participant::Entity::find()
|
||||
.select_only()
|
||||
.column(room_participant::Column::RoomId)
|
||||
.distinct()
|
||||
.filter(
|
||||
room_participant::Column::AnsweringConnectionServerId
|
||||
.is_in(stale_server_epochs),
|
||||
)
|
||||
.into_values::<_, QueryAs>()
|
||||
.all(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_stale_servers(
|
||||
&self,
|
||||
environment: &str,
|
||||
new_server_id: ServerId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
server::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(server::Column::Environment.eq(environment))
|
||||
.add(server::Column::Id.ne(new_server_id)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stale_server_ids(
|
||||
&self,
|
||||
environment: &str,
|
||||
new_server_id: ServerId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<ServerId>> {
|
||||
let stale_servers = server::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(server::Column::Environment.eq(environment))
|
||||
.add(server::Column::Id.ne(new_server_id)),
|
||||
)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
Ok(stale_servers.into_iter().map(|server| server.id).collect())
|
||||
}
|
||||
}
|
349
crates/collab/src/db/queries/signups.rs
Normal file
349
crates/collab/src/db/queries/signups.rs
Normal file
|
@ -0,0 +1,349 @@
|
|||
use super::*;
|
||||
use hyper::StatusCode;
|
||||
|
||||
impl Database {
|
||||
pub async fn create_invite_from_code(
|
||||
&self,
|
||||
code: &str,
|
||||
email_address: &str,
|
||||
device_id: Option<&str>,
|
||||
added_to_mailing_list: bool,
|
||||
) -> Result<Invite> {
|
||||
self.transaction(|tx| async move {
|
||||
let existing_user = user::Entity::find()
|
||||
.filter(user::Column::EmailAddress.eq(email_address))
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
|
||||
if existing_user.is_some() {
|
||||
Err(anyhow!("email address is already in use"))?;
|
||||
}
|
||||
|
||||
let inviting_user_with_invites = match user::Entity::find()
|
||||
.filter(
|
||||
user::Column::InviteCode
|
||||
.eq(code)
|
||||
.and(user::Column::InviteCount.gt(0)),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
{
|
||||
Some(inviting_user) => inviting_user,
|
||||
None => {
|
||||
return Err(Error::Http(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unable to find an invite code with invites remaining".to_string(),
|
||||
))?
|
||||
}
|
||||
};
|
||||
user::Entity::update_many()
|
||||
.filter(
|
||||
user::Column::Id
|
||||
.eq(inviting_user_with_invites.id)
|
||||
.and(user::Column::InviteCount.gt(0)),
|
||||
)
|
||||
.col_expr(
|
||||
user::Column::InviteCount,
|
||||
Expr::col(user::Column::InviteCount).sub(1),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let signup = signup::Entity::insert(signup::ActiveModel {
|
||||
email_address: ActiveValue::set(email_address.into()),
|
||||
email_confirmation_code: ActiveValue::set(random_email_confirmation_code()),
|
||||
email_confirmation_sent: ActiveValue::set(false),
|
||||
inviting_user_id: ActiveValue::set(Some(inviting_user_with_invites.id)),
|
||||
platform_linux: ActiveValue::set(false),
|
||||
platform_mac: ActiveValue::set(false),
|
||||
platform_windows: ActiveValue::set(false),
|
||||
platform_unknown: ActiveValue::set(true),
|
||||
device_id: ActiveValue::set(device_id.map(|device_id| device_id.into())),
|
||||
added_to_mailing_list: ActiveValue::set(added_to_mailing_list),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(signup::Column::EmailAddress)
|
||||
.update_column(signup::Column::InvitingUserId)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_with_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(Invite {
|
||||
email_address: signup.email_address,
|
||||
email_confirmation_code: signup.email_confirmation_code,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_user_from_invite(
|
||||
&self,
|
||||
invite: &Invite,
|
||||
user: NewUserParams,
|
||||
) -> Result<Option<NewUserResult>> {
|
||||
self.transaction(|tx| async {
|
||||
let tx = tx;
|
||||
let signup = signup::Entity::find()
|
||||
.filter(
|
||||
signup::Column::EmailAddress
|
||||
.eq(invite.email_address.as_str())
|
||||
.and(
|
||||
signup::Column::EmailConfirmationCode
|
||||
.eq(invite.email_confirmation_code.as_str()),
|
||||
),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "no such invite".to_string()))?;
|
||||
|
||||
if signup.user_id.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let user = user::Entity::insert(user::ActiveModel {
|
||||
email_address: ActiveValue::set(Some(invite.email_address.clone())),
|
||||
github_login: ActiveValue::set(user.github_login.clone()),
|
||||
github_user_id: ActiveValue::set(Some(user.github_user_id)),
|
||||
admin: ActiveValue::set(false),
|
||||
invite_count: ActiveValue::set(user.invite_count),
|
||||
invite_code: ActiveValue::set(Some(random_invite_code())),
|
||||
metrics_id: ActiveValue::set(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(user::Column::GithubLogin)
|
||||
.update_columns([
|
||||
user::Column::EmailAddress,
|
||||
user::Column::GithubUserId,
|
||||
user::Column::Admin,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_with_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut signup = signup.into_active_model();
|
||||
signup.user_id = ActiveValue::set(Some(user.id));
|
||||
let signup = signup.update(&*tx).await?;
|
||||
|
||||
if let Some(inviting_user_id) = signup.inviting_user_id {
|
||||
let (user_id_a, user_id_b, a_to_b) = if inviting_user_id < user.id {
|
||||
(inviting_user_id, user.id, true)
|
||||
} else {
|
||||
(user.id, inviting_user_id, false)
|
||||
};
|
||||
|
||||
contact::Entity::insert(contact::ActiveModel {
|
||||
user_id_a: ActiveValue::set(user_id_a),
|
||||
user_id_b: ActiveValue::set(user_id_b),
|
||||
a_to_b: ActiveValue::set(a_to_b),
|
||||
should_notify: ActiveValue::set(true),
|
||||
accepted: ActiveValue::set(true),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(OnConflict::new().do_nothing().to_owned())
|
||||
.exec_without_returning(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(Some(NewUserResult {
|
||||
user_id: user.id,
|
||||
metrics_id: user.metrics_id.to_string(),
|
||||
inviting_user_id: signup.inviting_user_id,
|
||||
signup_device_id: signup.device_id,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_invite_count_for_user(&self, id: UserId, count: i32) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
if count > 0 {
|
||||
user::Entity::update_many()
|
||||
.filter(
|
||||
user::Column::Id
|
||||
.eq(id)
|
||||
.and(user::Column::InviteCode.is_null()),
|
||||
)
|
||||
.set(user::ActiveModel {
|
||||
invite_code: ActiveValue::set(Some(random_invite_code())),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
user::Entity::update_many()
|
||||
.filter(user::Column::Id.eq(id))
|
||||
.set(user::ActiveModel {
|
||||
invite_count: ActiveValue::set(count),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_invite_code_for_user(&self, id: UserId) -> Result<Option<(String, i32)>> {
|
||||
self.transaction(|tx| async move {
|
||||
match user::Entity::find_by_id(id).one(&*tx).await? {
|
||||
Some(user) if user.invite_code.is_some() => {
|
||||
Ok(Some((user.invite_code.unwrap(), user.invite_count)))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_user_for_invite_code(&self, code: &str) -> Result<User> {
|
||||
self.transaction(|tx| async move {
|
||||
user::Entity::find()
|
||||
.filter(user::Column::InviteCode.eq(code))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::Http(
|
||||
StatusCode::NOT_FOUND,
|
||||
"that invite code does not exist".to_string(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_signup(&self, signup: &NewSignup) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
signup::Entity::insert(signup::ActiveModel {
|
||||
email_address: ActiveValue::set(signup.email_address.clone()),
|
||||
email_confirmation_code: ActiveValue::set(random_email_confirmation_code()),
|
||||
email_confirmation_sent: ActiveValue::set(false),
|
||||
platform_mac: ActiveValue::set(signup.platform_mac),
|
||||
platform_windows: ActiveValue::set(signup.platform_windows),
|
||||
platform_linux: ActiveValue::set(signup.platform_linux),
|
||||
platform_unknown: ActiveValue::set(false),
|
||||
editor_features: ActiveValue::set(Some(signup.editor_features.clone())),
|
||||
programming_languages: ActiveValue::set(Some(signup.programming_languages.clone())),
|
||||
device_id: ActiveValue::set(signup.device_id.clone()),
|
||||
added_to_mailing_list: ActiveValue::set(signup.added_to_mailing_list),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(signup::Column::EmailAddress)
|
||||
.update_columns([
|
||||
signup::Column::PlatformMac,
|
||||
signup::Column::PlatformWindows,
|
||||
signup::Column::PlatformLinux,
|
||||
signup::Column::EditorFeatures,
|
||||
signup::Column::ProgrammingLanguages,
|
||||
signup::Column::DeviceId,
|
||||
signup::Column::AddedToMailingList,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_signup(&self, email_address: &str) -> Result<signup::Model> {
|
||||
self.transaction(|tx| async move {
|
||||
let signup = signup::Entity::find()
|
||||
.filter(signup::Column::EmailAddress.eq(email_address))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
anyhow!("signup with email address {} doesn't exist", email_address)
|
||||
})?;
|
||||
|
||||
Ok(signup)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_waitlist_summary(&self) -> Result<WaitlistSummary> {
|
||||
self.transaction(|tx| async move {
|
||||
let query = "
|
||||
SELECT
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(CASE WHEN platform_linux THEN 1 ELSE 0 END), 0) as linux_count,
|
||||
COALESCE(SUM(CASE WHEN platform_mac THEN 1 ELSE 0 END), 0) as mac_count,
|
||||
COALESCE(SUM(CASE WHEN platform_windows THEN 1 ELSE 0 END), 0) as windows_count,
|
||||
COALESCE(SUM(CASE WHEN platform_unknown THEN 1 ELSE 0 END), 0) as unknown_count
|
||||
FROM (
|
||||
SELECT *
|
||||
FROM signups
|
||||
WHERE
|
||||
NOT email_confirmation_sent
|
||||
) AS unsent
|
||||
";
|
||||
Ok(
|
||||
WaitlistSummary::find_by_statement(Statement::from_sql_and_values(
|
||||
self.pool.get_database_backend(),
|
||||
query.into(),
|
||||
vec![],
|
||||
))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("invalid result"))?,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> {
|
||||
let emails = invites
|
||||
.iter()
|
||||
.map(|s| s.email_address.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
self.transaction(|tx| async {
|
||||
let tx = tx;
|
||||
signup::Entity::update_many()
|
||||
.filter(signup::Column::EmailAddress.is_in(emails.iter().copied()))
|
||||
.set(signup::ActiveModel {
|
||||
email_confirmation_sent: ActiveValue::set(true),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(signup::Entity::find()
|
||||
.select_only()
|
||||
.column(signup::Column::EmailAddress)
|
||||
.column(signup::Column::EmailConfirmationCode)
|
||||
.filter(
|
||||
signup::Column::EmailConfirmationSent.eq(false).and(
|
||||
signup::Column::PlatformMac
|
||||
.eq(true)
|
||||
.or(signup::Column::PlatformUnknown.eq(true)),
|
||||
),
|
||||
)
|
||||
.order_by_asc(signup::Column::CreatedAt)
|
||||
.limit(count as u64)
|
||||
.into_model()
|
||||
.all(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn random_invite_code() -> String {
|
||||
nanoid::nanoid!(16)
|
||||
}
|
||||
|
||||
fn random_email_confirmation_code() -> String {
|
||||
nanoid::nanoid!(64)
|
||||
}
|
243
crates/collab/src/db/queries/users.rs
Normal file
243
crates/collab/src/db/queries/users.rs
Normal file
|
@ -0,0 +1,243 @@
|
|||
use super::*;
|
||||
|
||||
impl Database {
|
||||
pub async fn create_user(
|
||||
&self,
|
||||
email_address: &str,
|
||||
admin: bool,
|
||||
params: NewUserParams,
|
||||
) -> Result<NewUserResult> {
|
||||
self.transaction(|tx| async {
|
||||
let tx = tx;
|
||||
let user = user::Entity::insert(user::ActiveModel {
|
||||
email_address: ActiveValue::set(Some(email_address.into())),
|
||||
github_login: ActiveValue::set(params.github_login.clone()),
|
||||
github_user_id: ActiveValue::set(Some(params.github_user_id)),
|
||||
admin: ActiveValue::set(admin),
|
||||
metrics_id: ActiveValue::set(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(user::Column::GithubLogin)
|
||||
.update_column(user::Column::GithubLogin)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_with_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(NewUserResult {
|
||||
user_id: user.id,
|
||||
metrics_id: user.metrics_id.to_string(),
|
||||
signup_device_id: None,
|
||||
inviting_user_id: None,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(&self, id: UserId) -> Result<Option<user::Model>> {
|
||||
self.transaction(|tx| async move { Ok(user::Entity::find_by_id(id).one(&*tx).await?) })
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<user::Model>> {
|
||||
self.transaction(|tx| async {
|
||||
let tx = tx;
|
||||
Ok(user::Entity::find()
|
||||
.filter(user::Column::Id.is_in(ids.iter().copied()))
|
||||
.all(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(user::Entity::find()
|
||||
.filter(user::Column::GithubLogin.eq(github_login))
|
||||
.one(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_or_create_user_by_github_account(
|
||||
&self,
|
||||
github_login: &str,
|
||||
github_user_id: Option<i32>,
|
||||
github_email: Option<&str>,
|
||||
) -> Result<Option<User>> {
|
||||
self.transaction(|tx| async move {
|
||||
let tx = &*tx;
|
||||
if let Some(github_user_id) = github_user_id {
|
||||
if let Some(user_by_github_user_id) = user::Entity::find()
|
||||
.filter(user::Column::GithubUserId.eq(github_user_id))
|
||||
.one(tx)
|
||||
.await?
|
||||
{
|
||||
let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
|
||||
user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
|
||||
Ok(Some(user_by_github_user_id.update(tx).await?))
|
||||
} else if let Some(user_by_github_login) = user::Entity::find()
|
||||
.filter(user::Column::GithubLogin.eq(github_login))
|
||||
.one(tx)
|
||||
.await?
|
||||
{
|
||||
let mut user_by_github_login = user_by_github_login.into_active_model();
|
||||
user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
|
||||
Ok(Some(user_by_github_login.update(tx).await?))
|
||||
} else {
|
||||
let user = user::Entity::insert(user::ActiveModel {
|
||||
email_address: ActiveValue::set(github_email.map(|email| email.into())),
|
||||
github_login: ActiveValue::set(github_login.into()),
|
||||
github_user_id: ActiveValue::set(Some(github_user_id)),
|
||||
admin: ActiveValue::set(false),
|
||||
invite_count: ActiveValue::set(0),
|
||||
invite_code: ActiveValue::set(None),
|
||||
metrics_id: ActiveValue::set(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(&*tx)
|
||||
.await?;
|
||||
Ok(Some(user))
|
||||
}
|
||||
} else {
|
||||
Ok(user::Entity::find()
|
||||
.filter(user::Column::GithubLogin.eq(github_login))
|
||||
.one(tx)
|
||||
.await?)
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(user::Entity::find()
|
||||
.order_by_asc(user::Column::GithubLogin)
|
||||
.limit(limit as u64)
|
||||
.offset(page as u64 * limit as u64)
|
||||
.all(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_users_with_no_invites(
|
||||
&self,
|
||||
invited_by_another_user: bool,
|
||||
) -> Result<Vec<User>> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(user::Entity::find()
|
||||
.filter(
|
||||
user::Column::InviteCount
|
||||
.eq(0)
|
||||
.and(if invited_by_another_user {
|
||||
user::Column::InviterId.is_not_null()
|
||||
} else {
|
||||
user::Column::InviterId.is_null()
|
||||
}),
|
||||
)
|
||||
.all(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_user_metrics_id(&self, id: UserId) -> Result<String> {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryAs {
|
||||
MetricsId,
|
||||
}
|
||||
|
||||
self.transaction(|tx| async move {
|
||||
let metrics_id: Uuid = user::Entity::find_by_id(id)
|
||||
.select_only()
|
||||
.column(user::Column::MetricsId)
|
||||
.into_values::<_, QueryAs>()
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("could not find user"))?;
|
||||
Ok(metrics_id.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
user::Entity::update_many()
|
||||
.filter(user::Column::Id.eq(id))
|
||||
.set(user::ActiveModel {
|
||||
admin: ActiveValue::set(is_admin),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
user::Entity::update_many()
|
||||
.filter(user::Column::Id.eq(id))
|
||||
.set(user::ActiveModel {
|
||||
connected_once: ActiveValue::set(connected_once),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn destroy_user(&self, id: UserId) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
access_token::Entity::delete_many()
|
||||
.filter(access_token::Column::UserId.eq(id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
user::Entity::delete_by_id(id).exec(&*tx).await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
|
||||
self.transaction(|tx| async {
|
||||
let tx = tx;
|
||||
let like_string = Self::fuzzy_like_string(name_query);
|
||||
let query = "
|
||||
SELECT users.*
|
||||
FROM users
|
||||
WHERE github_login ILIKE $1
|
||||
ORDER BY github_login <-> $2
|
||||
LIMIT $3
|
||||
";
|
||||
|
||||
Ok(user::Entity::find()
|
||||
.from_raw_sql(Statement::from_sql_and_values(
|
||||
self.pool.get_database_backend(),
|
||||
query.into(),
|
||||
vec![like_string.into(), name_query.into(), limit.into()],
|
||||
))
|
||||
.all(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn fuzzy_like_string(string: &str) -> String {
|
||||
let mut result = String::with_capacity(string.len() * 2 + 1);
|
||||
for c in string.chars() {
|
||||
if c.is_alphanumeric() {
|
||||
result.push('%');
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result.push('%');
|
||||
result
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
use super::{SignupId, UserId};
|
||||
use sea_orm::{entity::prelude::*, FromQueryResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signups")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: SignupId,
|
||||
pub email_address: String,
|
||||
pub email_confirmation_code: String,
|
||||
pub email_confirmation_sent: bool,
|
||||
pub created_at: DateTime,
|
||||
pub device_id: Option<String>,
|
||||
pub user_id: Option<UserId>,
|
||||
pub inviting_user_id: Option<UserId>,
|
||||
pub platform_mac: bool,
|
||||
pub platform_linux: bool,
|
||||
pub platform_windows: bool,
|
||||
pub platform_unknown: bool,
|
||||
pub editor_features: Option<Vec<String>>,
|
||||
pub programming_languages: Option<Vec<String>>,
|
||||
pub added_to_mailing_list: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)]
|
||||
pub struct Invite {
|
||||
pub email_address: String,
|
||||
pub email_confirmation_code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NewSignup {
|
||||
pub email_address: String,
|
||||
pub platform_mac: bool,
|
||||
pub platform_windows: bool,
|
||||
pub platform_linux: bool,
|
||||
pub editor_features: Vec<String>,
|
||||
pub programming_languages: Vec<String>,
|
||||
pub device_id: Option<String>,
|
||||
pub added_to_mailing_list: bool,
|
||||
pub created_at: Option<DateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromQueryResult)]
|
||||
pub struct WaitlistSummary {
|
||||
pub count: i64,
|
||||
pub linux_count: i64,
|
||||
pub mac_count: i64,
|
||||
pub windows_count: i64,
|
||||
pub unknown_count: i64,
|
||||
}
|
20
crates/collab/src/db/tables.rs
Normal file
20
crates/collab/src/db/tables.rs
Normal file
|
@ -0,0 +1,20 @@
|
|||
pub mod access_token;
|
||||
pub mod channel;
|
||||
pub mod channel_member;
|
||||
pub mod channel_path;
|
||||
pub mod contact;
|
||||
pub mod follower;
|
||||
pub mod language_server;
|
||||
pub mod project;
|
||||
pub mod project_collaborator;
|
||||
pub mod room;
|
||||
pub mod room_participant;
|
||||
pub mod server;
|
||||
pub mod signup;
|
||||
pub mod user;
|
||||
pub mod worktree;
|
||||
pub mod worktree_diagnostic_summary;
|
||||
pub mod worktree_entry;
|
||||
pub mod worktree_repository;
|
||||
pub mod worktree_repository_statuses;
|
||||
pub mod worktree_settings_file;
|
|
@ -1,4 +1,4 @@
|
|||
use super::{AccessTokenId, UserId};
|
||||
use crate::db::{AccessTokenId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::ChannelId;
|
||||
use crate::db::ChannelId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
||||
|
@ -30,9 +30,3 @@ impl Related<super::room::Entity> for Entity {
|
|||
Relation::Room.def()
|
||||
}
|
||||
}
|
||||
|
||||
// impl Related<super::follower::Entity> for Entity {
|
||||
// fn to() -> RelationDef {
|
||||
// Relation::Follower.def()
|
||||
// }
|
||||
// }
|
|
@ -1,6 +1,4 @@
|
|||
use crate::db::channel_member;
|
||||
|
||||
use super::{ChannelId, ChannelMemberId, UserId};
|
||||
use crate::db::{channel_member, ChannelId, ChannelMemberId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::ChannelId;
|
||||
use crate::db::ChannelId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::{ContactId, UserId};
|
||||
use crate::db::{ContactId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
||||
|
@ -30,29 +30,3 @@ pub enum Relation {
|
|||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Contact {
|
||||
Accepted {
|
||||
user_id: UserId,
|
||||
should_notify: bool,
|
||||
busy: bool,
|
||||
},
|
||||
Outgoing {
|
||||
user_id: UserId,
|
||||
},
|
||||
Incoming {
|
||||
user_id: UserId,
|
||||
should_notify: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Contact {
|
||||
pub fn user_id(&self) -> UserId {
|
||||
match self {
|
||||
Contact::Accepted { user_id, .. } => *user_id,
|
||||
Contact::Outgoing { user_id } => *user_id,
|
||||
Contact::Incoming { user_id, .. } => *user_id,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,9 +1,8 @@
|
|||
use super::{FollowerId, ProjectId, RoomId, ServerId};
|
||||
use crate::db::{FollowerId, ProjectId, RoomId, ServerId};
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "followers")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::ProjectId;
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::{ProjectId, Result, RoomId, ServerId, UserId};
|
||||
use crate::db::{ProjectId, Result, RoomId, ServerId, UserId};
|
||||
use anyhow::anyhow;
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
|
@ -1,4 +1,4 @@
|
|||
use super::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
|
||||
use crate::db::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use super::{ChannelId, RoomId};
|
||||
use crate::db::{ChannelId, RoomId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Default, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
|
||||
use crate::db::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::ServerId;
|
||||
use crate::db::ServerId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
28
crates/collab/src/db/tables/signup.rs
Normal file
28
crates/collab/src/db/tables/signup.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use crate::db::{SignupId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signups")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: SignupId,
|
||||
pub email_address: String,
|
||||
pub email_confirmation_code: String,
|
||||
pub email_confirmation_sent: bool,
|
||||
pub created_at: DateTime,
|
||||
pub device_id: Option<String>,
|
||||
pub user_id: Option<UserId>,
|
||||
pub inviting_user_id: Option<UserId>,
|
||||
pub platform_mac: bool,
|
||||
pub platform_linux: bool,
|
||||
pub platform_windows: bool,
|
||||
pub platform_unknown: bool,
|
||||
pub editor_features: Option<Vec<String>>,
|
||||
pub programming_languages: Option<Vec<String>>,
|
||||
pub added_to_mailing_list: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
|
@ -1,4 +1,4 @@
|
|||
use super::UserId;
|
||||
use crate::db::UserId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::Serialize;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use super::ProjectId;
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::ProjectId;
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::ProjectId;
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::ProjectId;
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::ProjectId;
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
@ -1,4 +1,4 @@
|
|||
use super::ProjectId;
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
120
crates/collab/src/db/test_db.rs
Normal file
120
crates/collab/src/db/test_db.rs
Normal file
|
@ -0,0 +1,120 @@
|
|||
use super::*;
|
||||
use gpui::executor::Background;
|
||||
use parking_lot::Mutex;
|
||||
use sea_orm::ConnectionTrait;
|
||||
use sqlx::migrate::MigrateDatabase;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct TestDb {
|
||||
pub db: Option<Arc<Database>>,
|
||||
pub connection: Option<sqlx::AnyConnection>,
|
||||
}
|
||||
|
||||
impl TestDb {
|
||||
pub fn sqlite(background: Arc<Background>) -> Self {
|
||||
let url = format!("sqlite::memory:");
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let mut db = runtime.block_on(async {
|
||||
let mut options = ConnectOptions::new(url);
|
||||
options.max_connections(5);
|
||||
let db = Database::new(options, Executor::Deterministic(background))
|
||||
.await
|
||||
.unwrap();
|
||||
let sql = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/migrations.sqlite/20221109000000_test_schema.sql"
|
||||
));
|
||||
db.pool
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
db.pool.get_database_backend(),
|
||||
sql.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
db
|
||||
});
|
||||
|
||||
db.runtime = Some(runtime);
|
||||
|
||||
Self {
|
||||
db: Some(Arc::new(db)),
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn postgres(background: Arc<Background>) -> Self {
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
let _guard = LOCK.lock();
|
||||
let mut rng = StdRng::from_entropy();
|
||||
let url = format!(
|
||||
"postgres://postgres@localhost/zed-test-{}",
|
||||
rng.gen::<u128>()
|
||||
);
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let mut db = runtime.block_on(async {
|
||||
sqlx::Postgres::create_database(&url)
|
||||
.await
|
||||
.expect("failed to create test db");
|
||||
let mut options = ConnectOptions::new(url);
|
||||
options
|
||||
.max_connections(5)
|
||||
.idle_timeout(Duration::from_secs(0));
|
||||
let db = Database::new(options, Executor::Deterministic(background))
|
||||
.await
|
||||
.unwrap();
|
||||
let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations");
|
||||
db.migrate(Path::new(migrations_path), false).await.unwrap();
|
||||
db
|
||||
});
|
||||
|
||||
db.runtime = Some(runtime);
|
||||
|
||||
Self {
|
||||
db: Some(Arc::new(db)),
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn db(&self) -> &Arc<Database> {
|
||||
self.db.as_ref().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDb {
|
||||
fn drop(&mut self) {
|
||||
let db = self.db.take().unwrap();
|
||||
if let sea_orm::DatabaseBackend::Postgres = db.pool.get_database_backend() {
|
||||
db.runtime.as_ref().unwrap().block_on(async {
|
||||
use util::ResultExt;
|
||||
let query = "
|
||||
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE
|
||||
pg_stat_activity.datname = current_database() AND
|
||||
pid <> pg_backend_pid();
|
||||
";
|
||||
db.pool
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
db.pool.get_database_backend(),
|
||||
query.into(),
|
||||
))
|
||||
.await
|
||||
.log_err();
|
||||
sqlx::Postgres::drop_database(db.options.get_url())
|
||||
.await
|
||||
.log_err();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
use crate::{
|
||||
db::{NewUserParams, TestDb, UserId},
|
||||
db::{test_db::TestDb, NewUserParams, UserId},
|
||||
executor::Executor,
|
||||
rpc::{Server, CLEANUP_TIMEOUT},
|
||||
AppState,
|
||||
|
|
|
@ -4163,6 +4163,7 @@ async fn test_collaborating_with_completion(
|
|||
capabilities: lsp::ServerCapabilities {
|
||||
completion_provider: Some(lsp::CompletionOptions {
|
||||
trigger_characters: Some(vec![".".to_string()]),
|
||||
resolve_provider: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
|
|
|
@ -86,7 +86,7 @@ impl_actions!(
|
|||
]
|
||||
);
|
||||
|
||||
const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel";
|
||||
const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
|
||||
|
||||
pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
|
||||
settings::register::<panel_settings::CollaborationPanelSettings>(cx);
|
||||
|
@ -464,7 +464,7 @@ impl CollabPanel {
|
|||
cx.spawn(|mut cx| async move {
|
||||
let serialized_panel = if let Some(panel) = cx
|
||||
.background()
|
||||
.spawn(async move { KEY_VALUE_STORE.read_kvp(CHANNELS_PANEL_KEY) })
|
||||
.spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
|
||||
.await
|
||||
.log_err()
|
||||
.flatten()
|
||||
|
@ -493,7 +493,7 @@ impl CollabPanel {
|
|||
async move {
|
||||
KEY_VALUE_STORE
|
||||
.write_kvp(
|
||||
CHANNELS_PANEL_KEY.into(),
|
||||
COLLABORATION_PANEL_KEY.into(),
|
||||
serde_json::to_string(&SerializedChannelsPanel { width })?,
|
||||
)
|
||||
.await?;
|
||||
|
@ -2354,7 +2354,7 @@ impl View for CollabPanel {
|
|||
.into_any()
|
||||
})
|
||||
.on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
|
||||
.into_any_named("channels panel")
|
||||
.into_any_named("collab panel")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2404,7 +2404,10 @@ impl Panel for CollabPanel {
|
|||
}
|
||||
|
||||
fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
|
||||
("Channels Panel".to_string(), Some(Box::new(ToggleFocus)))
|
||||
(
|
||||
"Collaboration Panel".to_string(),
|
||||
Some(Box::new(ToggleFocus)),
|
||||
)
|
||||
}
|
||||
|
||||
fn should_change_position_on_event(event: &Self::Event) -> bool {
|
||||
|
|
|
@ -577,6 +577,7 @@ pub struct Editor {
|
|||
searchable: bool,
|
||||
cursor_shape: CursorShape,
|
||||
collapse_matches: bool,
|
||||
autoindent_mode: Option<AutoindentMode>,
|
||||
workspace: Option<(WeakViewHandle<Workspace>, i64)>,
|
||||
keymap_context_layers: BTreeMap<TypeId, KeymapContext>,
|
||||
input_enabled: bool,
|
||||
|
@ -1412,6 +1413,7 @@ impl Editor {
|
|||
searchable: true,
|
||||
override_text_style: None,
|
||||
cursor_shape: Default::default(),
|
||||
autoindent_mode: Some(AutoindentMode::EachLine),
|
||||
collapse_matches: false,
|
||||
workspace: None,
|
||||
keymap_context_layers: Default::default(),
|
||||
|
@ -1590,6 +1592,14 @@ impl Editor {
|
|||
self.input_enabled = input_enabled;
|
||||
}
|
||||
|
||||
pub fn set_autoindent(&mut self, autoindent: bool) {
|
||||
if autoindent {
|
||||
self.autoindent_mode = Some(AutoindentMode::EachLine);
|
||||
} else {
|
||||
self.autoindent_mode = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_read_only(&mut self, read_only: bool) {
|
||||
self.read_only = read_only;
|
||||
}
|
||||
|
@ -1722,7 +1732,7 @@ impl Editor {
|
|||
}
|
||||
|
||||
self.buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(edits, Some(AutoindentMode::EachLine), cx)
|
||||
buffer.edit(edits, self.autoindent_mode.clone(), cx)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2093,12 +2103,12 @@ impl Editor {
|
|||
for (selection, autoclose_region) in
|
||||
self.selections_with_autoclose_regions(selections, &snapshot)
|
||||
{
|
||||
if let Some(language) = snapshot.language_scope_at(selection.head()) {
|
||||
if let Some(scope) = snapshot.language_scope_at(selection.head()) {
|
||||
// Determine if the inserted text matches the opening or closing
|
||||
// bracket of any of this language's bracket pairs.
|
||||
let mut bracket_pair = None;
|
||||
let mut is_bracket_pair_start = false;
|
||||
for (pair, enabled) in language.brackets() {
|
||||
for (pair, enabled) in scope.brackets() {
|
||||
if enabled && pair.close && pair.start.ends_with(text.as_ref()) {
|
||||
bracket_pair = Some(pair.clone());
|
||||
is_bracket_pair_start = true;
|
||||
|
@ -2120,7 +2130,7 @@ impl Editor {
|
|||
let following_text_allows_autoclose = snapshot
|
||||
.chars_at(selection.start)
|
||||
.next()
|
||||
.map_or(true, |c| language.should_autoclose_before(c));
|
||||
.map_or(true, |c| scope.should_autoclose_before(c));
|
||||
let preceding_text_matches_prefix = prefix_len == 0
|
||||
|| (selection.start.column >= (prefix_len as u32)
|
||||
&& snapshot.contains_str_at(
|
||||
|
@ -2197,7 +2207,7 @@ impl Editor {
|
|||
drop(snapshot);
|
||||
self.transact(cx, |this, cx| {
|
||||
this.buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(edits, Some(AutoindentMode::EachLine), cx);
|
||||
buffer.edit(edits, this.autoindent_mode.clone(), cx);
|
||||
});
|
||||
|
||||
let new_anchor_selections = new_selections.iter().map(|e| &e.0);
|
||||
|
@ -3038,7 +3048,7 @@ impl Editor {
|
|||
this.buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(
|
||||
ranges.iter().map(|range| (range.clone(), text)),
|
||||
Some(AutoindentMode::EachLine),
|
||||
this.autoindent_mode.clone(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
|
|
@ -5237,6 +5237,7 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
|
|||
lsp::ServerCapabilities {
|
||||
completion_provider: Some(lsp::CompletionOptions {
|
||||
trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
|
||||
resolve_provider: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
|
@ -7528,6 +7529,7 @@ async fn test_completions_with_additional_edits(cx: &mut gpui::TestAppContext) {
|
|||
lsp::ServerCapabilities {
|
||||
completion_provider: Some(lsp::CompletionOptions {
|
||||
trigger_characters: Some(vec![".".to_string()]),
|
||||
resolve_provider: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
|
|
|
@ -61,10 +61,10 @@ pub fn up_by_rows(
|
|||
goal: SelectionGoal,
|
||||
preserve_column_at_start: bool,
|
||||
) -> (DisplayPoint, SelectionGoal) {
|
||||
let mut goal_column = if let SelectionGoal::Column(column) = goal {
|
||||
column
|
||||
} else {
|
||||
map.column_to_chars(start.row(), start.column())
|
||||
let mut goal_column = match goal {
|
||||
SelectionGoal::Column(column) => column,
|
||||
SelectionGoal::ColumnRange { end, .. } => end,
|
||||
_ => map.column_to_chars(start.row(), start.column()),
|
||||
};
|
||||
|
||||
let prev_row = start.row().saturating_sub(row_count);
|
||||
|
@ -95,10 +95,10 @@ pub fn down_by_rows(
|
|||
goal: SelectionGoal,
|
||||
preserve_column_at_end: bool,
|
||||
) -> (DisplayPoint, SelectionGoal) {
|
||||
let mut goal_column = if let SelectionGoal::Column(column) = goal {
|
||||
column
|
||||
} else {
|
||||
map.column_to_chars(start.row(), start.column())
|
||||
let mut goal_column = match goal {
|
||||
SelectionGoal::Column(column) => column,
|
||||
SelectionGoal::ColumnRange { end, .. } => end,
|
||||
_ => map.column_to_chars(start.row(), start.column()),
|
||||
};
|
||||
|
||||
let new_row = start.row() + row_count;
|
||||
|
|
|
@ -29,6 +29,7 @@ use self::{
|
|||
};
|
||||
|
||||
pub const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28);
|
||||
pub const VERTICAL_SCROLL_MARGIN: f32 = 3.;
|
||||
const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Default)]
|
||||
|
@ -136,7 +137,7 @@ pub struct ScrollManager {
|
|||
impl ScrollManager {
|
||||
pub fn new() -> Self {
|
||||
ScrollManager {
|
||||
vertical_scroll_margin: 3.0,
|
||||
vertical_scroll_margin: VERTICAL_SCROLL_MARGIN,
|
||||
anchor: ScrollAnchor::new(),
|
||||
ongoing: OngoingScroll::new(),
|
||||
autoscroll_request: None,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use std::{
|
||||
cell::Ref,
|
||||
cmp, iter, mem,
|
||||
ops::{Deref, Range, Sub},
|
||||
ops::{Deref, DerefMut, Range, Sub},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
|
@ -53,7 +53,7 @@ impl SelectionsCollection {
|
|||
}
|
||||
}
|
||||
|
||||
fn display_map(&self, cx: &mut AppContext) -> DisplaySnapshot {
|
||||
pub fn display_map(&self, cx: &mut AppContext) -> DisplaySnapshot {
|
||||
self.display_map.update(cx, |map, cx| map.snapshot(cx))
|
||||
}
|
||||
|
||||
|
@ -250,6 +250,10 @@ impl SelectionsCollection {
|
|||
resolve(self.oldest_anchor(), &self.buffer(cx))
|
||||
}
|
||||
|
||||
pub fn first_anchor(&self) -> Selection<Anchor> {
|
||||
self.disjoint[0].clone()
|
||||
}
|
||||
|
||||
pub fn first<D: TextDimension + Ord + Sub<D, Output = D>>(
|
||||
&self,
|
||||
cx: &AppContext,
|
||||
|
@ -352,7 +356,7 @@ pub struct MutableSelectionsCollection<'a> {
|
|||
}
|
||||
|
||||
impl<'a> MutableSelectionsCollection<'a> {
|
||||
fn display_map(&mut self) -> DisplaySnapshot {
|
||||
pub fn display_map(&mut self) -> DisplaySnapshot {
|
||||
self.collection.display_map(self.cx)
|
||||
}
|
||||
|
||||
|
@ -607,6 +611,10 @@ impl<'a> MutableSelectionsCollection<'a> {
|
|||
self.select_anchors(selections)
|
||||
}
|
||||
|
||||
pub fn new_selection_id(&mut self) -> usize {
|
||||
post_inc(&mut self.next_selection_id)
|
||||
}
|
||||
|
||||
pub fn select_display_ranges<T>(&mut self, ranges: T)
|
||||
where
|
||||
T: IntoIterator<Item = Range<DisplayPoint>>,
|
||||
|
@ -831,6 +839,12 @@ impl<'a> Deref for MutableSelectionsCollection<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a> DerefMut for MutableSelectionsCollection<'a> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.collection
|
||||
}
|
||||
}
|
||||
|
||||
// Panics if passed selections are not in order
|
||||
pub fn resolve_multiple<'a, D, I>(
|
||||
selections: I,
|
||||
|
|
|
@ -72,7 +72,7 @@ impl View for TestView {
|
|||
TextStyle::for_color(Color::blue()),
|
||||
)
|
||||
.with_style(ButtonStyle::fill(Color::yellow()))
|
||||
.into_element(),
|
||||
.element(),
|
||||
)
|
||||
.with_child(
|
||||
ToggleableButton::new(self.is_doubling, move |_, v: &mut Self, cx| {
|
||||
|
@ -84,7 +84,7 @@ impl View for TestView {
|
|||
inactive: ButtonStyle::fill(Color::red()),
|
||||
active: ButtonStyle::fill(Color::green()),
|
||||
})
|
||||
.into_element(),
|
||||
.element(),
|
||||
)
|
||||
.expanded()
|
||||
.contained()
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::marker::PhantomData;
|
||||
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
|
||||
use crate::{
|
||||
|
@ -9,6 +11,12 @@ use super::Empty;
|
|||
|
||||
pub trait GeneralComponent {
|
||||
fn render<V: View>(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>;
|
||||
fn element<V: View>(self) -> ComponentAdapter<V, Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
ComponentAdapter::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait StyleableComponent {
|
||||
|
@ -36,7 +44,7 @@ impl StyleableComponent for () {
|
|||
pub trait Component<V: View> {
|
||||
fn render(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>;
|
||||
|
||||
fn into_element(self) -> ComponentAdapter<V, Self>
|
||||
fn element(self) -> ComponentAdapter<V, Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
|
@ -50,11 +58,57 @@ impl<V: View, C: GeneralComponent> Component<V> for C {
|
|||
}
|
||||
}
|
||||
|
||||
// StylableComponent -> GeneralComponent
|
||||
pub struct StylableComponentAdapter<C: Component<V>, V: View> {
|
||||
component: C,
|
||||
phantom: std::marker::PhantomData<V>,
|
||||
}
|
||||
|
||||
impl<C: Component<V>, V: View> StylableComponentAdapter<C, V> {
|
||||
pub fn new(component: C) -> Self {
|
||||
Self {
|
||||
component,
|
||||
phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: GeneralComponent, V: View> StyleableComponent for StylableComponentAdapter<C, V> {
|
||||
type Style = ();
|
||||
|
||||
type Output = C;
|
||||
|
||||
fn with_style(self, _: Self::Style) -> Self::Output {
|
||||
self.component
|
||||
}
|
||||
}
|
||||
|
||||
// Element -> Component
|
||||
pub struct ElementAdapter<V: View> {
|
||||
element: AnyElement<V>,
|
||||
_phantom: std::marker::PhantomData<V>,
|
||||
}
|
||||
|
||||
impl<V: View> ElementAdapter<V> {
|
||||
pub fn new(element: AnyElement<V>) -> Self {
|
||||
Self {
|
||||
element,
|
||||
_phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<V: View> Component<V> for ElementAdapter<V> {
|
||||
fn render(self, _: &mut V, _: &mut ViewContext<V>) -> AnyElement<V> {
|
||||
self.element
|
||||
}
|
||||
}
|
||||
|
||||
// Component -> Element
|
||||
pub struct ComponentAdapter<V: View, E> {
|
||||
component: Option<E>,
|
||||
element: Option<AnyElement<V>>,
|
||||
#[cfg(debug_assertions)]
|
||||
_component_name: &'static str,
|
||||
phantom: PhantomData<V>,
|
||||
}
|
||||
|
||||
impl<E, V: View> ComponentAdapter<V, E> {
|
||||
|
@ -62,8 +116,7 @@ impl<E, V: View> ComponentAdapter<V, E> {
|
|||
Self {
|
||||
component: Some(e),
|
||||
element: None,
|
||||
#[cfg(debug_assertions)]
|
||||
_component_name: std::any::type_name::<E>(),
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -80,8 +133,12 @@ impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
|
|||
cx: &mut LayoutContext<V>,
|
||||
) -> (Vector2F, Self::LayoutState) {
|
||||
if self.element.is_none() {
|
||||
let component = self.component.take().unwrap();
|
||||
self.element = Some(component.render(view, cx.view_context()));
|
||||
let element = self
|
||||
.component
|
||||
.take()
|
||||
.expect("Component can only be rendered once")
|
||||
.render(view, cx.view_context());
|
||||
self.element = Some(element);
|
||||
}
|
||||
let constraint = self.element.as_mut().unwrap().layout(constraint, view, cx);
|
||||
(constraint, ())
|
||||
|
@ -98,7 +155,7 @@ impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
|
|||
) -> Self::PaintState {
|
||||
self.element
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.expect("Layout should always be called before paint")
|
||||
.paint(scene, bounds.origin(), visible_bounds, view, cx)
|
||||
}
|
||||
|
||||
|
@ -114,8 +171,7 @@ impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
|
|||
) -> Option<RectF> {
|
||||
self.element
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.rect_for_text_range(range_utf16, view, cx)
|
||||
.and_then(|el| el.rect_for_text_range(range_utf16, view, cx))
|
||||
}
|
||||
|
||||
fn debug(
|
||||
|
@ -126,16 +182,9 @@ impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
|
|||
view: &V,
|
||||
cx: &ViewContext<V>,
|
||||
) -> serde_json::Value {
|
||||
#[cfg(debug_assertions)]
|
||||
let component_name = self._component_name;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let component_name = "Unknown";
|
||||
|
||||
serde_json::json!({
|
||||
"type": "ComponentAdapter",
|
||||
"child": self.element.as_ref().unwrap().debug(view, cx),
|
||||
"component_name": component_name
|
||||
"child": self.element.as_ref().map(|el| el.debug(view, cx)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ pub async fn install_cli(cx: &AsyncAppContext) -> Result<()> {
|
|||
|
||||
// The symlink could not be created, so use osascript with admin privileges
|
||||
// to create it.
|
||||
let status = smol::process::Command::new("osascript")
|
||||
let status = smol::process::Command::new("/usr/bin/osascript")
|
||||
.args([
|
||||
"-e",
|
||||
&format!(
|
||||
|
|
|
@ -2145,27 +2145,46 @@ impl BufferSnapshot {
|
|||
|
||||
pub fn language_scope_at<D: ToOffset>(&self, position: D) -> Option<LanguageScope> {
|
||||
let offset = position.to_offset(self);
|
||||
let mut range = 0..self.len();
|
||||
let mut scope = self.language.clone().map(|language| LanguageScope {
|
||||
language,
|
||||
override_id: None,
|
||||
});
|
||||
let mut scope = None;
|
||||
let mut smallest_range: Option<Range<usize>> = None;
|
||||
|
||||
// Use the layer that has the smallest node intersecting the given point.
|
||||
for layer in self.syntax.layers_for_range(offset..offset, &self.text) {
|
||||
let mut cursor = layer.node().walk();
|
||||
while cursor.goto_first_child_for_byte(offset).is_some() {}
|
||||
let node_range = cursor.node().byte_range();
|
||||
if node_range.to_inclusive().contains(&offset) && node_range.len() < range.len() {
|
||||
range = node_range;
|
||||
scope = Some(LanguageScope {
|
||||
language: layer.language.clone(),
|
||||
override_id: layer.override_id(offset, &self.text),
|
||||
});
|
||||
|
||||
let mut range = None;
|
||||
loop {
|
||||
let child_range = cursor.node().byte_range();
|
||||
if !child_range.to_inclusive().contains(&offset) {
|
||||
break;
|
||||
}
|
||||
|
||||
range = Some(child_range);
|
||||
if cursor.goto_first_child_for_byte(offset).is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(range) = range {
|
||||
if smallest_range
|
||||
.as_ref()
|
||||
.map_or(true, |smallest_range| range.len() < smallest_range.len())
|
||||
{
|
||||
smallest_range = Some(range);
|
||||
scope = Some(LanguageScope {
|
||||
language: layer.language.clone(),
|
||||
override_id: layer.override_id(offset, &self.text),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope
|
||||
scope.or_else(|| {
|
||||
self.language.clone().map(|language| LanguageScope {
|
||||
language,
|
||||
override_id: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn surrounding_word<T: ToOffset>(&self, start: T) -> (Range<usize>, Option<CharKind>) {
|
||||
|
|
|
@ -1631,7 +1631,7 @@ fn test_autoindent_query_with_outdent_captures(cx: &mut AppContext) {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_language_scope_at(cx: &mut AppContext) {
|
||||
fn test_language_scope_at_with_javascript(cx: &mut AppContext) {
|
||||
init_settings(cx, |_| {});
|
||||
|
||||
cx.add_model(|cx| {
|
||||
|
@ -1718,6 +1718,73 @@ fn test_language_scope_at(cx: &mut AppContext) {
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_language_scope_at_with_rust(cx: &mut AppContext) {
|
||||
init_settings(cx, |_| {});
|
||||
|
||||
cx.add_model(|cx| {
|
||||
let language = Language::new(
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
brackets: BracketPairConfig {
|
||||
pairs: vec![
|
||||
BracketPair {
|
||||
start: "{".into(),
|
||||
end: "}".into(),
|
||||
close: true,
|
||||
newline: false,
|
||||
},
|
||||
BracketPair {
|
||||
start: "'".into(),
|
||||
end: "'".into(),
|
||||
close: true,
|
||||
newline: false,
|
||||
},
|
||||
],
|
||||
disabled_scopes_by_bracket_ix: vec![
|
||||
Vec::new(), //
|
||||
vec!["string".into()],
|
||||
],
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Some(tree_sitter_rust::language()),
|
||||
)
|
||||
.with_override_query(
|
||||
r#"
|
||||
(string_literal) @string
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let text = r#"
|
||||
const S: &'static str = "hello";
|
||||
"#
|
||||
.unindent();
|
||||
|
||||
let buffer = Buffer::new(0, text.clone(), cx).with_language(Arc::new(language), cx);
|
||||
let snapshot = buffer.snapshot();
|
||||
|
||||
// By default, all brackets are enabled
|
||||
let config = snapshot.language_scope_at(0).unwrap();
|
||||
assert_eq!(
|
||||
config.brackets().map(|e| e.1).collect::<Vec<_>>(),
|
||||
&[true, true]
|
||||
);
|
||||
|
||||
// Within a string, the quotation brackets are disabled.
|
||||
let string_config = snapshot
|
||||
.language_scope_at(text.find("ello").unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
string_config.brackets().map(|e| e.1).collect::<Vec<_>>(),
|
||||
&[true, false]
|
||||
);
|
||||
|
||||
buffer
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_language_scope_at_with_combined_injections(cx: &mut AppContext) {
|
||||
init_settings(cx, |_| {});
|
||||
|
|
|
@ -72,7 +72,7 @@ pub struct SyntaxMapMatch<'a> {
|
|||
|
||||
struct SyntaxMapCapturesLayer<'a> {
|
||||
depth: usize,
|
||||
captures: QueryCaptures<'a, 'a, TextProvider<'a>>,
|
||||
captures: QueryCaptures<'a, 'a, TextProvider<'a>, &'a [u8]>,
|
||||
next_capture: Option<QueryCapture<'a>>,
|
||||
grammar_index: usize,
|
||||
_query_cursor: QueryCursorHandle,
|
||||
|
@ -83,7 +83,7 @@ struct SyntaxMapMatchesLayer<'a> {
|
|||
next_pattern_index: usize,
|
||||
next_captures: Vec<QueryCapture<'a>>,
|
||||
has_next: bool,
|
||||
matches: QueryMatches<'a, 'a, TextProvider<'a>>,
|
||||
matches: QueryMatches<'a, 'a, TextProvider<'a>, &'a [u8]>,
|
||||
grammar_index: usize,
|
||||
_query_cursor: QueryCursorHandle,
|
||||
}
|
||||
|
@ -1279,7 +1279,9 @@ fn get_injections(
|
|||
}
|
||||
|
||||
for (language, mut included_ranges) in combined_injection_ranges.drain() {
|
||||
included_ranges.sort_unstable();
|
||||
included_ranges.sort_unstable_by(|a, b| {
|
||||
Ord::cmp(&a.start_byte, &b.start_byte).then_with(|| Ord::cmp(&a.end_byte, &b.end_byte))
|
||||
});
|
||||
queue.push(ParseStep {
|
||||
depth,
|
||||
language: ParseStepLanguage::Loaded { language },
|
||||
|
@ -1697,7 +1699,7 @@ impl std::fmt::Debug for SyntaxLayer {
|
|||
}
|
||||
}
|
||||
|
||||
impl<'a> tree_sitter::TextProvider<'a> for TextProvider<'a> {
|
||||
impl<'a> tree_sitter::TextProvider<&'a [u8]> for TextProvider<'a> {
|
||||
type I = ByteChunks<'a>;
|
||||
|
||||
fn text(&mut self, node: tree_sitter::Node) -> Self::I {
|
||||
|
|
|
@ -4454,10 +4454,20 @@ impl Project {
|
|||
};
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let additional_text_edits = lang_server
|
||||
.request::<lsp::request::ResolveCompletionItem>(completion.lsp_completion)
|
||||
.await?
|
||||
.additional_text_edits;
|
||||
let can_resolve = lang_server
|
||||
.capabilities()
|
||||
.completion_provider
|
||||
.as_ref()
|
||||
.and_then(|options| options.resolve_provider)
|
||||
.unwrap_or(false);
|
||||
let additional_text_edits = if can_resolve {
|
||||
lang_server
|
||||
.request::<lsp::request::ResolveCompletionItem>(completion.lsp_completion)
|
||||
.await?
|
||||
.additional_text_edits
|
||||
} else {
|
||||
completion.lsp_completion.additional_text_edits
|
||||
};
|
||||
if let Some(edits) = additional_text_edits {
|
||||
let edits = this
|
||||
.update(&mut cx, |this, cx| {
|
||||
|
|
|
@ -523,6 +523,11 @@ impl BufferSearchBar {
|
|||
}
|
||||
|
||||
pub fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext<Self>) {
|
||||
assert_ne!(
|
||||
mode,
|
||||
SearchMode::Semantic,
|
||||
"Semantic search is not supported in buffer search"
|
||||
);
|
||||
if mode == self.current_mode {
|
||||
return;
|
||||
}
|
||||
|
@ -797,7 +802,7 @@ impl BufferSearchBar {
|
|||
}
|
||||
}
|
||||
fn cycle_mode(&mut self, _: &CycleMode, cx: &mut ViewContext<Self>) {
|
||||
self.activate_search_mode(next_mode(&self.current_mode), cx);
|
||||
self.activate_search_mode(next_mode(&self.current_mode, false), cx);
|
||||
}
|
||||
fn cycle_mode_on_pane(pane: &mut Pane, action: &CycleMode, cx: &mut ViewContext<Pane>) {
|
||||
let mut should_propagate = true;
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
use gpui::Action;
|
||||
|
||||
use crate::{ActivateRegexMode, ActivateTextMode};
|
||||
use crate::{ActivateRegexMode, ActivateSemanticMode, ActivateTextMode};
|
||||
// TODO: Update the default search mode to get from config
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq)]
|
||||
pub enum SearchMode {
|
||||
#[default]
|
||||
Text,
|
||||
Semantic,
|
||||
Regex,
|
||||
}
|
||||
|
||||
|
@ -19,6 +20,7 @@ impl SearchMode {
|
|||
pub(crate) fn label(&self) -> &'static str {
|
||||
match self {
|
||||
SearchMode::Text => "Text",
|
||||
SearchMode::Semantic => "Semantic",
|
||||
SearchMode::Regex => "Regex",
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +28,7 @@ impl SearchMode {
|
|||
pub(crate) fn region_id(&self) -> usize {
|
||||
match self {
|
||||
SearchMode::Text => 3,
|
||||
SearchMode::Semantic => 4,
|
||||
SearchMode::Regex => 5,
|
||||
}
|
||||
}
|
||||
|
@ -33,6 +36,7 @@ impl SearchMode {
|
|||
pub(crate) fn tooltip_text(&self) -> &'static str {
|
||||
match self {
|
||||
SearchMode::Text => "Activate Text Search",
|
||||
SearchMode::Semantic => "Activate Semantic Search",
|
||||
SearchMode::Regex => "Activate Regex Search",
|
||||
}
|
||||
}
|
||||
|
@ -40,6 +44,7 @@ impl SearchMode {
|
|||
pub(crate) fn activate_action(&self) -> Box<dyn Action> {
|
||||
match self {
|
||||
SearchMode::Text => Box::new(ActivateTextMode),
|
||||
SearchMode::Semantic => Box::new(ActivateSemanticMode),
|
||||
SearchMode::Regex => Box::new(ActivateRegexMode),
|
||||
}
|
||||
}
|
||||
|
@ -48,6 +53,7 @@ impl SearchMode {
|
|||
match self {
|
||||
SearchMode::Regex => true,
|
||||
SearchMode::Text => true,
|
||||
SearchMode::Semantic => true,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,14 +67,22 @@ impl SearchMode {
|
|||
pub(crate) fn button_side(&self) -> Option<Side> {
|
||||
match self {
|
||||
SearchMode::Text => Some(Side::Left),
|
||||
SearchMode::Semantic => None,
|
||||
SearchMode::Regex => Some(Side::Right),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn next_mode(mode: &SearchMode) -> SearchMode {
|
||||
pub(crate) fn next_mode(mode: &SearchMode, semantic_enabled: bool) -> SearchMode {
|
||||
let next_text_state = if semantic_enabled {
|
||||
SearchMode::Semantic
|
||||
} else {
|
||||
SearchMode::Regex
|
||||
};
|
||||
|
||||
match mode {
|
||||
SearchMode::Text => SearchMode::Regex,
|
||||
SearchMode::Text => next_text_state,
|
||||
SearchMode::Semantic => SearchMode::Regex,
|
||||
SearchMode::Regex => SearchMode::Text,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,10 @@ use crate::{
|
|||
history::SearchHistory,
|
||||
mode::SearchMode,
|
||||
search_bar::{render_nav_button, render_option_button_icon, render_search_mode_button},
|
||||
CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions, SelectNextMatch,
|
||||
SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord,
|
||||
ActivateRegexMode, CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions,
|
||||
SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use anyhow::{Context, Result};
|
||||
use collections::HashMap;
|
||||
use editor::{
|
||||
items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
|
||||
|
@ -13,6 +13,8 @@ use editor::{
|
|||
};
|
||||
use futures::StreamExt;
|
||||
|
||||
use gpui::platform::PromptLevel;
|
||||
|
||||
use gpui::{
|
||||
actions, elements::*, platform::MouseButton, Action, AnyElement, AnyViewHandle, AppContext,
|
||||
Entity, ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle,
|
||||
|
@ -20,10 +22,12 @@ use gpui::{
|
|||
};
|
||||
|
||||
use menu::Confirm;
|
||||
use postage::stream::Stream;
|
||||
use project::{
|
||||
search::{PathMatcher, SearchQuery},
|
||||
search::{PathMatcher, SearchInputs, SearchQuery},
|
||||
Entry, Project,
|
||||
};
|
||||
use semantic_index::SemanticIndex;
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
|
@ -60,7 +64,7 @@ pub fn init(cx: &mut AppContext) {
|
|||
cx.add_action(ProjectSearchBar::cycle_mode);
|
||||
cx.add_action(ProjectSearchBar::next_history_query);
|
||||
cx.add_action(ProjectSearchBar::previous_history_query);
|
||||
// cx.add_action(ProjectSearchBar::activate_regex_mode);
|
||||
cx.add_action(ProjectSearchBar::activate_regex_mode);
|
||||
cx.capture_action(ProjectSearchBar::tab);
|
||||
cx.capture_action(ProjectSearchBar::tab_previous);
|
||||
add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
|
||||
|
@ -114,6 +118,8 @@ pub struct ProjectSearchView {
|
|||
model: ModelHandle<ProjectSearch>,
|
||||
query_editor: ViewHandle<Editor>,
|
||||
results_editor: ViewHandle<Editor>,
|
||||
semantic_state: Option<SemanticSearchState>,
|
||||
semantic_permissioned: Option<bool>,
|
||||
search_options: SearchOptions,
|
||||
panels_with_errors: HashSet<InputPanel>,
|
||||
active_match_index: Option<usize>,
|
||||
|
@ -125,6 +131,12 @@ pub struct ProjectSearchView {
|
|||
current_mode: SearchMode,
|
||||
}
|
||||
|
||||
struct SemanticSearchState {
|
||||
file_count: usize,
|
||||
outstanding_file_count: usize,
|
||||
_progress_task: Task<()>,
|
||||
}
|
||||
|
||||
pub struct ProjectSearchBar {
|
||||
active_project_search: Option<ViewHandle<ProjectSearchView>>,
|
||||
subscription: Option<Subscription>,
|
||||
|
@ -206,6 +218,60 @@ impl ProjectSearch {
|
|||
}));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn semantic_search(&mut self, inputs: &SearchInputs, cx: &mut ModelContext<Self>) {
|
||||
let search = SemanticIndex::global(cx).map(|index| {
|
||||
index.update(cx, |semantic_index, cx| {
|
||||
semantic_index.search_project(
|
||||
self.project.clone(),
|
||||
inputs.as_str().to_owned(),
|
||||
10,
|
||||
inputs.files_to_include().to_vec(),
|
||||
inputs.files_to_exclude().to_vec(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
self.search_id += 1;
|
||||
self.match_ranges.clear();
|
||||
self.search_history.add(inputs.as_str().to_string());
|
||||
self.no_results = Some(true);
|
||||
self.pending_search = Some(cx.spawn(|this, mut cx| async move {
|
||||
let results = search?.await.log_err()?;
|
||||
|
||||
let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
|
||||
this.excerpts.update(cx, |excerpts, cx| {
|
||||
excerpts.clear(cx);
|
||||
|
||||
let matches = results
|
||||
.into_iter()
|
||||
.map(|result| (result.buffer, vec![result.range.start..result.range.start]))
|
||||
.collect();
|
||||
|
||||
excerpts.stream_excerpts_with_context_lines(matches, 3, cx)
|
||||
})
|
||||
});
|
||||
|
||||
while let Some(match_range) = match_ranges.next().await {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.match_ranges.push(match_range);
|
||||
while let Ok(Some(match_range)) = match_ranges.try_next() {
|
||||
this.match_ranges.push(match_range);
|
||||
}
|
||||
this.no_results = Some(false);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.pending_search.take();
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
None
|
||||
}));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
@ -245,10 +311,27 @@ impl View for ProjectSearchView {
|
|||
} else {
|
||||
match current_mode {
|
||||
SearchMode::Text => Cow::Borrowed("Text search all files and folders"),
|
||||
SearchMode::Semantic => {
|
||||
Cow::Borrowed("Search all code objects using Natural Language")
|
||||
}
|
||||
SearchMode::Regex => Cow::Borrowed("Regex search all files and folders"),
|
||||
}
|
||||
};
|
||||
|
||||
let semantic_status = if let Some(semantic) = &self.semantic_state {
|
||||
if semantic.outstanding_file_count > 0 {
|
||||
format!(
|
||||
"Indexing: {} of {}...",
|
||||
semantic.file_count - semantic.outstanding_file_count,
|
||||
semantic.file_count
|
||||
)
|
||||
} else {
|
||||
"Indexing complete".to_string()
|
||||
}
|
||||
} else {
|
||||
"Indexing: ...".to_string()
|
||||
};
|
||||
|
||||
let minor_text = if let Some(no_results) = model.no_results {
|
||||
if model.pending_search.is_none() && no_results {
|
||||
vec!["No results found in this project for the provided query".to_owned()]
|
||||
|
@ -256,11 +339,19 @@ impl View for ProjectSearchView {
|
|||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![
|
||||
"".to_owned(),
|
||||
"Include/exclude specific paths with the filter option.".to_owned(),
|
||||
"Matching exact word and/or casing is available too.".to_owned(),
|
||||
]
|
||||
match current_mode {
|
||||
SearchMode::Semantic => vec![
|
||||
"".to_owned(),
|
||||
semantic_status,
|
||||
"Simply explain the code you are looking to find.".to_owned(),
|
||||
"ex. 'prompt user for permissions to index their project'".to_owned(),
|
||||
],
|
||||
_ => vec![
|
||||
"".to_owned(),
|
||||
"Include/exclude specific paths with the filter option.".to_owned(),
|
||||
"Matching exact word and/or casing is available too.".to_owned(),
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
let previous_query_keystrokes =
|
||||
|
@ -408,10 +499,14 @@ impl Item for ProjectSearchView {
|
|||
.with_margin_right(tab_theme.spacing),
|
||||
)
|
||||
.with_child({
|
||||
let tab_name: Option<Cow<_>> =
|
||||
self.model.read(cx).active_query.as_ref().map(|query| {
|
||||
let query_text =
|
||||
util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
|
||||
let tab_name: Option<Cow<_>> = self
|
||||
.model
|
||||
.read(cx)
|
||||
.search_history
|
||||
.current()
|
||||
.as_ref()
|
||||
.map(|query| {
|
||||
let query_text = util::truncate_and_trailoff(query, MAX_TAB_TITLE_LEN);
|
||||
query_text.into()
|
||||
});
|
||||
Label::new(
|
||||
|
@ -539,6 +634,49 @@ impl ProjectSearchView {
|
|||
self.search_options.toggle(option);
|
||||
}
|
||||
|
||||
fn index_project(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if let Some(semantic_index) = SemanticIndex::global(cx) {
|
||||
// Semantic search uses no options
|
||||
self.search_options = SearchOptions::none();
|
||||
|
||||
let project = self.model.read(cx).project.clone();
|
||||
let index_task = semantic_index.update(cx, |semantic_index, cx| {
|
||||
semantic_index.index_project(project, cx)
|
||||
});
|
||||
|
||||
cx.spawn(|search_view, mut cx| async move {
|
||||
let (files_to_index, mut files_remaining_rx) = index_task.await?;
|
||||
|
||||
search_view.update(&mut cx, |search_view, cx| {
|
||||
cx.notify();
|
||||
search_view.semantic_state = Some(SemanticSearchState {
|
||||
file_count: files_to_index,
|
||||
outstanding_file_count: files_to_index,
|
||||
_progress_task: cx.spawn(|search_view, mut cx| async move {
|
||||
while let Some(count) = files_remaining_rx.recv().await {
|
||||
search_view
|
||||
.update(&mut cx, |search_view, cx| {
|
||||
if let Some(semantic_search_state) =
|
||||
&mut search_view.semantic_state
|
||||
{
|
||||
semantic_search_state.outstanding_file_count = count;
|
||||
cx.notify();
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}),
|
||||
});
|
||||
})?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_search(&mut self, cx: &mut ViewContext<Self>) {
|
||||
self.model.update(cx, |model, cx| {
|
||||
model.pending_search = None;
|
||||
|
@ -561,7 +699,61 @@ impl ProjectSearchView {
|
|||
self.current_mode = mode;
|
||||
self.active_match_index = None;
|
||||
|
||||
self.search(cx);
|
||||
match mode {
|
||||
SearchMode::Semantic => {
|
||||
let has_permission = self.semantic_permissioned(cx);
|
||||
self.active_match_index = None;
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let has_permission = has_permission.await?;
|
||||
|
||||
if !has_permission {
|
||||
let mut answer = this.update(&mut cx, |this, cx| {
|
||||
let project = this.model.read(cx).project.clone();
|
||||
let project_name = project
|
||||
.read(cx)
|
||||
.worktree_root_names(cx)
|
||||
.collect::<Vec<&str>>()
|
||||
.join("/");
|
||||
let is_plural =
|
||||
project_name.chars().filter(|letter| *letter == '/').count() > 0;
|
||||
let prompt_text = format!("Would you like to index the '{}' project{} for semantic search? This requires sending code to the OpenAI API", project_name,
|
||||
if is_plural {
|
||||
"s"
|
||||
} else {""});
|
||||
cx.prompt(
|
||||
PromptLevel::Info,
|
||||
prompt_text.as_str(),
|
||||
&["Continue", "Cancel"],
|
||||
)
|
||||
})?;
|
||||
|
||||
if answer.next().await == Some(0) {
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.semantic_permissioned = Some(true);
|
||||
})?;
|
||||
} else {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.semantic_permissioned = Some(false);
|
||||
debug_assert_ne!(previous_mode, SearchMode::Semantic, "Tried to re-enable semantic search mode after user modal was rejected");
|
||||
this.activate_search_mode(previous_mode, cx);
|
||||
})?;
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.index_project(cx);
|
||||
})?;
|
||||
|
||||
anyhow::Ok(())
|
||||
}).detach_and_log_err(cx);
|
||||
}
|
||||
SearchMode::Regex | SearchMode::Text => {
|
||||
self.semantic_state = None;
|
||||
self.active_match_index = None;
|
||||
self.search(cx);
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
@ -657,6 +849,8 @@ impl ProjectSearchView {
|
|||
model,
|
||||
query_editor,
|
||||
results_editor,
|
||||
semantic_state: None,
|
||||
semantic_permissioned: None,
|
||||
search_options: options,
|
||||
panels_with_errors: HashSet::new(),
|
||||
active_match_index: None,
|
||||
|
@ -670,6 +864,18 @@ impl ProjectSearchView {
|
|||
this
|
||||
}
|
||||
|
||||
fn semantic_permissioned(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
|
||||
if let Some(value) = self.semantic_permissioned {
|
||||
return Task::ready(Ok(value));
|
||||
}
|
||||
|
||||
SemanticIndex::global(cx)
|
||||
.map(|semantic| {
|
||||
let project = self.model.read(cx).project.clone();
|
||||
semantic.update(cx, |this, cx| this.project_previously_indexed(project, cx))
|
||||
})
|
||||
.unwrap_or(Task::ready(Ok(false)))
|
||||
}
|
||||
pub fn new_search_in_directory(
|
||||
workspace: &mut Workspace,
|
||||
dir_entry: &Entry,
|
||||
|
@ -745,8 +951,26 @@ impl ProjectSearchView {
|
|||
}
|
||||
|
||||
fn search(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if let Some(query) = self.build_search_query(cx) {
|
||||
self.model.update(cx, |model, cx| model.search(query, cx));
|
||||
let mode = self.current_mode;
|
||||
match mode {
|
||||
SearchMode::Semantic => {
|
||||
if let Some(semantic) = &mut self.semantic_state {
|
||||
if semantic.outstanding_file_count > 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(query) = self.build_search_query(cx) {
|
||||
self.model
|
||||
.update(cx, |model, cx| model.semantic_search(query.as_inner(), cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
if let Some(query) = self.build_search_query(cx) {
|
||||
self.model.update(cx, |model, cx| model.search(query, cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -946,7 +1170,8 @@ impl ProjectSearchBar {
|
|||
.and_then(|item| item.downcast::<ProjectSearchView>())
|
||||
{
|
||||
search_view.update(cx, |this, cx| {
|
||||
let new_mode = crate::mode::next_mode(&this.current_mode);
|
||||
let new_mode =
|
||||
crate::mode::next_mode(&this.current_mode, SemanticIndex::enabled(cx));
|
||||
this.activate_search_mode(new_mode, cx);
|
||||
cx.focus(&this.query_editor);
|
||||
})
|
||||
|
@ -1071,18 +1296,18 @@ impl ProjectSearchBar {
|
|||
}
|
||||
}
|
||||
|
||||
// fn activate_regex_mode(pane: &mut Pane, _: &ActivateRegexMode, cx: &mut ViewContext<Pane>) {
|
||||
// if let Some(search_view) = pane
|
||||
// .active_item()
|
||||
// .and_then(|item| item.downcast::<ProjectSearchView>())
|
||||
// {
|
||||
// search_view.update(cx, |view, cx| {
|
||||
// view.activate_search_mode(SearchMode::Regex, cx)
|
||||
// });
|
||||
// } else {
|
||||
// cx.propagate_action();
|
||||
// }
|
||||
// }
|
||||
fn activate_regex_mode(pane: &mut Pane, _: &ActivateRegexMode, cx: &mut ViewContext<Pane>) {
|
||||
if let Some(search_view) = pane
|
||||
.active_item()
|
||||
.and_then(|item| item.downcast::<ProjectSearchView>())
|
||||
{
|
||||
search_view.update(cx, |view, cx| {
|
||||
view.activate_search_mode(SearchMode::Regex, cx)
|
||||
});
|
||||
} else {
|
||||
cx.propagate_action();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
|
||||
if let Some(search_view) = self.active_project_search.as_ref() {
|
||||
|
@ -1195,7 +1420,8 @@ impl View for ProjectSearchBar {
|
|||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
let search = _search.read(cx);
|
||||
let is_semantic_disabled = search.semantic_state.is_none();
|
||||
let render_option_button_icon = |path, option, cx: &mut ViewContext<Self>| {
|
||||
crate::search_bar::render_option_button_icon(
|
||||
self.is_option_enabled(option, cx),
|
||||
|
@ -1209,17 +1435,17 @@ impl View for ProjectSearchBar {
|
|||
cx,
|
||||
)
|
||||
};
|
||||
let case_sensitive = render_option_button_icon(
|
||||
"icons/case_insensitive_12.svg",
|
||||
SearchOptions::CASE_SENSITIVE,
|
||||
cx,
|
||||
);
|
||||
let case_sensitive = is_semantic_disabled.then(|| {
|
||||
render_option_button_icon(
|
||||
"icons/case_insensitive_12.svg",
|
||||
SearchOptions::CASE_SENSITIVE,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let whole_word = render_option_button_icon(
|
||||
"icons/word_search_12.svg",
|
||||
SearchOptions::WHOLE_WORD,
|
||||
cx,
|
||||
);
|
||||
let whole_word = is_semantic_disabled.then(|| {
|
||||
render_option_button_icon("icons/word_search_12.svg", SearchOptions::WHOLE_WORD, cx)
|
||||
});
|
||||
|
||||
let search = _search.read(cx);
|
||||
let icon_style = theme.search.editor_icon.clone();
|
||||
|
@ -1235,8 +1461,8 @@ impl View for ProjectSearchBar {
|
|||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(filter_button)
|
||||
.with_child(case_sensitive)
|
||||
.with_child(whole_word)
|
||||
.with_children(case_sensitive)
|
||||
.with_children(whole_word)
|
||||
.flex(1., false)
|
||||
.constrained()
|
||||
.contained(),
|
||||
|
@ -1335,7 +1561,8 @@ impl View for ProjectSearchBar {
|
|||
)
|
||||
};
|
||||
let is_active = search.active_match_index.is_some();
|
||||
|
||||
let semantic_index = SemanticIndex::enabled(cx)
|
||||
.then(|| search_button_for_mode(SearchMode::Semantic, cx));
|
||||
let nav_button_for_direction = |label, direction, cx: &mut ViewContext<Self>| {
|
||||
render_nav_button(
|
||||
label,
|
||||
|
@ -1361,6 +1588,7 @@ impl View for ProjectSearchBar {
|
|||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(search_button_for_mode(SearchMode::Text, cx))
|
||||
.with_children(semantic_index)
|
||||
.with_child(search_button_for_mode(SearchMode::Regex, cx))
|
||||
.contained()
|
||||
.with_style(theme.search.modes_container),
|
||||
|
|
|
@ -8,9 +8,7 @@ use gpui::{
|
|||
pub use mode::SearchMode;
|
||||
use project::search::SearchQuery;
|
||||
pub use project_search::{ProjectSearchBar, ProjectSearchView};
|
||||
use theme::components::{
|
||||
action_button::ActionButton, svg::Svg, ComponentExt, ToggleIconButtonStyle,
|
||||
};
|
||||
use theme::components::{action_button::ActionButton, ComponentExt, ToggleIconButtonStyle};
|
||||
|
||||
pub mod buffer_search;
|
||||
mod history;
|
||||
|
@ -35,6 +33,7 @@ actions!(
|
|||
NextHistoryQuery,
|
||||
PreviousHistoryQuery,
|
||||
ActivateTextMode,
|
||||
ActivateSemanticMode,
|
||||
ActivateRegexMode
|
||||
]
|
||||
);
|
||||
|
@ -95,10 +94,10 @@ impl SearchOptions {
|
|||
format!("Toggle {}", self.label()),
|
||||
tooltip_style,
|
||||
)
|
||||
.with_contents(Svg::new(self.icon()))
|
||||
.with_contents(theme::components::svg::Svg::new(self.icon()))
|
||||
.toggleable(active)
|
||||
.with_style(button_style)
|
||||
.into_element()
|
||||
.element()
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -156,25 +156,27 @@ impl VectorDatabase {
|
|||
mtime: SystemTime,
|
||||
documents: Vec<Document>,
|
||||
) -> Result<()> {
|
||||
// Write to files table, and return generated id.
|
||||
self.db.execute(
|
||||
"
|
||||
DELETE FROM files WHERE worktree_id = ?1 AND relative_path = ?2;
|
||||
",
|
||||
params![worktree_id, path.to_str()],
|
||||
)?;
|
||||
// Return the existing ID, if both the file and mtime match
|
||||
let mtime = Timestamp::from(mtime);
|
||||
self.db.execute(
|
||||
"
|
||||
INSERT INTO files
|
||||
(worktree_id, relative_path, mtime_seconds, mtime_nanos)
|
||||
VALUES
|
||||
(?1, ?2, $3, $4);
|
||||
",
|
||||
params![worktree_id, path.to_str(), mtime.seconds, mtime.nanos],
|
||||
)?;
|
||||
|
||||
let file_id = self.db.last_insert_rowid();
|
||||
let mut existing_id_query = self.db.prepare("SELECT id FROM files WHERE worktree_id = ?1 AND relative_path = ?2 AND mtime_seconds = ?3 AND mtime_nanos = ?4")?;
|
||||
let existing_id = existing_id_query
|
||||
.query_row(
|
||||
params![worktree_id, path.to_str(), mtime.seconds, mtime.nanos],
|
||||
|row| Ok(row.get::<_, i64>(0)?),
|
||||
)
|
||||
.map_err(|err| anyhow!(err));
|
||||
let file_id = if existing_id.is_ok() {
|
||||
// If already exists, just return the existing id
|
||||
existing_id.unwrap()
|
||||
} else {
|
||||
// Delete Existing Row
|
||||
self.db.execute(
|
||||
"DELETE FROM files WHERE worktree_id = ?1 AND relative_path = ?2;",
|
||||
params![worktree_id, path.to_str()],
|
||||
)?;
|
||||
self.db.execute("INSERT INTO files (worktree_id, relative_path, mtime_seconds, mtime_nanos) VALUES (?1, ?2, ?3, ?4);", params![worktree_id, path.to_str(), mtime.seconds, mtime.nanos])?;
|
||||
self.db.last_insert_rowid()
|
||||
};
|
||||
|
||||
// Currently inserting at approximately 3400 documents a second
|
||||
// I imagine we can speed this up with a bulk insert of some kind.
|
||||
|
|
|
@ -96,10 +96,21 @@ struct ProjectState {
|
|||
_outstanding_job_count_tx: Arc<Mutex<watch::Sender<usize>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct JobHandle {
|
||||
tx: Weak<Mutex<watch::Sender<usize>>>,
|
||||
/// The outer Arc is here to count the clones of a JobHandle instance;
|
||||
/// when the last handle to a given job is dropped, we decrement a counter (just once).
|
||||
tx: Arc<Weak<Mutex<watch::Sender<usize>>>>,
|
||||
}
|
||||
|
||||
impl JobHandle {
|
||||
fn new(tx: &Arc<Mutex<watch::Sender<usize>>>) -> Self {
|
||||
*tx.lock().borrow_mut() += 1;
|
||||
Self {
|
||||
tx: Arc::new(Arc::downgrade(&tx)),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ProjectState {
|
||||
fn db_id_for_worktree_id(&self, id: WorktreeId) -> Option<i64> {
|
||||
self.worktree_db_ids
|
||||
|
@ -380,6 +391,20 @@ impl SemanticIndex {
|
|||
.await
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
// Insert the file in spite of failure so that future attempts to index it do not take place (unless the file is changed).
|
||||
for (worktree_id, _, path, mtime, job_handle) in embeddings_queue.into_iter() {
|
||||
db_update_tx
|
||||
.send(DbOperation::InsertFile {
|
||||
worktree_id,
|
||||
documents: vec![],
|
||||
path,
|
||||
mtime,
|
||||
job_handle,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -389,6 +414,7 @@ impl SemanticIndex {
|
|||
embeddings_queue: &mut Vec<(i64, Vec<Document>, PathBuf, SystemTime, JobHandle)>,
|
||||
embed_batch_tx: &channel::Sender<Vec<(i64, Vec<Document>, PathBuf, SystemTime, JobHandle)>>,
|
||||
) {
|
||||
// Handle edge case where individual file has more documents than max batch size
|
||||
let should_flush = match job {
|
||||
EmbeddingJob::Enqueue {
|
||||
documents,
|
||||
|
@ -397,9 +423,43 @@ impl SemanticIndex {
|
|||
mtime,
|
||||
job_handle,
|
||||
} => {
|
||||
*queue_len += &documents.len();
|
||||
embeddings_queue.push((worktree_id, documents, path, mtime, job_handle));
|
||||
*queue_len >= EMBEDDINGS_BATCH_SIZE
|
||||
// If documents is greater than embeddings batch size, recursively batch existing rows.
|
||||
if &documents.len() > &EMBEDDINGS_BATCH_SIZE {
|
||||
let first_job = EmbeddingJob::Enqueue {
|
||||
documents: documents[..EMBEDDINGS_BATCH_SIZE].to_vec(),
|
||||
worktree_id,
|
||||
path: path.clone(),
|
||||
mtime,
|
||||
job_handle: job_handle.clone(),
|
||||
};
|
||||
|
||||
Self::enqueue_documents_to_embed(
|
||||
first_job,
|
||||
queue_len,
|
||||
embeddings_queue,
|
||||
embed_batch_tx,
|
||||
);
|
||||
|
||||
let second_job = EmbeddingJob::Enqueue {
|
||||
documents: documents[EMBEDDINGS_BATCH_SIZE..].to_vec(),
|
||||
worktree_id,
|
||||
path: path.clone(),
|
||||
mtime,
|
||||
job_handle: job_handle.clone(),
|
||||
};
|
||||
|
||||
Self::enqueue_documents_to_embed(
|
||||
second_job,
|
||||
queue_len,
|
||||
embeddings_queue,
|
||||
embed_batch_tx,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
*queue_len += &documents.len();
|
||||
embeddings_queue.push((worktree_id, documents, path, mtime, job_handle));
|
||||
*queue_len >= EMBEDDINGS_BATCH_SIZE
|
||||
}
|
||||
}
|
||||
EmbeddingJob::Flush => true,
|
||||
};
|
||||
|
@ -613,10 +673,8 @@ impl SemanticIndex {
|
|||
|
||||
if !already_stored {
|
||||
count += 1;
|
||||
*job_count_tx.lock().borrow_mut() += 1;
|
||||
let job_handle = JobHandle {
|
||||
tx: Arc::downgrade(&job_count_tx),
|
||||
};
|
||||
|
||||
let job_handle = JobHandle::new(&job_count_tx);
|
||||
parsing_files_tx
|
||||
.try_send(PendingFile {
|
||||
worktree_db_id: db_ids_by_worktree_id[&worktree.id()],
|
||||
|
@ -690,6 +748,7 @@ impl SemanticIndex {
|
|||
let database_url = self.database_url.clone();
|
||||
let fs = self.fs.clone();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let t0 = Instant::now();
|
||||
let database = VectorDatabase::new(fs.clone(), database_url.clone()).await?;
|
||||
|
||||
let phrase_embedding = embedding_provider
|
||||
|
@ -699,6 +758,11 @@ impl SemanticIndex {
|
|||
.next()
|
||||
.unwrap();
|
||||
|
||||
log::trace!(
|
||||
"Embedding search phrase took: {:?} milliseconds",
|
||||
t0.elapsed().as_millis()
|
||||
);
|
||||
|
||||
let file_ids =
|
||||
database.retrieve_included_file_ids(&worktree_db_ids, &includes, &excludes)?;
|
||||
|
||||
|
@ -773,6 +837,11 @@ impl SemanticIndex {
|
|||
|
||||
let buffers = futures::future::join_all(tasks).await;
|
||||
|
||||
log::trace!(
|
||||
"Semantic Searching took: {:?} milliseconds in total",
|
||||
t0.elapsed().as_millis()
|
||||
);
|
||||
|
||||
Ok(buffers
|
||||
.into_iter()
|
||||
.zip(ranges)
|
||||
|
@ -794,9 +863,32 @@ impl Entity for SemanticIndex {
|
|||
|
||||
impl Drop for JobHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.tx.upgrade() {
|
||||
let mut tx = tx.lock();
|
||||
*tx.borrow_mut() -= 1;
|
||||
if let Some(inner) = Arc::get_mut(&mut self.tx) {
|
||||
// This is the last instance of the JobHandle (regardless of it's origin - whether it was cloned or not)
|
||||
if let Some(tx) = inner.upgrade() {
|
||||
let mut tx = tx.lock();
|
||||
*tx.borrow_mut() -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_job_handle() {
|
||||
let (job_count_tx, job_count_rx) = watch::channel_with(0);
|
||||
let tx = Arc::new(Mutex::new(job_count_tx));
|
||||
let job_handle = JobHandle::new(&tx);
|
||||
|
||||
assert_eq!(1, *job_count_rx.borrow());
|
||||
let new_job_handle = job_handle.clone();
|
||||
assert_eq!(1, *job_count_rx.borrow());
|
||||
drop(job_handle);
|
||||
assert_eq!(1, *job_count_rx.borrow());
|
||||
drop(new_job_handle);
|
||||
assert_eq!(0, *job_count_rx.borrow());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
#![allow(non_snake_case, non_upper_case_globals)]
|
||||
|
||||
mod keymap_file;
|
||||
mod settings_file;
|
||||
mod settings_store;
|
||||
|
|
|
@ -179,7 +179,7 @@ pub mod action_button {
|
|||
let view = cx.view_id();
|
||||
let action = action.boxed_clone();
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
window.dispatch_action(view, action.as_ref(), &mut cx);
|
||||
window.dispatch_action(view, action.as_ref(), &mut cx)
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::Vim;
|
||||
use crate::{Vim, VimEvent};
|
||||
use editor::{EditorBlurred, EditorFocused, EditorReleased};
|
||||
use gpui::AppContext;
|
||||
|
||||
|
@ -22,6 +22,11 @@ fn focused(EditorFocused(editor): &EditorFocused, cx: &mut AppContext) {
|
|||
editor.window().update(cx, |cx| {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
vim.set_active_editor(editor.clone(), cx);
|
||||
if vim.enabled {
|
||||
cx.emit_global(VimEvent::ModeChanged {
|
||||
mode: vim.state().mode,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -48,6 +53,7 @@ fn released(EditorReleased(editor): &EditorReleased, cx: &mut AppContext) {
|
|||
vim.active_editor = None;
|
||||
}
|
||||
}
|
||||
vim.editor_states.remove(&editor.id())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ impl ModeIndicator {
|
|||
if settings::get::<VimModeSetting>(cx).0 {
|
||||
mode_indicator.mode = cx
|
||||
.has_global::<Vim>()
|
||||
.then(|| cx.global::<Vim>().state.mode);
|
||||
.then(|| cx.global::<Vim>().state().mode);
|
||||
} else {
|
||||
mode_indicator.mode.take();
|
||||
}
|
||||
|
@ -46,7 +46,7 @@ impl ModeIndicator {
|
|||
.has_global::<Vim>()
|
||||
.then(|| {
|
||||
let vim = cx.global::<Vim>();
|
||||
vim.enabled.then(|| vim.state.mode)
|
||||
vim.enabled.then(|| vim.state().mode)
|
||||
})
|
||||
.flatten();
|
||||
|
||||
|
@ -80,14 +80,12 @@ impl View for ModeIndicator {
|
|||
|
||||
let theme = &theme::current(cx).workspace.status_bar;
|
||||
|
||||
// we always choose text to be 12 monospace characters
|
||||
// so that as the mode indicator changes, the rest of the
|
||||
// UI stays still.
|
||||
let text = match mode {
|
||||
Mode::Normal => "-- NORMAL --",
|
||||
Mode::Insert => "-- INSERT --",
|
||||
Mode::Visual { line: false } => "-- VISUAL --",
|
||||
Mode::Visual { line: true } => "VISUAL LINE",
|
||||
Mode::Visual => "-- VISUAL --",
|
||||
Mode::VisualLine => "-- VISUAL LINE --",
|
||||
Mode::VisualBlock => "-- VISUAL BLOCK --",
|
||||
};
|
||||
Label::new(text, theme.vim_mode_indicator.text.clone())
|
||||
.contained()
|
||||
|
|
|
@ -147,9 +147,9 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
|
|||
|
||||
let times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx));
|
||||
let operator = Vim::read(cx).active_operator();
|
||||
match Vim::read(cx).state.mode {
|
||||
match Vim::read(cx).state().mode {
|
||||
Mode::Normal => normal_motion(motion, operator, times, cx),
|
||||
Mode::Visual { .. } => visual_motion(motion, times, cx),
|
||||
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_motion(motion, times, cx),
|
||||
Mode::Insert => {
|
||||
// Shouldn't execute a motion in insert mode. Ignoring
|
||||
}
|
||||
|
@ -158,7 +158,7 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
|
|||
}
|
||||
|
||||
fn repeat_motion(backwards: bool, cx: &mut WindowContext) {
|
||||
let find = match Vim::read(cx).state.last_find.clone() {
|
||||
let find = match Vim::read(cx).workspace_state.last_find.clone() {
|
||||
Some(Motion::FindForward { before, text }) => {
|
||||
if backwards {
|
||||
Motion::FindBackward {
|
||||
|
@ -655,7 +655,10 @@ fn find_backward(
|
|||
|
||||
fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
|
||||
let new_row = (point.row() + times as u32).min(map.max_buffer_row());
|
||||
map.clip_point(DisplayPoint::new(new_row, 0), Bias::Left)
|
||||
first_non_whitespace(
|
||||
map,
|
||||
map.clip_point(DisplayPoint::new(new_row, 0), Bias::Left),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -803,4 +806,12 @@ mod test {
|
|||
cx.simulate_shared_keystrokes([","]).await;
|
||||
cx.assert_shared_state("one two thˇree four").await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_next_line_start(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
cx.set_shared_state("ˇone\n two\nthree").await;
|
||||
cx.simulate_shared_keystrokes(["enter"]).await;
|
||||
cx.assert_shared_state("one\n ˇtwo\nthree").await;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -116,8 +116,8 @@ pub fn normal_motion(
|
|||
|
||||
pub fn normal_object(object: Object, cx: &mut WindowContext) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
match vim.state.operator_stack.pop() {
|
||||
Some(Operator::Object { around }) => match vim.state.operator_stack.pop() {
|
||||
match vim.maybe_pop_operator() {
|
||||
Some(Operator::Object { around }) => match vim.maybe_pop_operator() {
|
||||
Some(Operator::Change) => change_object(vim, object, around, cx),
|
||||
Some(Operator::Delete) => delete_object(vim, object, around, cx),
|
||||
Some(Operator::Yank) => yank_object(vim, object, around, cx),
|
||||
|
|
|
@ -13,15 +13,15 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Works
|
|||
let mut cursor_positions = Vec::new();
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
for selection in editor.selections.all::<Point>(cx) {
|
||||
match vim.state.mode {
|
||||
Mode::Visual { line: true } => {
|
||||
match vim.state().mode {
|
||||
Mode::VisualLine => {
|
||||
let start = Point::new(selection.start.row, 0);
|
||||
let end =
|
||||
Point::new(selection.end.row, snapshot.line_len(selection.end.row));
|
||||
ranges.push(start..end);
|
||||
cursor_positions.push(start..start);
|
||||
}
|
||||
Mode::Visual { line: false } => {
|
||||
Mode::Visual | Mode::VisualBlock => {
|
||||
ranges.push(selection.start..selection.end);
|
||||
cursor_positions.push(selection.start..selection.start);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
use std::cmp::Ordering;
|
||||
|
||||
use crate::Vim;
|
||||
use editor::{display_map::ToDisplayPoint, scroll::scroll_amount::ScrollAmount, Editor};
|
||||
use editor::{
|
||||
display_map::ToDisplayPoint,
|
||||
scroll::{scroll_amount::ScrollAmount, VERTICAL_SCROLL_MARGIN},
|
||||
DisplayPoint, Editor,
|
||||
};
|
||||
use gpui::{actions, AppContext, ViewContext};
|
||||
use language::Bias;
|
||||
use workspace::Workspace;
|
||||
|
@ -53,13 +55,9 @@ fn scroll(cx: &mut ViewContext<Workspace>, by: fn(c: Option<f32>) -> ScrollAmoun
|
|||
|
||||
fn scroll_editor(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContext<Editor>) {
|
||||
let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq();
|
||||
|
||||
editor.scroll_screen(amount, cx);
|
||||
if should_move_cursor {
|
||||
let selection_ordering = editor.newest_selection_on_screen(cx);
|
||||
if selection_ordering.is_eq() {
|
||||
return;
|
||||
}
|
||||
|
||||
let visible_rows = if let Some(visible_rows) = editor.visible_line_count() {
|
||||
visible_rows as u32
|
||||
} else {
|
||||
|
@ -69,21 +67,19 @@ fn scroll_editor(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContex
|
|||
let top_anchor = editor.scroll_manager.anchor().anchor;
|
||||
|
||||
editor.change_selections(None, cx, |s| {
|
||||
s.replace_cursors_with(|snapshot| {
|
||||
let mut new_point = top_anchor.to_display_point(&snapshot);
|
||||
s.move_heads_with(|map, head, goal| {
|
||||
let top = top_anchor.to_display_point(map);
|
||||
let min_row = top.row() + VERTICAL_SCROLL_MARGIN as u32;
|
||||
let max_row = top.row() + visible_rows - VERTICAL_SCROLL_MARGIN as u32 - 1;
|
||||
|
||||
match selection_ordering {
|
||||
Ordering::Less => {
|
||||
new_point = snapshot.clip_point(new_point, Bias::Right);
|
||||
}
|
||||
Ordering::Greater => {
|
||||
*new_point.row_mut() += visible_rows - 1;
|
||||
new_point = snapshot.clip_point(new_point, Bias::Left);
|
||||
}
|
||||
Ordering::Equal => unreachable!(),
|
||||
}
|
||||
|
||||
vec![new_point]
|
||||
let new_head = if head.row() < min_row {
|
||||
map.clip_point(DisplayPoint::new(min_row, head.column()), Bias::Left)
|
||||
} else if head.row() > max_row {
|
||||
map.clip_point(DisplayPoint::new(max_row, head.column()), Bias::Left)
|
||||
} else {
|
||||
head
|
||||
};
|
||||
(new_head, goal)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
|
@ -68,10 +68,10 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Works
|
|||
search_bar.set_search_options(SearchOptions::CASE_SENSITIVE, cx);
|
||||
search_bar.activate_search_mode(SearchMode::Regex, cx);
|
||||
}
|
||||
vim.state.search = SearchState {
|
||||
vim.workspace_state.search = SearchState {
|
||||
direction,
|
||||
count,
|
||||
initial_query: query,
|
||||
initial_query: query.clone(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Works
|
|||
|
||||
// hook into the existing to clear out any vim search state on cmd+f or edit -> find.
|
||||
fn search_deploy(_: &mut Pane, _: &buffer_search::Deploy, cx: &mut ViewContext<Pane>) {
|
||||
Vim::update(cx, |vim, _| vim.state.search = Default::default());
|
||||
Vim::update(cx, |vim, _| vim.workspace_state.search = Default::default());
|
||||
cx.propagate_action();
|
||||
}
|
||||
|
||||
|
@ -91,8 +91,9 @@ fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewConte
|
|||
pane.update(cx, |pane, cx| {
|
||||
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
|
||||
search_bar.update(cx, |search_bar, cx| {
|
||||
let state = &mut vim.state.search;
|
||||
let state = &mut vim.workspace_state.search;
|
||||
let mut count = state.count;
|
||||
let direction = state.direction;
|
||||
|
||||
// in the case that the query has changed, the search bar
|
||||
// will have selected the next match already.
|
||||
|
@ -101,8 +102,8 @@ fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewConte
|
|||
{
|
||||
count = count.saturating_sub(1)
|
||||
}
|
||||
search_bar.select_match(state.direction, count, cx);
|
||||
state.count = 1;
|
||||
search_bar.select_match(direction, count, cx);
|
||||
search_bar.focus_editor(&Default::default(), cx);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -4,9 +4,9 @@ use language::Point;
|
|||
use crate::{motion::Motion, utils::copy_selections_content, Mode, Vim};
|
||||
|
||||
pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) {
|
||||
let line_mode = vim.state.mode == Mode::Visual { line: true };
|
||||
vim.switch_mode(Mode::Insert, true, cx);
|
||||
let line_mode = vim.state().mode == Mode::VisualLine;
|
||||
vim.update_active_editor(cx, |editor, cx| {
|
||||
editor.set_clip_at_line_ends(false, cx);
|
||||
editor.transact(cx, |editor, cx| {
|
||||
editor.change_selections(None, cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
|
@ -32,6 +32,7 @@ pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) {
|
|||
editor.edit(edits, cx);
|
||||
});
|
||||
});
|
||||
vim.switch_mode(Mode::Insert, true, cx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -52,7 +53,7 @@ mod test {
|
|||
cx.assert_editor_state("xˇbc\n");
|
||||
|
||||
// supports a selection
|
||||
cx.set_state(indoc! {"a«bcˇ»\n"}, Mode::Visual { line: false });
|
||||
cx.set_state(indoc! {"a«bcˇ»\n"}, Mode::Visual);
|
||||
cx.assert_editor_state("a«bcˇ»\n");
|
||||
cx.simulate_keystrokes(["s", "x"]);
|
||||
cx.assert_editor_state("axˇ\n");
|
||||
|
|
|
@ -62,9 +62,9 @@ pub fn init(cx: &mut AppContext) {
|
|||
}
|
||||
|
||||
fn object(object: Object, cx: &mut WindowContext) {
|
||||
match Vim::read(cx).state.mode {
|
||||
match Vim::read(cx).state().mode {
|
||||
Mode::Normal => normal_object(object, cx),
|
||||
Mode::Visual { .. } => visual_object(object, cx),
|
||||
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_object(object, cx),
|
||||
Mode::Insert => {
|
||||
// Shouldn't execute a text object in insert mode. Ignoring
|
||||
}
|
||||
|
@ -72,6 +72,47 @@ fn object(object: Object, cx: &mut WindowContext) {
|
|||
}
|
||||
|
||||
impl Object {
|
||||
pub fn is_multiline(self) -> bool {
|
||||
match self {
|
||||
Object::Word { .. } | Object::Quotes | Object::BackQuotes | Object::DoubleQuotes => {
|
||||
false
|
||||
}
|
||||
Object::Sentence
|
||||
| Object::Parentheses
|
||||
| Object::AngleBrackets
|
||||
| Object::CurlyBrackets
|
||||
| Object::SquareBrackets => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn always_expands_both_ways(self) -> bool {
|
||||
match self {
|
||||
Object::Word { .. } | Object::Sentence => false,
|
||||
Object::Quotes
|
||||
| Object::BackQuotes
|
||||
| Object::DoubleQuotes
|
||||
| Object::Parentheses
|
||||
| Object::SquareBrackets
|
||||
| Object::CurlyBrackets
|
||||
| Object::AngleBrackets => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_visual_mode(self, current_mode: Mode) -> Mode {
|
||||
match self {
|
||||
Object::Word { .. } if current_mode == Mode::VisualLine => Mode::Visual,
|
||||
Object::Word { .. } => current_mode,
|
||||
Object::Sentence
|
||||
| Object::Quotes
|
||||
| Object::BackQuotes
|
||||
| Object::DoubleQuotes
|
||||
| Object::Parentheses
|
||||
| Object::SquareBrackets
|
||||
| Object::CurlyBrackets
|
||||
| Object::AngleBrackets => Mode::Visual,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range(
|
||||
self,
|
||||
map: &DisplaySnapshot,
|
||||
|
@ -87,13 +128,27 @@ impl Object {
|
|||
}
|
||||
}
|
||||
Object::Sentence => sentence(map, relative_to, around),
|
||||
Object::Quotes => surrounding_markers(map, relative_to, around, false, '\'', '\''),
|
||||
Object::BackQuotes => surrounding_markers(map, relative_to, around, false, '`', '`'),
|
||||
Object::DoubleQuotes => surrounding_markers(map, relative_to, around, false, '"', '"'),
|
||||
Object::Parentheses => surrounding_markers(map, relative_to, around, true, '(', ')'),
|
||||
Object::SquareBrackets => surrounding_markers(map, relative_to, around, true, '[', ']'),
|
||||
Object::CurlyBrackets => surrounding_markers(map, relative_to, around, true, '{', '}'),
|
||||
Object::AngleBrackets => surrounding_markers(map, relative_to, around, true, '<', '>'),
|
||||
Object::Quotes => {
|
||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
|
||||
}
|
||||
Object::BackQuotes => {
|
||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
|
||||
}
|
||||
Object::DoubleQuotes => {
|
||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"')
|
||||
}
|
||||
Object::Parentheses => {
|
||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
|
||||
}
|
||||
Object::SquareBrackets => {
|
||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
|
||||
}
|
||||
Object::CurlyBrackets => {
|
||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '{', '}')
|
||||
}
|
||||
Object::AngleBrackets => {
|
||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,14 +9,16 @@ use crate::motion::Motion;
|
|||
pub enum Mode {
|
||||
Normal,
|
||||
Insert,
|
||||
Visual { line: bool },
|
||||
Visual,
|
||||
VisualLine,
|
||||
VisualBlock,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn is_visual(&self) -> bool {
|
||||
match self {
|
||||
Mode::Normal | Mode::Insert => false,
|
||||
Mode::Visual { .. } => true,
|
||||
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -39,15 +41,20 @@ pub enum Operator {
|
|||
FindBackward { after: bool },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct VimState {
|
||||
#[derive(Default, Clone)]
|
||||
pub struct EditorState {
|
||||
pub mode: Mode,
|
||||
pub last_mode: Mode,
|
||||
pub operator_stack: Vec<Operator>,
|
||||
pub search: SearchState,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct WorkspaceState {
|
||||
pub search: SearchState,
|
||||
pub last_find: Option<Motion>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SearchState {
|
||||
pub direction: Direction,
|
||||
pub count: usize,
|
||||
|
@ -64,7 +71,7 @@ impl Default for SearchState {
|
|||
}
|
||||
}
|
||||
|
||||
impl VimState {
|
||||
impl EditorState {
|
||||
pub fn cursor_shape(&self) -> CursorShape {
|
||||
match self.mode {
|
||||
Mode::Normal => {
|
||||
|
@ -74,7 +81,7 @@ impl VimState {
|
|||
CursorShape::Underscore
|
||||
}
|
||||
}
|
||||
Mode::Visual { .. } => CursorShape::Block,
|
||||
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => CursorShape::Block,
|
||||
Mode::Insert => CursorShape::Bar,
|
||||
}
|
||||
}
|
||||
|
@ -87,9 +94,13 @@ impl VimState {
|
|||
)
|
||||
}
|
||||
|
||||
pub fn should_autoindent(&self) -> bool {
|
||||
!(self.mode == Mode::Insert && self.last_mode == Mode::VisualBlock)
|
||||
}
|
||||
|
||||
pub fn clip_at_line_ends(&self) -> bool {
|
||||
match self.mode {
|
||||
Mode::Insert | Mode::Visual { .. } => false,
|
||||
Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => false,
|
||||
Mode::Normal => true,
|
||||
}
|
||||
}
|
||||
|
@ -101,7 +112,7 @@ impl VimState {
|
|||
"vim_mode",
|
||||
match self.mode {
|
||||
Mode::Normal => "normal",
|
||||
Mode::Visual { .. } => "visual",
|
||||
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual",
|
||||
Mode::Insert => "insert",
|
||||
},
|
||||
);
|
||||
|
|
|
@ -241,7 +241,7 @@ async fn test_status_indicator(
|
|||
deterministic.run_until_parked();
|
||||
assert_eq!(
|
||||
cx.workspace(|_, cx| mode_indicator.read(cx).mode),
|
||||
Some(Mode::Visual { line: false })
|
||||
Some(Mode::Visual)
|
||||
);
|
||||
|
||||
// hides if vim mode is disabled
|
||||
|
|
|
@ -116,7 +116,7 @@ impl<'a> NeovimBackedTestContext<'a> {
|
|||
|
||||
pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle {
|
||||
let mode = if marked_text.contains("»") {
|
||||
Mode::Visual { line: false }
|
||||
Mode::Visual
|
||||
} else {
|
||||
Mode::Normal
|
||||
};
|
||||
|
@ -160,7 +160,7 @@ impl<'a> NeovimBackedTestContext<'a> {
|
|||
pub async fn neovim_state(&mut self) -> String {
|
||||
generate_marked_text(
|
||||
self.neovim.text().await.as_str(),
|
||||
&vec![self.neovim_selection().await],
|
||||
&self.neovim_selections().await[..],
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
@ -169,9 +169,12 @@ impl<'a> NeovimBackedTestContext<'a> {
|
|||
self.neovim.mode().await.unwrap()
|
||||
}
|
||||
|
||||
async fn neovim_selection(&mut self) -> Range<usize> {
|
||||
let neovim_selection = self.neovim.selection().await;
|
||||
neovim_selection.to_offset(&self.buffer_snapshot())
|
||||
async fn neovim_selections(&mut self) -> Vec<Range<usize>> {
|
||||
let neovim_selections = self.neovim.selections().await;
|
||||
neovim_selections
|
||||
.into_iter()
|
||||
.map(|selection| selection.to_offset(&self.buffer_snapshot()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn assert_state_matches(&mut self) {
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
#[cfg(feature = "neovim")]
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::{
|
||||
cmp,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
use std::{ops::Range, path::PathBuf};
|
||||
|
||||
#[cfg(feature = "neovim")]
|
||||
|
@ -135,7 +138,7 @@ impl NeovimConnection {
|
|||
|
||||
#[cfg(feature = "neovim")]
|
||||
pub async fn set_state(&mut self, marked_text: &str) {
|
||||
let (text, selection) = parse_state(&marked_text);
|
||||
let (text, selections) = parse_state(&marked_text);
|
||||
|
||||
let nvim_buffer = self
|
||||
.nvim
|
||||
|
@ -167,6 +170,11 @@ impl NeovimConnection {
|
|||
.await
|
||||
.expect("Could not get neovim window");
|
||||
|
||||
if selections.len() != 1 {
|
||||
panic!("must have one selection");
|
||||
}
|
||||
let selection = &selections[0];
|
||||
|
||||
let cursor = selection.start;
|
||||
nvim_window
|
||||
.set_cursor((cursor.row as i64 + 1, cursor.column as i64))
|
||||
|
@ -224,7 +232,7 @@ impl NeovimConnection {
|
|||
}
|
||||
|
||||
#[cfg(feature = "neovim")]
|
||||
pub async fn state(&mut self) -> (Option<Mode>, String, Range<Point>) {
|
||||
pub async fn state(&mut self) -> (Option<Mode>, String, Vec<Range<Point>>) {
|
||||
let nvim_buffer = self
|
||||
.nvim
|
||||
.get_current_buf()
|
||||
|
@ -261,16 +269,51 @@ impl NeovimConnection {
|
|||
let mode = match nvim_mode_text.as_ref() {
|
||||
"i" => Some(Mode::Insert),
|
||||
"n" => Some(Mode::Normal),
|
||||
"v" => Some(Mode::Visual { line: false }),
|
||||
"V" => Some(Mode::Visual { line: true }),
|
||||
"v" => Some(Mode::Visual),
|
||||
"V" => Some(Mode::VisualLine),
|
||||
"\x16" => Some(Mode::VisualBlock),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut selections = Vec::new();
|
||||
// Vim uses the index of the first and last character in the selection
|
||||
// Zed uses the index of the positions between the characters, so we need
|
||||
// to add one to the end in visual mode.
|
||||
match mode {
|
||||
Some(Mode::Visual { .. }) => {
|
||||
Some(Mode::VisualBlock) if selection_row != cursor_row => {
|
||||
// in zed we fake a block selecrtion by using multiple cursors (one per line)
|
||||
// this code emulates that.
|
||||
// to deal with casees where the selection is not perfectly rectangular we extract
|
||||
// the content of the selection via the "a register to get the shape correctly.
|
||||
self.nvim.input("\"aygv").await.unwrap();
|
||||
let content = self.nvim.command_output("echo getreg('a')").await.unwrap();
|
||||
let lines = content.split("\n").collect::<Vec<_>>();
|
||||
let top = cmp::min(selection_row, cursor_row);
|
||||
let left = cmp::min(selection_col, cursor_col);
|
||||
for row in top..=cmp::max(selection_row, cursor_row) {
|
||||
let content = if row - top >= lines.len() as u32 {
|
||||
""
|
||||
} else {
|
||||
lines[(row - top) as usize]
|
||||
};
|
||||
let line_len = self
|
||||
.read_position(format!("echo strlen(getline({}))", row + 1).as_str())
|
||||
.await;
|
||||
|
||||
if left > line_len {
|
||||
continue;
|
||||
}
|
||||
|
||||
let start = Point::new(row, left);
|
||||
let end = Point::new(row, left + content.len() as u32);
|
||||
if cursor_col >= selection_col {
|
||||
selections.push(start..end)
|
||||
} else {
|
||||
selections.push(end..start)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Mode::Visual) | Some(Mode::VisualLine) | Some(Mode::VisualBlock) => {
|
||||
if selection_col > cursor_col {
|
||||
let selection_line_length =
|
||||
self.read_position("echo strlen(getline(line('v')))").await;
|
||||
|
@ -290,38 +333,37 @@ impl NeovimConnection {
|
|||
cursor_row += 1;
|
||||
}
|
||||
}
|
||||
selections.push(
|
||||
Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col),
|
||||
)
|
||||
}
|
||||
Some(Mode::Insert) | Some(Mode::Normal) | None => {}
|
||||
Some(Mode::Insert) | Some(Mode::Normal) | None => selections
|
||||
.push(Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col)),
|
||||
}
|
||||
|
||||
let (start, end) = (
|
||||
Point::new(selection_row, selection_col),
|
||||
Point::new(cursor_row, cursor_col),
|
||||
);
|
||||
|
||||
let state = NeovimData::Get {
|
||||
mode,
|
||||
state: encode_range(&text, start..end),
|
||||
state: encode_ranges(&text, &selections),
|
||||
};
|
||||
|
||||
if self.data.back() != Some(&state) {
|
||||
self.data.push_back(state.clone());
|
||||
}
|
||||
|
||||
(mode, text, start..end)
|
||||
(mode, text, selections)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "neovim"))]
|
||||
pub async fn state(&mut self) -> (Option<Mode>, String, Range<Point>) {
|
||||
pub async fn state(&mut self) -> (Option<Mode>, String, Vec<Range<Point>>) {
|
||||
if let Some(NeovimData::Get { state: text, mode }) = self.data.front() {
|
||||
let (text, range) = parse_state(text);
|
||||
(*mode, text, range)
|
||||
let (text, ranges) = parse_state(text);
|
||||
(*mode, text, ranges)
|
||||
} else {
|
||||
panic!("operation does not match recorded script. re-record with --features=neovim");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn selection(&mut self) -> Range<Point> {
|
||||
pub async fn selections(&mut self) -> Vec<Range<Point>> {
|
||||
self.state().await.2
|
||||
}
|
||||
|
||||
|
@ -421,51 +463,62 @@ impl Handler for NvimHandler {
|
|||
}
|
||||
}
|
||||
|
||||
fn parse_state(marked_text: &str) -> (String, Range<Point>) {
|
||||
fn parse_state(marked_text: &str) -> (String, Vec<Range<Point>>) {
|
||||
let (text, ranges) = util::test::marked_text_ranges(marked_text, true);
|
||||
let byte_range = ranges[0].clone();
|
||||
let mut point_range = Point::zero()..Point::zero();
|
||||
let mut ix = 0;
|
||||
let mut position = Point::zero();
|
||||
for c in text.chars().chain(['\0']) {
|
||||
if ix == byte_range.start {
|
||||
point_range.start = position;
|
||||
}
|
||||
if ix == byte_range.end {
|
||||
point_range.end = position;
|
||||
}
|
||||
let len_utf8 = c.len_utf8();
|
||||
ix += len_utf8;
|
||||
if c == '\n' {
|
||||
position.row += 1;
|
||||
position.column = 0;
|
||||
} else {
|
||||
position.column += len_utf8 as u32;
|
||||
}
|
||||
}
|
||||
(text, point_range)
|
||||
let point_ranges = ranges
|
||||
.into_iter()
|
||||
.map(|byte_range| {
|
||||
let mut point_range = Point::zero()..Point::zero();
|
||||
let mut ix = 0;
|
||||
let mut position = Point::zero();
|
||||
for c in text.chars().chain(['\0']) {
|
||||
if ix == byte_range.start {
|
||||
point_range.start = position;
|
||||
}
|
||||
if ix == byte_range.end {
|
||||
point_range.end = position;
|
||||
}
|
||||
let len_utf8 = c.len_utf8();
|
||||
ix += len_utf8;
|
||||
if c == '\n' {
|
||||
position.row += 1;
|
||||
position.column = 0;
|
||||
} else {
|
||||
position.column += len_utf8 as u32;
|
||||
}
|
||||
}
|
||||
point_range
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(text, point_ranges)
|
||||
}
|
||||
|
||||
#[cfg(feature = "neovim")]
|
||||
fn encode_range(text: &str, range: Range<Point>) -> String {
|
||||
let mut byte_range = 0..0;
|
||||
let mut ix = 0;
|
||||
let mut position = Point::zero();
|
||||
for c in text.chars().chain(['\0']) {
|
||||
if position == range.start {
|
||||
byte_range.start = ix;
|
||||
}
|
||||
if position == range.end {
|
||||
byte_range.end = ix;
|
||||
}
|
||||
let len_utf8 = c.len_utf8();
|
||||
ix += len_utf8;
|
||||
if c == '\n' {
|
||||
position.row += 1;
|
||||
position.column = 0;
|
||||
} else {
|
||||
position.column += len_utf8 as u32;
|
||||
}
|
||||
}
|
||||
util::test::generate_marked_text(text, &[byte_range], true)
|
||||
fn encode_ranges(text: &str, point_ranges: &Vec<Range<Point>>) -> String {
|
||||
let byte_ranges = point_ranges
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
let mut byte_range = 0..0;
|
||||
let mut ix = 0;
|
||||
let mut position = Point::zero();
|
||||
for c in text.chars().chain(['\0']) {
|
||||
if position == range.start {
|
||||
byte_range.start = ix;
|
||||
}
|
||||
if position == range.end {
|
||||
byte_range.end = ix;
|
||||
}
|
||||
let len_utf8 = c.len_utf8();
|
||||
ix += len_utf8;
|
||||
if c == '\n' {
|
||||
position.row += 1;
|
||||
position.column = 0;
|
||||
} else {
|
||||
position.column += len_utf8 as u32;
|
||||
}
|
||||
}
|
||||
byte_range
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
util::test::generate_marked_text(text, &byte_ranges[..], true)
|
||||
}
|
||||
|
|
|
@ -76,12 +76,12 @@ impl<'a> VimTestContext<'a> {
|
|||
}
|
||||
|
||||
pub fn mode(&mut self) -> Mode {
|
||||
self.cx.read(|cx| cx.global::<Vim>().state.mode)
|
||||
self.cx.read(|cx| cx.global::<Vim>().state().mode)
|
||||
}
|
||||
|
||||
pub fn active_operator(&mut self) -> Option<Operator> {
|
||||
self.cx
|
||||
.read(|cx| cx.global::<Vim>().state.operator_stack.last().copied())
|
||||
.read(|cx| cx.global::<Vim>().state().operator_stack.last().copied())
|
||||
}
|
||||
|
||||
pub fn set_state(&mut self, text: &str, mode: Mode) -> ContextHandle {
|
||||
|
|
|
@ -12,21 +12,21 @@ mod utils;
|
|||
mod visual;
|
||||
|
||||
use anyhow::Result;
|
||||
use collections::CommandPaletteFilter;
|
||||
use collections::{CommandPaletteFilter, HashMap};
|
||||
use editor::{movement, Editor, EditorMode, Event};
|
||||
use gpui::{
|
||||
actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext,
|
||||
Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
|
||||
};
|
||||
use language::CursorShape;
|
||||
use language::{CursorShape, Selection, SelectionGoal};
|
||||
pub use mode_indicator::ModeIndicator;
|
||||
use motion::Motion;
|
||||
use normal::normal_replace;
|
||||
use serde::Deserialize;
|
||||
use settings::{Setting, SettingsStore};
|
||||
use state::{Mode, Operator, VimState};
|
||||
use state::{EditorState, Mode, Operator, WorkspaceState};
|
||||
use std::sync::Arc;
|
||||
use visual::visual_replace;
|
||||
use visual::{visual_block_motion, visual_replace};
|
||||
use workspace::{self, Workspace};
|
||||
|
||||
struct VimModeSetting(bool);
|
||||
|
@ -127,7 +127,9 @@ pub struct Vim {
|
|||
active_editor: Option<WeakViewHandle<Editor>>,
|
||||
editor_subscription: Option<Subscription>,
|
||||
enabled: bool,
|
||||
state: VimState,
|
||||
editor_states: HashMap<usize, EditorState>,
|
||||
workspace_state: WorkspaceState,
|
||||
default_state: EditorState,
|
||||
}
|
||||
|
||||
impl Vim {
|
||||
|
@ -143,13 +145,13 @@ impl Vim {
|
|||
}
|
||||
|
||||
fn set_active_editor(&mut self, editor: ViewHandle<Editor>, cx: &mut WindowContext) {
|
||||
self.active_editor = Some(editor.downgrade());
|
||||
self.active_editor = Some(editor.clone().downgrade());
|
||||
self.editor_subscription = Some(cx.subscribe(&editor, |editor, event, cx| match event {
|
||||
Event::SelectionsChanged { local: true } => {
|
||||
let editor = editor.read(cx);
|
||||
if editor.leader_replica_id().is_none() {
|
||||
let newest_empty = editor.selections.newest::<usize>(cx).is_empty();
|
||||
local_selections_changed(newest_empty, cx);
|
||||
let newest = editor.selections.newest::<usize>(cx);
|
||||
local_selections_changed(newest, cx);
|
||||
}
|
||||
}
|
||||
Event::InputIgnored { text } => {
|
||||
|
@ -163,8 +165,11 @@ impl Vim {
|
|||
let editor_mode = editor.mode();
|
||||
let newest_selection_empty = editor.selections.newest::<usize>(cx).is_empty();
|
||||
|
||||
if editor_mode == EditorMode::Full && !newest_selection_empty {
|
||||
self.switch_mode(Mode::Visual { line: false }, true, cx);
|
||||
if editor_mode == EditorMode::Full
|
||||
&& !newest_selection_empty
|
||||
&& self.state().mode == Mode::Normal
|
||||
{
|
||||
self.switch_mode(Mode::Visual, true, cx);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -181,9 +186,14 @@ impl Vim {
|
|||
}
|
||||
|
||||
fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) {
|
||||
let last_mode = self.state.mode;
|
||||
self.state.mode = mode;
|
||||
self.state.operator_stack.clear();
|
||||
let state = self.state();
|
||||
let last_mode = state.mode;
|
||||
let prior_mode = state.last_mode;
|
||||
self.update_state(|state| {
|
||||
state.last_mode = last_mode;
|
||||
state.mode = mode;
|
||||
state.operator_stack.clear();
|
||||
});
|
||||
|
||||
cx.emit_global(VimEvent::ModeChanged { mode });
|
||||
|
||||
|
@ -196,11 +206,33 @@ impl Vim {
|
|||
|
||||
// Adjust selections
|
||||
self.update_active_editor(cx, |editor, cx| {
|
||||
if last_mode != Mode::VisualBlock && last_mode.is_visual() && mode == Mode::VisualBlock
|
||||
{
|
||||
visual_block_motion(true, editor, cx, |_, point, goal| Some((point, goal)))
|
||||
}
|
||||
|
||||
editor.change_selections(None, cx, |s| {
|
||||
// we cheat with visual block mode and use multiple cursors.
|
||||
// the cost of this cheat is we need to convert back to a single
|
||||
// cursor whenever vim would.
|
||||
if last_mode == Mode::VisualBlock
|
||||
&& (mode != Mode::VisualBlock && mode != Mode::Insert)
|
||||
{
|
||||
let tail = s.oldest_anchor().tail();
|
||||
let head = s.newest_anchor().head();
|
||||
s.select_anchor_ranges(vec![tail..head]);
|
||||
} else if last_mode == Mode::Insert
|
||||
&& prior_mode == Mode::VisualBlock
|
||||
&& mode != Mode::VisualBlock
|
||||
{
|
||||
let pos = s.first_anchor().head();
|
||||
s.select_anchor_ranges(vec![pos..pos])
|
||||
}
|
||||
|
||||
s.move_with(|map, selection| {
|
||||
if last_mode.is_visual() && !mode.is_visual() {
|
||||
let mut point = selection.head();
|
||||
if !selection.reversed {
|
||||
if !selection.reversed && !selection.is_empty() {
|
||||
point = movement::left(map, selection.head());
|
||||
}
|
||||
selection.collapse_to(point, selection.goal)
|
||||
|
@ -215,7 +247,7 @@ impl Vim {
|
|||
}
|
||||
|
||||
fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) {
|
||||
self.state.operator_stack.push(operator);
|
||||
self.update_state(|state| state.operator_stack.push(operator));
|
||||
self.sync_vim_settings(cx);
|
||||
}
|
||||
|
||||
|
@ -228,9 +260,13 @@ impl Vim {
|
|||
}
|
||||
}
|
||||
|
||||
fn maybe_pop_operator(&mut self) -> Option<Operator> {
|
||||
self.update_state(|state| state.operator_stack.pop())
|
||||
}
|
||||
|
||||
fn pop_operator(&mut self, cx: &mut WindowContext) -> Operator {
|
||||
let popped_operator = self.state.operator_stack.pop()
|
||||
.expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
|
||||
let popped_operator = self.update_state( |state| state.operator_stack.pop()
|
||||
) .expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
|
||||
self.sync_vim_settings(cx);
|
||||
popped_operator
|
||||
}
|
||||
|
@ -244,12 +280,12 @@ impl Vim {
|
|||
}
|
||||
|
||||
fn clear_operator(&mut self, cx: &mut WindowContext) {
|
||||
self.state.operator_stack.clear();
|
||||
self.update_state(|state| state.operator_stack.clear());
|
||||
self.sync_vim_settings(cx);
|
||||
}
|
||||
|
||||
fn active_operator(&self) -> Option<Operator> {
|
||||
self.state.operator_stack.last().copied()
|
||||
self.state().operator_stack.last().copied()
|
||||
}
|
||||
|
||||
fn active_editor_input_ignored(text: Arc<str>, cx: &mut WindowContext) {
|
||||
|
@ -260,17 +296,21 @@ impl Vim {
|
|||
match Vim::read(cx).active_operator() {
|
||||
Some(Operator::FindForward { before }) => {
|
||||
let find = Motion::FindForward { before, text };
|
||||
Vim::update(cx, |vim, _| vim.state.last_find = Some(find.clone()));
|
||||
Vim::update(cx, |vim, _| {
|
||||
vim.workspace_state.last_find = Some(find.clone())
|
||||
});
|
||||
motion::motion(find, cx)
|
||||
}
|
||||
Some(Operator::FindBackward { after }) => {
|
||||
let find = Motion::FindBackward { after, text };
|
||||
Vim::update(cx, |vim, _| vim.state.last_find = Some(find.clone()));
|
||||
Vim::update(cx, |vim, _| {
|
||||
vim.workspace_state.last_find = Some(find.clone())
|
||||
});
|
||||
motion::motion(find, cx)
|
||||
}
|
||||
Some(Operator::Replace) => match Vim::read(cx).state.mode {
|
||||
Some(Operator::Replace) => match Vim::read(cx).state().mode {
|
||||
Mode::Normal => normal_replace(text, cx),
|
||||
Mode::Visual { .. } => visual_replace(text, cx),
|
||||
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_replace(text, cx),
|
||||
_ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)),
|
||||
},
|
||||
_ => {}
|
||||
|
@ -280,7 +320,6 @@ impl Vim {
|
|||
fn set_enabled(&mut self, enabled: bool, cx: &mut AppContext) {
|
||||
if self.enabled != enabled {
|
||||
self.enabled = enabled;
|
||||
self.state = Default::default();
|
||||
|
||||
cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
|
||||
if self.enabled {
|
||||
|
@ -307,8 +346,29 @@ impl Vim {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &EditorState {
|
||||
if let Some(active_editor) = self.active_editor.as_ref() {
|
||||
if let Some(state) = self.editor_states.get(&active_editor.id()) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
&self.default_state
|
||||
}
|
||||
|
||||
pub fn update_state<T>(&mut self, func: impl FnOnce(&mut EditorState) -> T) -> T {
|
||||
let mut state = self.state().clone();
|
||||
let ret = func(&mut state);
|
||||
|
||||
if let Some(active_editor) = self.active_editor.as_ref() {
|
||||
self.editor_states.insert(active_editor.id(), state);
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
fn sync_vim_settings(&self, cx: &mut WindowContext) {
|
||||
let state = &self.state;
|
||||
let state = self.state();
|
||||
let cursor_shape = state.cursor_shape();
|
||||
|
||||
self.update_active_editor(cx, |editor, cx| {
|
||||
|
@ -317,7 +377,8 @@ impl Vim {
|
|||
editor.set_clip_at_line_ends(state.clip_at_line_ends(), cx);
|
||||
editor.set_collapse_matches(true);
|
||||
editor.set_input_enabled(!state.vim_controlled());
|
||||
editor.selections.line_mode = matches!(state.mode, Mode::Visual { line: true });
|
||||
editor.set_autoindent(state.should_autoindent());
|
||||
editor.selections.line_mode = matches!(state.mode, Mode::VisualLine);
|
||||
let context_layer = state.keymap_context_layer();
|
||||
editor.set_keymap_context_layer::<Self>(context_layer, cx);
|
||||
} else {
|
||||
|
@ -333,6 +394,7 @@ impl Vim {
|
|||
editor.set_cursor_shape(CursorShape::Bar, cx);
|
||||
editor.set_clip_at_line_ends(false, cx);
|
||||
editor.set_input_enabled(true);
|
||||
editor.set_autoindent(true);
|
||||
editor.selections.line_mode = false;
|
||||
|
||||
// we set the VimEnabled context on all editors so that we
|
||||
|
@ -365,10 +427,14 @@ impl Setting for VimModeSetting {
|
|||
}
|
||||
}
|
||||
|
||||
fn local_selections_changed(newest_empty: bool, cx: &mut WindowContext) {
|
||||
fn local_selections_changed(newest: Selection<usize>, cx: &mut WindowContext) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
if vim.enabled && vim.state.mode == Mode::Normal && !newest_empty {
|
||||
vim.switch_mode(Mode::Visual { line: false }, false, cx)
|
||||
if vim.enabled && vim.state().mode == Mode::Normal && !newest.is_empty() {
|
||||
if matches!(newest.goal, SelectionGoal::ColumnRange { .. }) {
|
||||
vim.switch_mode(Mode::VisualBlock, false, cx);
|
||||
} else {
|
||||
vim.switch_mode(Mode::Visual, false, cx)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
use std::{borrow::Cow, sync::Arc};
|
||||
use std::{borrow::Cow, cmp, sync::Arc};
|
||||
|
||||
use collections::HashMap;
|
||||
use editor::{
|
||||
display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, Bias, ClipboardSelection,
|
||||
display_map::{DisplaySnapshot, ToDisplayPoint},
|
||||
movement,
|
||||
scroll::autoscroll::Autoscroll,
|
||||
Bias, ClipboardSelection, DisplayPoint, Editor,
|
||||
};
|
||||
use gpui::{actions, AppContext, ViewContext, WindowContext};
|
||||
use language::{AutoindentMode, SelectionGoal};
|
||||
use language::{AutoindentMode, Selection, SelectionGoal};
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::{
|
||||
|
@ -21,6 +24,7 @@ actions!(
|
|||
[
|
||||
ToggleVisual,
|
||||
ToggleVisualLine,
|
||||
ToggleVisualBlock,
|
||||
VisualDelete,
|
||||
VisualYank,
|
||||
VisualPaste,
|
||||
|
@ -29,8 +33,17 @@ actions!(
|
|||
);
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.add_action(toggle_visual);
|
||||
cx.add_action(toggle_visual_line);
|
||||
cx.add_action(|_, _: &ToggleVisual, cx: &mut ViewContext<Workspace>| {
|
||||
toggle_mode(Mode::Visual, cx)
|
||||
});
|
||||
cx.add_action(|_, _: &ToggleVisualLine, cx: &mut ViewContext<Workspace>| {
|
||||
toggle_mode(Mode::VisualLine, cx)
|
||||
});
|
||||
cx.add_action(
|
||||
|_, _: &ToggleVisualBlock, cx: &mut ViewContext<Workspace>| {
|
||||
toggle_mode(Mode::VisualBlock, cx)
|
||||
},
|
||||
);
|
||||
cx.add_action(other_end);
|
||||
cx.add_action(delete);
|
||||
cx.add_action(yank);
|
||||
|
@ -40,59 +53,156 @@ pub fn init(cx: &mut AppContext) {
|
|||
pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
vim.update_active_editor(cx, |editor, cx| {
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
let was_reversed = selection.reversed;
|
||||
if vim.state().mode == Mode::VisualBlock && !matches!(motion, Motion::EndOfLine) {
|
||||
let is_up_or_down = matches!(motion, Motion::Up | Motion::Down);
|
||||
visual_block_motion(is_up_or_down, editor, cx, |map, point, goal| {
|
||||
motion.move_point(map, point, goal, times)
|
||||
})
|
||||
} else {
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
let was_reversed = selection.reversed;
|
||||
let mut current_head = selection.head();
|
||||
|
||||
let mut current_head = selection.head();
|
||||
// our motions assume the current character is after the cursor,
|
||||
// but in (forward) visual mode the current character is just
|
||||
// before the end of the selection.
|
||||
|
||||
// our motions assume the current character is after the cursor,
|
||||
// but in (forward) visual mode the current character is just
|
||||
// before the end of the selection.
|
||||
// If the file ends with a newline (which is common) we don't do this.
|
||||
// so that if you go to the end of such a file you can use "up" to go
|
||||
// to the previous line and have it work somewhat as expected.
|
||||
if !selection.reversed
|
||||
&& !selection.is_empty()
|
||||
&& !(selection.end.column() == 0 && selection.end == map.max_point())
|
||||
{
|
||||
current_head = movement::left(map, selection.end)
|
||||
}
|
||||
|
||||
// If the file ends with a newline (which is common) we don't do this.
|
||||
// so that if you go to the end of such a file you can use "up" to go
|
||||
// to the previous line and have it work somewhat as expected.
|
||||
if !selection.reversed
|
||||
&& !selection.is_empty()
|
||||
&& !(selection.end.column() == 0 && selection.end == map.max_point())
|
||||
{
|
||||
current_head = movement::left(map, selection.end)
|
||||
}
|
||||
|
||||
let Some((new_head, goal)) =
|
||||
let Some((new_head, goal)) =
|
||||
motion.move_point(map, current_head, selection.goal, times) else { return };
|
||||
|
||||
selection.set_head(new_head, goal);
|
||||
selection.set_head(new_head, goal);
|
||||
|
||||
// ensure the current character is included in the selection.
|
||||
if !selection.reversed {
|
||||
// TODO: maybe try clipping left for multi-buffers
|
||||
let next_point = movement::right(map, selection.end);
|
||||
// ensure the current character is included in the selection.
|
||||
if !selection.reversed {
|
||||
let next_point = if vim.state().mode == Mode::VisualBlock {
|
||||
movement::saturating_right(map, selection.end)
|
||||
} else {
|
||||
movement::right(map, selection.end)
|
||||
};
|
||||
|
||||
if !(next_point.column() == 0 && next_point == map.max_point()) {
|
||||
selection.end = movement::right(map, selection.end)
|
||||
if !(next_point.column() == 0 && next_point == map.max_point()) {
|
||||
selection.end = next_point;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// vim always ensures the anchor character stays selected.
|
||||
// if our selection has reversed, we need to move the opposite end
|
||||
// to ensure the anchor is still selected.
|
||||
if was_reversed && !selection.reversed {
|
||||
selection.start = movement::left(map, selection.start);
|
||||
} else if !was_reversed && selection.reversed {
|
||||
selection.end = movement::right(map, selection.end);
|
||||
}
|
||||
// vim always ensures the anchor character stays selected.
|
||||
// if our selection has reversed, we need to move the opposite end
|
||||
// to ensure the anchor is still selected.
|
||||
if was_reversed && !selection.reversed {
|
||||
selection.start = movement::left(map, selection.start);
|
||||
} else if !was_reversed && selection.reversed {
|
||||
selection.end = movement::right(map, selection.end);
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn visual_block_motion(
|
||||
preserve_goal: bool,
|
||||
editor: &mut Editor,
|
||||
cx: &mut ViewContext<Editor>,
|
||||
mut move_selection: impl FnMut(
|
||||
&DisplaySnapshot,
|
||||
DisplayPoint,
|
||||
SelectionGoal,
|
||||
) -> Option<(DisplayPoint, SelectionGoal)>,
|
||||
) {
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
let map = &s.display_map();
|
||||
let mut head = s.newest_anchor().head().to_display_point(map);
|
||||
let mut tail = s.oldest_anchor().tail().to_display_point(map);
|
||||
let mut goal = s.newest_anchor().goal;
|
||||
|
||||
let was_reversed = tail.column() > head.column();
|
||||
|
||||
if !was_reversed && !preserve_goal {
|
||||
head = movement::saturating_left(map, head);
|
||||
}
|
||||
|
||||
let Some((new_head, _)) = move_selection(&map, head, goal) else {
|
||||
return
|
||||
};
|
||||
head = new_head;
|
||||
|
||||
let is_reversed = tail.column() > head.column();
|
||||
if was_reversed && !is_reversed {
|
||||
tail = movement::left(map, tail)
|
||||
} else if !was_reversed && is_reversed {
|
||||
tail = movement::right(map, tail)
|
||||
}
|
||||
if !is_reversed && !preserve_goal {
|
||||
head = movement::saturating_right(map, head)
|
||||
}
|
||||
|
||||
let (start, end) = match goal {
|
||||
SelectionGoal::ColumnRange { start, end } if preserve_goal => (start, end),
|
||||
SelectionGoal::Column(start) if preserve_goal => (start, start + 1),
|
||||
_ => (tail.column(), head.column()),
|
||||
};
|
||||
goal = SelectionGoal::ColumnRange { start, end };
|
||||
|
||||
let columns = if is_reversed {
|
||||
head.column()..tail.column()
|
||||
} else if head.column() == tail.column() {
|
||||
head.column()..(head.column() + 1)
|
||||
} else {
|
||||
tail.column()..head.column()
|
||||
};
|
||||
|
||||
let mut selections = Vec::new();
|
||||
let mut row = tail.row();
|
||||
|
||||
loop {
|
||||
let start = map.clip_point(DisplayPoint::new(row, columns.start), Bias::Left);
|
||||
let end = map.clip_point(DisplayPoint::new(row, columns.end), Bias::Left);
|
||||
if columns.start <= map.line_len(row) {
|
||||
let selection = Selection {
|
||||
id: s.new_selection_id(),
|
||||
start: start.to_point(map),
|
||||
end: end.to_point(map),
|
||||
reversed: is_reversed,
|
||||
goal: goal.clone(),
|
||||
};
|
||||
|
||||
selections.push(selection);
|
||||
}
|
||||
if row == head.row() {
|
||||
break;
|
||||
}
|
||||
if tail.row() > head.row() {
|
||||
row -= 1
|
||||
} else {
|
||||
row += 1
|
||||
}
|
||||
}
|
||||
|
||||
s.select(selections);
|
||||
})
|
||||
}
|
||||
|
||||
pub fn visual_object(object: Object, cx: &mut WindowContext) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
if let Some(Operator::Object { around }) = vim.active_operator() {
|
||||
vim.pop_operator(cx);
|
||||
let current_mode = vim.state().mode;
|
||||
let target_mode = object.target_visual_mode(current_mode);
|
||||
if target_mode != current_mode {
|
||||
vim.switch_mode(target_mode, true, cx);
|
||||
}
|
||||
|
||||
vim.update_active_editor(cx, |editor, cx| {
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
|
@ -108,20 +218,21 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
|
|||
|
||||
if let Some(range) = object.range(map, head, around) {
|
||||
if !range.is_empty() {
|
||||
let expand_both_ways = if selection.is_empty() {
|
||||
true
|
||||
// contains only one character
|
||||
} else if let Some((_, start)) =
|
||||
map.reverse_chars_at(selection.end).next()
|
||||
{
|
||||
selection.start == start
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let expand_both_ways =
|
||||
if object.always_expands_both_ways() || selection.is_empty() {
|
||||
true
|
||||
// contains only one character
|
||||
} else if let Some((_, start)) =
|
||||
map.reverse_chars_at(selection.end).next()
|
||||
{
|
||||
selection.start == start
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if expand_both_ways {
|
||||
selection.start = range.start;
|
||||
selection.end = range.end;
|
||||
selection.start = cmp::min(selection.start, range.start);
|
||||
selection.end = cmp::max(selection.end, range.end);
|
||||
} else if selection.reversed {
|
||||
selection.start = range.start;
|
||||
} else {
|
||||
|
@ -136,28 +247,12 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
|
|||
});
|
||||
}
|
||||
|
||||
pub fn toggle_visual(_: &mut Workspace, _: &ToggleVisual, cx: &mut ViewContext<Workspace>) {
|
||||
Vim::update(cx, |vim, cx| match vim.state.mode {
|
||||
Mode::Normal | Mode::Insert | Mode::Visual { line: true } => {
|
||||
vim.switch_mode(Mode::Visual { line: false }, false, cx);
|
||||
}
|
||||
Mode::Visual { line: false } => {
|
||||
vim.switch_mode(Mode::Normal, false, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn toggle_visual_line(
|
||||
_: &mut Workspace,
|
||||
_: &ToggleVisualLine,
|
||||
cx: &mut ViewContext<Workspace>,
|
||||
) {
|
||||
Vim::update(cx, |vim, cx| match vim.state.mode {
|
||||
Mode::Normal | Mode::Insert | Mode::Visual { line: false } => {
|
||||
vim.switch_mode(Mode::Visual { line: true }, false, cx);
|
||||
}
|
||||
Mode::Visual { line: true } => {
|
||||
fn toggle_mode(mode: Mode, cx: &mut ViewContext<Workspace>) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
if vim.state().mode == mode {
|
||||
vim.switch_mode(Mode::Normal, false, cx);
|
||||
} else {
|
||||
vim.switch_mode(mode, false, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -180,34 +275,39 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspac
|
|||
let mut original_columns: HashMap<_, _> = Default::default();
|
||||
let line_mode = editor.selections.line_mode;
|
||||
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
if line_mode {
|
||||
let mut position = selection.head();
|
||||
if !selection.reversed {
|
||||
position = movement::left(map, position);
|
||||
editor.transact(cx, |editor, cx| {
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
if line_mode {
|
||||
let mut position = selection.head();
|
||||
if !selection.reversed {
|
||||
position = movement::left(map, position);
|
||||
}
|
||||
original_columns.insert(selection.id, position.to_point(map).column);
|
||||
}
|
||||
original_columns.insert(selection.id, position.to_point(map).column);
|
||||
}
|
||||
selection.goal = SelectionGoal::None;
|
||||
selection.goal = SelectionGoal::None;
|
||||
});
|
||||
});
|
||||
});
|
||||
copy_selections_content(editor, line_mode, cx);
|
||||
editor.insert("", cx);
|
||||
copy_selections_content(editor, line_mode, cx);
|
||||
editor.insert("", cx);
|
||||
|
||||
// Fixup cursor position after the deletion
|
||||
editor.set_clip_at_line_ends(true, cx);
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
let mut cursor = selection.head().to_point(map);
|
||||
// Fixup cursor position after the deletion
|
||||
editor.set_clip_at_line_ends(true, cx);
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
let mut cursor = selection.head().to_point(map);
|
||||
|
||||
if let Some(column) = original_columns.get(&selection.id) {
|
||||
cursor.column = *column
|
||||
if let Some(column) = original_columns.get(&selection.id) {
|
||||
cursor.column = *column
|
||||
}
|
||||
let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
|
||||
selection.collapse_to(cursor, selection.goal)
|
||||
});
|
||||
if vim.state().mode == Mode::VisualBlock {
|
||||
s.select_anchors(vec![s.first_anchor()])
|
||||
}
|
||||
let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
|
||||
selection.collapse_to(cursor, selection.goal)
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
vim.switch_mode(Mode::Normal, true, cx);
|
||||
});
|
||||
|
@ -222,6 +322,9 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>)
|
|||
s.move_with(|_, selection| {
|
||||
selection.collapse_to(selection.start, SelectionGoal::None)
|
||||
});
|
||||
if vim.state().mode == Mode::VisualBlock {
|
||||
s.select_anchors(vec![s.first_anchor()])
|
||||
}
|
||||
});
|
||||
});
|
||||
vim.switch_mode(Mode::Normal, true, cx);
|
||||
|
@ -701,7 +804,7 @@ mod test {
|
|||
The quick brown
|
||||
fox «jumpsˇ» over
|
||||
the lazy dog"},
|
||||
Mode::Visual { line: false },
|
||||
Mode::Visual,
|
||||
);
|
||||
cx.simulate_keystroke("y");
|
||||
cx.set_state(
|
||||
|
@ -725,7 +828,7 @@ mod test {
|
|||
The quick brown
|
||||
fox ju«mˇ»ps over
|
||||
the lazy dog"},
|
||||
Mode::Visual { line: true },
|
||||
Mode::VisualLine,
|
||||
);
|
||||
cx.simulate_keystroke("d");
|
||||
cx.assert_state(
|
||||
|
@ -738,7 +841,7 @@ mod test {
|
|||
indoc! {"
|
||||
The quick brown
|
||||
the «lazyˇ» dog"},
|
||||
Mode::Visual { line: false },
|
||||
Mode::Visual,
|
||||
);
|
||||
cx.simulate_keystroke("p");
|
||||
cx.assert_state(
|
||||
|
@ -751,4 +854,218 @@ mod test {
|
|||
Mode::Normal,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_visual_block_mode(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.set_shared_state(indoc! {
|
||||
"The ˇquick brown
|
||||
fox jumps over
|
||||
the lazy dog"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["ctrl-v"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The «qˇ»uick brown
|
||||
fox jumps over
|
||||
the lazy dog"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["2", "down"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The «qˇ»uick brown
|
||||
fox «jˇ»umps over
|
||||
the «lˇ»azy dog"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["e"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The «quicˇ»k brown
|
||||
fox «jumpˇ»s over
|
||||
the «lazyˇ» dog"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["^"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"«ˇThe q»uick brown
|
||||
«ˇfox j»umps over
|
||||
«ˇthe l»azy dog"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["$"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The «quick brownˇ»
|
||||
fox «jumps overˇ»
|
||||
the «lazy dogˇ»"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["shift-f", " "]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The «quickˇ» brown
|
||||
fox «jumpsˇ» over
|
||||
the «lazy ˇ»dog"
|
||||
})
|
||||
.await;
|
||||
|
||||
// toggling through visual mode works as expected
|
||||
cx.simulate_shared_keystrokes(["v"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The «quick brown
|
||||
fox jumps over
|
||||
the lazy ˇ»dog"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["ctrl-v"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The «quickˇ» brown
|
||||
fox «jumpsˇ» over
|
||||
the «lazy ˇ»dog"
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.set_shared_state(indoc! {
|
||||
"The ˇquick
|
||||
brown
|
||||
fox
|
||||
jumps over the
|
||||
|
||||
lazy dog
|
||||
"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["ctrl-v", "down", "down"])
|
||||
.await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The«ˇ q»uick
|
||||
bro«ˇwn»
|
||||
foxˇ
|
||||
jumps over the
|
||||
|
||||
lazy dog
|
||||
"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["down"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The «qˇ»uick
|
||||
brow«nˇ»
|
||||
fox
|
||||
jump«sˇ» over the
|
||||
|
||||
lazy dog
|
||||
"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystroke("left").await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"The«ˇ q»uick
|
||||
bro«ˇwn»
|
||||
foxˇ
|
||||
jum«ˇps» over the
|
||||
|
||||
lazy dog
|
||||
"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["s", "o", "escape"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"Theˇouick
|
||||
broo
|
||||
foxo
|
||||
jumo over the
|
||||
|
||||
lazy dog
|
||||
"
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_visual_block_insert(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.set_shared_state(indoc! {
|
||||
"ˇThe quick brown
|
||||
fox jumps over
|
||||
the lazy dog
|
||||
"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["ctrl-v", "9", "down"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"«Tˇ»he quick brown
|
||||
«fˇ»ox jumps over
|
||||
«tˇ»he lazy dog
|
||||
ˇ"
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.simulate_shared_keystrokes(["shift-i", "k", "escape"])
|
||||
.await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"ˇkThe quick brown
|
||||
kfox jumps over
|
||||
kthe lazy dog
|
||||
k"
|
||||
})
|
||||
.await;
|
||||
|
||||
cx.set_shared_state(indoc! {
|
||||
"ˇThe quick brown
|
||||
fox jumps over
|
||||
the lazy dog
|
||||
"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["ctrl-v", "9", "down"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"«Tˇ»he quick brown
|
||||
«fˇ»ox jumps over
|
||||
«tˇ»he lazy dog
|
||||
ˇ"
|
||||
})
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes(["c", "k", "escape"]).await;
|
||||
cx.assert_shared_state(indoc! {
|
||||
"ˇkhe quick brown
|
||||
kox jumps over
|
||||
khe lazy dog
|
||||
k"
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_visual_object(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.set_shared_state("hello (in [parˇens] o)").await;
|
||||
cx.simulate_shared_keystrokes(["ctrl-v", "l"]).await;
|
||||
cx.simulate_shared_keystrokes(["a", "]"]).await;
|
||||
cx.assert_shared_state("hello (in «[parens]ˇ» o)").await;
|
||||
assert_eq!(cx.mode(), Mode::Visual);
|
||||
cx.simulate_shared_keystrokes(["i", "("]).await;
|
||||
cx.assert_shared_state("hello («in [parens] oˇ»)").await;
|
||||
|
||||
cx.set_shared_state("hello in a wˇord again.").await;
|
||||
cx.simulate_shared_keystrokes(["ctrl-v", "l", "i", "w"])
|
||||
.await;
|
||||
cx.assert_shared_state("hello in a w«ordˇ» again.").await;
|
||||
assert_eq!(cx.mode(), Mode::VisualBlock);
|
||||
cx.simulate_shared_keystrokes(["o", "a", "s"]).await;
|
||||
cx.assert_shared_state("«ˇhello in a word» again.").await;
|
||||
assert_eq!(cx.mode(), Mode::Visual);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_mode_across_command(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
|
||||
cx.set_state("aˇbc", Mode::Normal);
|
||||
cx.simulate_keystrokes(["ctrl-v"]);
|
||||
assert_eq!(cx.mode(), Mode::VisualBlock);
|
||||
cx.simulate_keystrokes(["cmd-shift-p", "escape"]);
|
||||
assert_eq!(cx.mode(), Mode::VisualBlock);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
|
||||
{"Key":"shift-v"}
|
||||
{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":{"Visual":{"line":true}}}}
|
||||
{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":"VisualLine"}}
|
||||
{"Key":"x"}
|
||||
{"Get":{"state":"fox ˇjumps over\nthe lazy dog","mode":"Normal"}}
|
||||
{"Put":{"state":"a\nˇ\nb"}}
|
||||
{"Key":"shift-v"}
|
||||
{"Get":{"state":"a\n«\nˇ»b","mode":{"Visual":{"line":true}}}}
|
||||
{"Get":{"state":"a\n«\nˇ»b","mode":"VisualLine"}}
|
||||
{"Key":"x"}
|
||||
{"Get":{"state":"a\nˇb","mode":"Normal"}}
|
||||
{"Put":{"state":"a\nb\nˇ"}}
|
||||
{"Key":"shift-v"}
|
||||
{"Get":{"state":"a\nb\nˇ","mode":{"Visual":{"line":true}}}}
|
||||
{"Get":{"state":"a\nb\nˇ","mode":"VisualLine"}}
|
||||
{"Key":"x"}
|
||||
{"Get":{"state":"a\nˇb","mode":"Normal"}}
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
|
||||
{"Key":"v"}
|
||||
{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":"Visual"}}
|
||||
{"Key":"w"}
|
||||
{"Key":"j"}
|
||||
{"Get":{"state":"The «quick brown\nfox jumps oˇ»ver\nthe lazy dog","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The «quick brown\nfox jumps oˇ»ver\nthe lazy dog","mode":"Visual"}}
|
||||
{"Key":"escape"}
|
||||
{"Get":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog","mode":"Normal"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"k"}
|
||||
{"Key":"b"}
|
||||
{"Get":{"state":"The «ˇquick brown\nfox jumps o»ver\nthe lazy dog","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The «ˇquick brown\nfox jumps o»ver\nthe lazy dog","mode":"Visual"}}
|
||||
{"Put":{"state":"a\nˇ\nb\n"}}
|
||||
{"Key":"v"}
|
||||
{"Get":{"state":"a\n«\nˇ»b\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"a\n«\nˇ»b\n","mode":"Visual"}}
|
||||
{"Key":"v"}
|
||||
{"Get":{"state":"a\nˇ\nb\n","mode":"Normal"}}
|
||||
{"Put":{"state":"a\nb\nˇ"}}
|
||||
{"Key":"v"}
|
||||
{"Get":{"state":"a\nb\nˇ","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"a\nb\nˇ","mode":"Visual"}}
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"{"}
|
||||
{"Get":{"state":"func empty(a string) bool {\n« if a == \"\" {\n return true\n }\n return false\nˇ»}","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"func empty(a string) bool {\n« if a == \"\" {\n return true\n }\n return false\nˇ»}","mode":"Visual"}}
|
||||
{"Put":{"state":"func empty(a string) bool {\n if a == \"\" {\n ˇreturn true\n }\n return false\n}"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"{"}
|
||||
{"Get":{"state":"func empty(a string) bool {\n if a == \"\" {\n« return true\nˇ» }\n return false\n}","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"func empty(a string) bool {\n if a == \"\" {\n« return true\nˇ» }\n return false\n}","mode":"Visual"}}
|
||||
|
|
3
crates/vim/test_data/test_next_line_start.json
Normal file
3
crates/vim/test_data/test_next_line_start.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{"Put":{"state":"ˇone\n two\nthree"}}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"one\n ˇtwo\nthree","mode":"Normal"}}
|
18
crates/vim/test_data/test_visual_block_insert.json
Normal file
18
crates/vim/test_data/test_visual_block_insert.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy dog\n"}}
|
||||
{"Key":"ctrl-v"}
|
||||
{"Key":"9"}
|
||||
{"Key":"down"}
|
||||
{"Get":{"state":"«Tˇ»he quick brown\n«fˇ»ox jumps over\n«tˇ»he lazy dog\nˇ","mode":"VisualBlock"}}
|
||||
{"Key":"shift-i"}
|
||||
{"Key":"k"}
|
||||
{"Key":"escape"}
|
||||
{"Get":{"state":"ˇkThe quick brown\nkfox jumps over\nkthe lazy dog\nk","mode":"Normal"}}
|
||||
{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy dog\n"}}
|
||||
{"Key":"ctrl-v"}
|
||||
{"Key":"9"}
|
||||
{"Key":"down"}
|
||||
{"Get":{"state":"«Tˇ»he quick brown\n«fˇ»ox jumps over\n«tˇ»he lazy dog\nˇ","mode":"VisualBlock"}}
|
||||
{"Key":"c"}
|
||||
{"Key":"k"}
|
||||
{"Key":"escape"}
|
||||
{"Get":{"state":"ˇkhe quick brown\nkox jumps over\nkhe lazy dog\nk","mode":"Normal"}}
|
32
crates/vim/test_data/test_visual_block_mode.json
Normal file
32
crates/vim/test_data/test_visual_block_mode.json
Normal file
|
@ -0,0 +1,32 @@
|
|||
{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
|
||||
{"Key":"ctrl-v"}
|
||||
{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":"VisualBlock"}}
|
||||
{"Key":"2"}
|
||||
{"Key":"down"}
|
||||
{"Get":{"state":"The «qˇ»uick brown\nfox «jˇ»umps over\nthe «lˇ»azy dog","mode":"VisualBlock"}}
|
||||
{"Key":"e"}
|
||||
{"Get":{"state":"The «quicˇ»k brown\nfox «jumpˇ»s over\nthe «lazyˇ» dog","mode":"VisualBlock"}}
|
||||
{"Key":"^"}
|
||||
{"Get":{"state":"«ˇThe q»uick brown\n«ˇfox j»umps over\n«ˇthe l»azy dog","mode":"VisualBlock"}}
|
||||
{"Key":"$"}
|
||||
{"Get":{"state":"The «quick brownˇ»\nfox «jumps overˇ»\nthe «lazy dogˇ»","mode":"VisualBlock"}}
|
||||
{"Key":"shift-f"}
|
||||
{"Key":" "}
|
||||
{"Get":{"state":"The «quickˇ» brown\nfox «jumpsˇ» over\nthe «lazy ˇ»dog","mode":"VisualBlock"}}
|
||||
{"Key":"v"}
|
||||
{"Get":{"state":"The «quick brown\nfox jumps over\nthe lazy ˇ»dog","mode":"Visual"}}
|
||||
{"Key":"ctrl-v"}
|
||||
{"Get":{"state":"The «quickˇ» brown\nfox «jumpsˇ» over\nthe «lazy ˇ»dog","mode":"VisualBlock"}}
|
||||
{"Put":{"state":"The ˇquick\nbrown\nfox\njumps over the\n\nlazy dog\n"}}
|
||||
{"Key":"ctrl-v"}
|
||||
{"Key":"down"}
|
||||
{"Key":"down"}
|
||||
{"Get":{"state":"The«ˇ q»uick\nbro«ˇwn»\nfoxˇ\njumps over the\n\nlazy dog\n","mode":"VisualBlock"}}
|
||||
{"Key":"down"}
|
||||
{"Get":{"state":"The «qˇ»uick\nbrow«nˇ»\nfox\njump«sˇ» over the\n\nlazy dog\n","mode":"VisualBlock"}}
|
||||
{"Key":"left"}
|
||||
{"Get":{"state":"The«ˇ q»uick\nbro«ˇwn»\nfoxˇ\njum«ˇps» over the\n\nlazy dog\n","mode":"VisualBlock"}}
|
||||
{"Key":"s"}
|
||||
{"Key":"o"}
|
||||
{"Key":"escape"}
|
||||
{"Get":{"state":"Theˇouick\nbroo\nfoxo\njumo over the\n\nlazy dog\n","mode":"Normal"}}
|
|
@ -1,7 +1,7 @@
|
|||
{"Put":{"state":"The quick ˇbrown"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick «brownˇ»","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick «brownˇ»","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick ˇbrown"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"w"}
|
||||
|
|
19
crates/vim/test_data/test_visual_object.json
Normal file
19
crates/vim/test_data/test_visual_object.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{"Put":{"state":"hello (in [parˇens] o)"}}
|
||||
{"Key":"ctrl-v"}
|
||||
{"Key":"l"}
|
||||
{"Key":"a"}
|
||||
{"Key":"]"}
|
||||
{"Get":{"state":"hello (in «[parens]ˇ» o)","mode":"Visual"}}
|
||||
{"Key":"i"}
|
||||
{"Key":"("}
|
||||
{"Get":{"state":"hello («in [parens] oˇ»)","mode":"Visual"}}
|
||||
{"Put":{"state":"hello in a wˇord again."}}
|
||||
{"Key":"ctrl-v"}
|
||||
{"Key":"l"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"hello in a w«ordˇ» again.","mode":"VisualBlock"}}
|
||||
{"Key":"o"}
|
||||
{"Key":"a"}
|
||||
{"Key":"s"}
|
||||
{"Get":{"state":"«ˇhello in a word» again.","mode":"VisualBlock"}}
|
|
@ -1,236 +1,236 @@
|
|||
{"Put":{"state":"The quick ˇbrown\nfox"}}
|
||||
{"Key":"v"}
|
||||
{"Get":{"state":"The quick «bˇ»rown\nfox","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick «bˇ»rown\nfox","mode":"Visual"}}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick «brownˇ»\nfox","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick «brownˇ»\nfox","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick ˇbrown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick browˇn \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brownˇ \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown« ˇ»\nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown« ˇ»\nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox ˇjumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox juˇmps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps« ˇ»over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps« ˇ»over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dogˇ \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ»\n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ»\n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n«\nˇ»\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n«\nˇ»\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n«\nˇ»\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n«\nˇ»\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n«\nˇ»The-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n«\nˇ»The-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThˇe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«Theˇ»-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«Theˇ»-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nTheˇ-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe«-ˇ»quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe«-ˇ»quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-ˇquick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quˇick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick« ˇ»brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick« ˇ»brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick ˇbrown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «brownˇ» \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «brownˇ» \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown« ˇ»\n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown« ˇ»\n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \nˇ \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ»\n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ»\n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \nˇ \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ»\n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ»\n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \nˇ fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ»fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ»fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumpˇs over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-«jumpsˇ» over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-«jumpsˇ» over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n«\nˇ»","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n«\nˇ»","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick ˇbrown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick browˇn \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brownˇ \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown« ˇ»\nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown« ˇ»\nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox ˇjumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox juˇmps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps« ˇ»over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps« ˇ»over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dogˇ \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ»\n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ»\n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n«\nˇ»\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n«\nˇ»\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n«\nˇ»\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n«\nˇ»\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n«\nˇ»The-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n«\nˇ»The-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThˇe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nTheˇ-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-ˇquick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quˇick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick« ˇ»brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick« ˇ»brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick ˇbrown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «brownˇ» \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «brownˇ» \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown« ˇ»\n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown« ˇ»\n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \nˇ \n \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ»\n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ»\n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \nˇ \n fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ»\n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ»\n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \nˇ fox-jumps over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ»fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ»fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumpˇs over\nthe lazy dog \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n «fox-jumpsˇ» over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n «fox-jumpsˇ» over\nthe lazy dog \n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":"Visual"}}
|
||||
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n"}}
|
||||
{"Key":"v"}
|
||||
{"Key":"i"}
|
||||
{"Key":"shift-w"}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n«\nˇ»","mode":{"Visual":{"line":false}}}}
|
||||
{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n«\nˇ»","mode":"Visual"}}
|
||||
|
|
|
@ -18,11 +18,7 @@
|
|||
<true/>
|
||||
<key>com.apple.security.personal-information.photos-library</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- <key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/> -->
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue